yak(behavior-tests-blogex-public-api-and-router-plumbing-q1fb): behavior tests: blogex public API and router plumbing

This commit is contained in:
Willem van den Ende
2026-10-28 22:15:02 +00:00
parent c04d8c856c
commit cc03cefe8d
9 changed files with 211 additions and 46 deletions
+4 -4
View File
@@ -232,10 +232,10 @@ defmodule Blogex.LinkValidator do
@doc false @doc false
@spec slug_end(String.t()) :: integer() @spec slug_end(String.t()) :: integer()
defp slug_end(str) do defp slug_end(str) do
case String.split(str, ["?", "#"], parts: 2) do # `String.split/3` always returns a non-empty list whose head is a binary,
[slug | _] -> String.length(slug) # so there is no empty/other case to handle here.
_ -> String.length(str) [slug | _] = String.split(str, ["?", "#"], parts: 2)
end String.length(slug)
end end
@doc false @doc false
+11 -7
View File
@@ -56,18 +56,22 @@ defmodule Blogex.MixProject do
# in blogex/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks # 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. # 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 # same via `mix test --cover` and via `mix test --cover --export-coverage
# default` + `mix test.coverage`. Floor is the integer below that. # default` + `mix test.coverage`. Floor is the integer below that.
# #
# Excluded: the test scaffolding (Blogex.Test.*) only. Fixtures measure the # Excluded: the test scaffolding in `ignore_modules` below. The two registry
# tests, not the library. Everything in lib/blogex counts, and today's gaps # blog doubles (Blogex.Test.AlphaBlog/BetaBlog) are deliberately not listed
# are real: Blogex (the delegating facade), Blogex.Components and # there: they are fully exercised, and excluding them measures 78.06% — the
# Blogex.Layout are at 0% because the router tests only exercise the JSON # 78 floor holds either way.
# responses — moving this floor up is the behavior-test work. #
# 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 defp test_coverage do
[ [
summary: [threshold: 74], summary: [threshold: 78],
ignore_modules: [ ignore_modules: [
Blogex.Test.FakeBlog, Blogex.Test.FakeBlog,
Blogex.Test.PostBuilder, Blogex.Test.PostBuilder,
@@ -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.
@@ -127,4 +127,41 @@ defmodule Blogex.BlogIntegrationTest do
File.rm!(tmp_file) File.rm!(tmp_file)
end end
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 end
@@ -324,4 +324,18 @@ defmodule Blogex.LinkValidatorTest do
} }
end end
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 end
+1 -35
View File
@@ -2,41 +2,7 @@ defmodule Blogex.RegistryTest do
use ExUnit.Case use ExUnit.Case
alias Blogex.Registry alias Blogex.Registry
alias Blogex.Test.{AlphaBlog, BetaBlog}
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
setup do setup do
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog]) Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
+20
View File
@@ -153,6 +153,26 @@ defmodule Blogex.RouterTest do
end end
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 defp get_content_type(conn) do
conn conn
|> Plug.Conn.get_resp_header("content-type") |> Plug.Conn.get_resp_header("content-type")
+67
View File
@@ -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
+49
View File
@@ -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