From cc03cefe8d5abadfdb4ffce7c380155d2e11629c Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 28 Oct 2026 22:15:02 +0000 Subject: [PATCH] yak(behavior-tests-blogex-public-api-and-router-plumbing-q1fb): behavior tests: blogex public API and router plumbing --- blogex/lib/blogex/link_validator.ex | 8 +-- blogex/mix.exs | 18 +++-- .../priv/blog/test/2026/01-03-draft-post.md | 8 +++ blogex/test/blogex/blog_integration_test.exs | 37 ++++++++++ blogex/test/blogex/link_validator_test.exs | 14 ++++ blogex/test/blogex/registry_test.exs | 36 +--------- blogex/test/blogex/router_test.exs | 20 ++++++ blogex/test/blogex_test.exs | 67 +++++++++++++++++++ blogex/test/support/blogex_test_blogs.ex | 49 ++++++++++++++ 9 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 blogex/priv/blog/test/2026/01-03-draft-post.md create mode 100644 blogex/test/blogex_test.exs create mode 100644 blogex/test/support/blogex_test_blogs.ex diff --git a/blogex/lib/blogex/link_validator.ex b/blogex/lib/blogex/link_validator.ex index e363415..0a62b00 100644 --- a/blogex/lib/blogex/link_validator.ex +++ b/blogex/lib/blogex/link_validator.ex @@ -232,10 +232,10 @@ defmodule Blogex.LinkValidator do @doc false @spec slug_end(String.t()) :: integer() defp slug_end(str) do - case String.split(str, ["?", "#"], parts: 2) do - [slug | _] -> String.length(slug) - _ -> String.length(str) - end + # `String.split/3` always returns a non-empty list whose head is a binary, + # so there is no empty/other case to handle here. + [slug | _] = String.split(str, ["?", "#"], parts: 2) + String.length(slug) end @doc false diff --git a/blogex/mix.exs b/blogex/mix.exs index d867a65..7e94fbb 100644 --- a/blogex/mix.exs +++ b/blogex/mix.exs @@ -56,18 +56,22 @@ defmodule Blogex.MixProject do # in blogex/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks # land; never lower it. A drop means the change (or an exclusion) is wrong. # - # Measured 2026-10-26, with the test scaffolding below excluded: 74.53% — the + # Measured 2026-10-28, with the test scaffolding below excluded: 78.59% — the # same via `mix test --cover` and via `mix test --cover --export-coverage # default` + `mix test.coverage`. Floor is the integer below that. # - # Excluded: the test scaffolding (Blogex.Test.*) only. Fixtures measure the - # tests, not the library. Everything in lib/blogex counts, and today's gaps - # are real: Blogex (the delegating facade), Blogex.Components and - # Blogex.Layout are at 0% because the router tests only exercise the JSON - # responses — moving this floor up is the behavior-test work. + # Excluded: the test scaffolding in `ignore_modules` below. The two registry + # blog doubles (Blogex.Test.AlphaBlog/BetaBlog) are deliberately not listed + # there: they are fully exercised, and excluding them measures 78.06% — the + # 78 floor holds either way. + # + # Everything in lib/blogex counts, and today's gaps are real: + # Blogex.Components and Blogex.Layout are at 0% because the router tests only + # exercise the JSON responses — covering the rendered HTML is its own yak. + # Blogex.Router (72.06%) still has its HTML arms and `match _` uncovered. defp test_coverage do [ - summary: [threshold: 74], + summary: [threshold: 78], ignore_modules: [ Blogex.Test.FakeBlog, Blogex.Test.PostBuilder, diff --git a/blogex/priv/blog/test/2026/01-03-draft-post.md b/blogex/priv/blog/test/2026/01-03-draft-post.md new file mode 100644 index 0000000..14c9fb0 --- /dev/null +++ b/blogex/priv/blog/test/2026/01-03-draft-post.md @@ -0,0 +1,8 @@ +%{ + title: "Draft Post (test fixture)", + author: "Test Author", + description: "A draft used by the show_drafts? tests", + published: false +} +--- +This post is a draft and is hidden unless :show_drafts is on. diff --git a/blogex/test/blogex/blog_integration_test.exs b/blogex/test/blogex/blog_integration_test.exs index 70214a5..647f647 100644 --- a/blogex/test/blogex/blog_integration_test.exs +++ b/blogex/test/blogex/blog_integration_test.exs @@ -127,4 +127,41 @@ defmodule Blogex.BlogIntegrationTest do File.rm!(tmp_file) end end + + describe "show_drafts?/0" do + # The only path through `Blogex.show_drafts?/0` (blogex.ex): `use Blogex.Blog` + # compiles a real module over a real draft fixture, so the flag is read by + # the generated `all_posts/0` rather than asserted in isolation. + # + # One test, one compile: compiling the same module name twice would print a + # "redefining module" warning, and warnings are fatal everywhere in this repo. + test "drafts are hidden by default and visible when :show_drafts is on" do + tmp_file = Path.join(System.tmp_dir!(), "test_blog_show_drafts.ex") + + File.write!(tmp_file, """ + defmodule TestBlogShowDrafts do + use Blogex.Blog, + blog_id: :test, + app: :blogex, + from: "priv/blog/test/2026/01-03-draft-post.md", + title: "Test Blog", + base_path: "/blog/test" + end + """) + + [{TestBlogShowDrafts, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file) + + on_exit(fn -> + File.rm!(tmp_file) + Application.delete_env(:blogex, :show_drafts) + end) + + assert apply(TestBlogShowDrafts, :all_posts, []) == [] + + Application.put_env(:blogex, :show_drafts, true) + + assert [post] = apply(TestBlogShowDrafts, :all_posts, []) + assert post.title == "Draft Post (test fixture)" + end + end end diff --git a/blogex/test/blogex/link_validator_test.exs b/blogex/test/blogex/link_validator_test.exs index 3681c81..b06599b 100644 --- a/blogex/test/blogex/link_validator_test.exs +++ b/blogex/test/blogex/link_validator_test.exs @@ -324,4 +324,18 @@ defmodule Blogex.LinkValidatorTest do } end end + + describe "parse_blog_link/1" do + # `slug_end/1` decides where the slug stops so `?query` / `#fragment` are + # not validated as part of it. Pinned directly on `parse_blog_link/1` + # (both are `@doc false`) because `validate_link/1` only reaches them via + # "valid slug" cases, and a wrong cut would still validate by luck. + test "strips query strings and fragments from the slug" do + assert LinkValidator.parse_blog_link("/blog/engineering/hello-world?utm=1") == + {"engineering", "hello-world"} + + assert LinkValidator.parse_blog_link("/blog/engineering/hello-world#top") == + {"engineering", "hello-world"} + end + end end diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs index bacbbdd..b4401b8 100644 --- a/blogex/test/blogex/registry_test.exs +++ b/blogex/test/blogex/registry_test.exs @@ -2,41 +2,7 @@ defmodule Blogex.RegistryTest do use ExUnit.Case alias Blogex.Registry - - defmodule AlphaBlog do - def blog_id, do: :alpha - - def all_posts, - do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] - - def all_tags, do: ["elixir"] - - def unfiltered_posts, - do: [ - Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha), - Blogex.Test.PostBuilder.build( - id: "a-draft", - date: ~D[2026-03-05], - blog: :alpha, - published: false - ) - ] - end - - defmodule BetaBlog do - def blog_id, do: :beta - - def all_posts, - do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] - - def all_tags, do: ["devops"] - - def unfiltered_posts, - do: [ - Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta), - Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta) - ] - end + alias Blogex.Test.{AlphaBlog, BetaBlog} setup do Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog]) diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs index 90edb51..5b8813a 100644 --- a/blogex/test/blogex/router_test.exs +++ b/blogex/test/blogex/router_test.exs @@ -153,6 +153,26 @@ defmodule Blogex.RouterTest do end end + describe "Helpers.base_url/1" do + # `port_suffix/2` has one clause per (scheme, default port) pair plus the + # catch-all, and the feeds embed this URL. All four shapes are pinned so a + # clause cannot be dropped or reordered without a failure. + test "omits the port for the default scheme ports" do + assert base_url(:http, 80) == "http://www.example.com" + assert base_url(:https, 443) == "https://www.example.com" + end + + test "keeps a non-default port" do + assert base_url(:http, 4000) == "http://www.example.com:4000" + assert base_url(:https, 8443) == "https://www.example.com:8443" + end + + defp base_url(scheme, port) do + %{Plug.Test.conn(:get, "/") | scheme: scheme, port: port} + |> Blogex.Router.Helpers.base_url() + end + end + defp get_content_type(conn) do conn |> Plug.Conn.get_resp_header("content-type") diff --git a/blogex/test/blogex_test.exs b/blogex/test/blogex_test.exs new file mode 100644 index 0000000..e3e4df1 --- /dev/null +++ b/blogex/test/blogex_test.exs @@ -0,0 +1,67 @@ +defmodule BlogexTest do + @moduledoc """ + Behaviour tests for the `Blogex` facade. + + The facade is a thin delegating layer over `Blogex.Registry`, but callers + (Phoenix controllers, LiveViews) only ever see the answers, so these tests + assert the answers: the exact module lists, post orderings and tag sets the + fixtures imply. Asserting `Blogex.x() == Blogex.Registry.x()` would pin the + delegation instead of the behaviour and would keep passing if both sides + drifted together. + + Non-async: the registry is configured through the process-global `:blogex` + application env. + """ + use ExUnit.Case + + alias Blogex.Test.{AlphaBlog, BetaBlog} + + setup do + Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog]) + on_exit(fn -> Application.delete_env(:blogex, :blogs) end) + end + + describe "blogs/0" do + test "returns the configured blog modules" do + assert Blogex.blogs() == [AlphaBlog, BetaBlog] + end + end + + describe "get_blog!/1 and get_blog/1" do + test "return the module registered for a blog_id" do + assert Blogex.get_blog!(:alpha) == AlphaBlog + assert Blogex.get_blog(:beta) == BetaBlog + end + + test "an unknown blog_id returns nil and raises" do + assert Blogex.get_blog(:nope) == nil + + assert_raise Blogex.NotFoundError, fn -> Blogex.get_blog!(:nope) end + end + end + + describe "all_posts/0" do + # Newest first across both blogs: beta's 2026-03-15 post, then alpha's + # 2026-03-01. Drafts (a-draft) and future-dated posts (b-future) are not + # visible to readers, so neither appears. + test "merges published posts from every blog, newest first" do + assert ids(Blogex.all_posts()) == ["b1", "a1"] + end + end + + describe "all_posts_unfiltered/0" do + # Everything, newest first: the 2099 scheduled post, then b1, the alpha + # draft, and alpha's oldest. + test "includes drafts and future-dated posts, newest first" do + assert ids(Blogex.all_posts_unfiltered()) == ["b-future", "b1", "a-draft", "a1"] + end + end + + describe "all_tags/0" do + test "returns the unique tags across blogs, sorted" do + assert Blogex.all_tags() == ["devops", "elixir"] + end + end + + defp ids(posts), do: Enum.map(posts, & &1.id) +end diff --git a/blogex/test/support/blogex_test_blogs.ex b/blogex/test/support/blogex_test_blogs.ex new file mode 100644 index 0000000..f02aae5 --- /dev/null +++ b/blogex/test/support/blogex_test_blogs.ex @@ -0,0 +1,49 @@ +defmodule Blogex.Test.AlphaBlog do + @moduledoc """ + A minimal blog module for cross-blog tests. + + Implements the interface `Blogex.Registry` queries (and nothing else), so a + test can configure `:blogex, :blogs` with real modules without compiling a + `use Blogex.Blog` module or touching the filesystem. + + `all_posts/0`/`unfiltered_posts/0` are hard-coded — the dates are literals so + tests can assert exact orderings rather than re-deriving them. + """ + + def blog_id, do: :alpha + + def all_posts, + do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] + + def all_tags, do: ["elixir"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha), + Blogex.Test.PostBuilder.build( + id: "a-draft", + date: ~D[2026-03-05], + blog: :alpha, + published: false + ) + ] +end + +defmodule Blogex.Test.BetaBlog do + @moduledoc """ + The second blog module for cross-blog tests; see `Blogex.Test.AlphaBlog`. + """ + + def blog_id, do: :beta + + def all_posts, + do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] + + def all_tags, do: ["devops"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta), + Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta) + ] +end