Static blog with front page summary
Goal: have a personal blog, and try out another point in the 'modular app design with elixir' space. Designing OTP systems with elixir had some interesting ideas.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
defmodule Blogex.BlogTest do
|
||||
use ExUnit.Case
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.Test.FakeBlog
|
||||
|
||||
setup do
|
||||
Blogex.Test.Setup.with_blog()
|
||||
end
|
||||
|
||||
describe "all_posts/0" do
|
||||
test "excludes drafts", %{blog: blog} do
|
||||
ids = blog.all_posts() |> Enum.map(& &1.id)
|
||||
|
||||
assert "draft-post" not in ids
|
||||
end
|
||||
|
||||
test "returns posts newest first", %{blog: blog} do
|
||||
dates = blog.all_posts() |> Enum.map(& &1.date)
|
||||
|
||||
assert dates == Enum.sort(dates, {:desc, Date})
|
||||
end
|
||||
end
|
||||
|
||||
describe "recent_posts/1" do
|
||||
test "returns at most n posts", %{blog: blog} do
|
||||
assert length(blog.recent_posts(2)) == 2
|
||||
end
|
||||
|
||||
test "returns newest posts", %{blog: blog} do
|
||||
[first | _] = blog.recent_posts(1)
|
||||
|
||||
assert first.id == "newest-post"
|
||||
end
|
||||
end
|
||||
|
||||
describe "posts_by_tag/1" do
|
||||
test "returns only posts with the given tag", %{blog: blog} do
|
||||
posts = blog.posts_by_tag("testing")
|
||||
|
||||
assert [%{id: "middle-post"}] = posts
|
||||
end
|
||||
|
||||
test "returns empty list for unknown tag", %{blog: blog} do
|
||||
assert blog.posts_by_tag("nonexistent") == []
|
||||
end
|
||||
|
||||
test "excludes drafts even if tag matches" do
|
||||
{:ok, _} = FakeBlog.start([
|
||||
build(id: "pub", tags: ["elixir"], published: true),
|
||||
build(id: "draft", tags: ["elixir"], published: false)
|
||||
])
|
||||
|
||||
ids = FakeBlog.posts_by_tag("elixir") |> Enum.map(& &1.id)
|
||||
|
||||
assert "pub" in ids
|
||||
refute "draft" in ids
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_tags/0" do
|
||||
test "returns unique sorted tags from published posts", %{blog: blog} do
|
||||
tags = blog.all_tags()
|
||||
|
||||
assert tags == Enum.sort(tags)
|
||||
assert "elixir" in tags
|
||||
assert "devops" in tags
|
||||
end
|
||||
|
||||
test "excludes tags only appearing on drafts" do
|
||||
{:ok, _} = FakeBlog.start([
|
||||
build(tags: ["visible"], published: true),
|
||||
build(id: "d", tags: ["hidden"], published: false)
|
||||
])
|
||||
|
||||
refute "hidden" in FakeBlog.all_tags()
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_post!/1" do
|
||||
test "returns post by id", %{blog: blog} do
|
||||
post = blog.get_post!("oldest-post")
|
||||
|
||||
assert post.id == "oldest-post"
|
||||
end
|
||||
|
||||
test "raises for unknown id", %{blog: blog} do
|
||||
assert_raise Blogex.NotFoundError, fn ->
|
||||
blog.get_post!("nope")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises for draft post id", %{blog: blog} do
|
||||
assert_raise Blogex.NotFoundError, fn ->
|
||||
blog.get_post!("draft-post")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_post/1" do
|
||||
test "returns nil for unknown id", %{blog: blog} do
|
||||
assert blog.get_post("nope") == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "paginate/2" do
|
||||
setup do
|
||||
posts = build_many(25)
|
||||
{:ok, _} = FakeBlog.start(posts)
|
||||
%{blog: FakeBlog}
|
||||
end
|
||||
|
||||
test "returns the correct page size", %{blog: blog} do
|
||||
result = blog.paginate(1, 10)
|
||||
|
||||
assert length(result.entries) == 10
|
||||
end
|
||||
|
||||
test "calculates total pages", %{blog: blog} do
|
||||
result = blog.paginate(1, 10)
|
||||
|
||||
assert result.total_pages == 3
|
||||
assert result.total_entries == 25
|
||||
end
|
||||
|
||||
test "returns fewer entries on last page", %{blog: blog} do
|
||||
result = blog.paginate(3, 10)
|
||||
|
||||
assert length(result.entries) == 5
|
||||
end
|
||||
|
||||
test "page 2 does not overlap with page 1", %{blog: blog} do
|
||||
page1_ids = blog.paginate(1, 10).entries |> MapSet.new(& &1.id)
|
||||
page2_ids = blog.paginate(2, 10).entries |> MapSet.new(& &1.id)
|
||||
|
||||
assert MapSet.disjoint?(page1_ids, page2_ids)
|
||||
end
|
||||
|
||||
test "returns empty entries beyond last page", %{blog: blog} do
|
||||
result = blog.paginate(99, 10)
|
||||
|
||||
assert result.entries == []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
defmodule Blogex.FeedTest do
|
||||
use ExUnit.Case
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.Test.FakeBlog
|
||||
alias Blogex.Feed
|
||||
|
||||
@base_url "https://example.com"
|
||||
|
||||
setup do
|
||||
Blogex.Test.Setup.with_blog(
|
||||
%{},
|
||||
blog_id: :eng,
|
||||
title: "Eng Blog",
|
||||
description: "Tech articles",
|
||||
base_path: "/blog/eng"
|
||||
)
|
||||
end
|
||||
|
||||
describe "rss/3" do
|
||||
test "produces valid RSS 2.0 XML", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ ~s(<?xml version="1.0")
|
||||
assert xml =~ ~s(<rss version="2.0")
|
||||
assert xml =~ ~s(</rss>)
|
||||
end
|
||||
|
||||
test "includes blog title and description", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ "<title>Eng Blog</title>"
|
||||
assert xml =~ "<description>Tech articles</description>"
|
||||
end
|
||||
|
||||
test "includes post entries with correct links", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ "<link>https://example.com/blog/eng/newest-post</link>"
|
||||
end
|
||||
|
||||
test "wraps post body in CDATA", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ "<content:encoded><![CDATA["
|
||||
end
|
||||
|
||||
test "includes post tags as categories", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ "<category>elixir</category>"
|
||||
end
|
||||
|
||||
test "respects limit option" do
|
||||
{:ok, _} = FakeBlog.start(build_many(10))
|
||||
xml = Feed.rss(FakeBlog, @base_url, limit: 3)
|
||||
|
||||
item_count = xml |> String.split("<item>") |> length() |> Kernel.-(1)
|
||||
assert item_count == 3
|
||||
end
|
||||
|
||||
test "excludes drafts", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
refute xml =~ "draft-post"
|
||||
end
|
||||
|
||||
test "includes self-referencing atom:link", %{blog: blog} do
|
||||
xml = Feed.rss(blog, @base_url)
|
||||
|
||||
assert xml =~ ~s(href="https://example.com/blog/eng/feed.xml")
|
||||
assert xml =~ ~s(rel="self")
|
||||
end
|
||||
end
|
||||
|
||||
describe "atom/3" do
|
||||
test "produces valid Atom XML", %{blog: blog} do
|
||||
xml = Feed.atom(blog, @base_url)
|
||||
|
||||
assert xml =~ ~s(<feed xmlns="http://www.w3.org/2005/Atom">)
|
||||
assert xml =~ ~s(</feed>)
|
||||
end
|
||||
|
||||
test "includes post entries", %{blog: blog} do
|
||||
xml = Feed.atom(blog, @base_url)
|
||||
|
||||
assert xml =~ "<entry>"
|
||||
assert xml =~ ~s(href="https://example.com/blog/eng/newest-post")
|
||||
end
|
||||
|
||||
test "respects limit option" do
|
||||
{:ok, _} = FakeBlog.start(build_many(10))
|
||||
xml = Feed.atom(FakeBlog, @base_url, limit: 2)
|
||||
|
||||
entry_count = xml |> String.split("<entry>") |> length() |> Kernel.-(1)
|
||||
assert entry_count == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "XML escaping" do
|
||||
test "escapes special characters in titles" do
|
||||
{:ok, _} = FakeBlog.start(
|
||||
[build(title: "Foo & Bar <Baz>")],
|
||||
title: "A & B"
|
||||
)
|
||||
|
||||
xml = Feed.rss(FakeBlog, @base_url)
|
||||
|
||||
assert xml =~ "Foo & Bar <Baz>"
|
||||
assert xml =~ "<title>A & B</title>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "empty blog" do
|
||||
test "rss produces valid XML with no items" do
|
||||
{:ok, _} = FakeBlog.start([])
|
||||
|
||||
xml = Feed.rss(FakeBlog, @base_url)
|
||||
|
||||
assert xml =~ "<channel>"
|
||||
refute xml =~ "<item>"
|
||||
end
|
||||
|
||||
test "atom produces valid XML with no entries" do
|
||||
{:ok, _} = FakeBlog.start([])
|
||||
|
||||
xml = Feed.atom(FakeBlog, @base_url)
|
||||
|
||||
assert xml =~ "<feed"
|
||||
refute xml =~ "<entry>"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
defmodule Blogex.NotFoundErrorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "has a 404 plug status" do
|
||||
error = %Blogex.NotFoundError{message: "not found"}
|
||||
|
||||
assert error.plug_status == 404
|
||||
end
|
||||
|
||||
test "is raisable with a message" do
|
||||
assert_raise Blogex.NotFoundError, "gone", fn ->
|
||||
raise Blogex.NotFoundError, "gone"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule Blogex.PostTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Blogex.Post
|
||||
|
||||
describe "build/3" do
|
||||
test "extracts date from filename path" do
|
||||
post = Post.build("anything/2026/03-10-my-slug.md", valid_attrs(), "<p>body</p>")
|
||||
|
||||
assert post.date == ~D[2026-03-10]
|
||||
end
|
||||
|
||||
test "extracts slug from filename" do
|
||||
post = Post.build("anything/2026/01-05-cool-feature.md", valid_attrs(), "<p>body</p>")
|
||||
|
||||
assert post.id == "cool-feature"
|
||||
end
|
||||
|
||||
test "preserves slug with multiple hyphens" do
|
||||
post = Post.build("x/2026/06-01-my-multi-part-slug.md", valid_attrs(), "<p>x</p>")
|
||||
|
||||
assert post.id == "my-multi-part-slug"
|
||||
end
|
||||
|
||||
test "merges frontmatter attributes into struct" do
|
||||
attrs = %{
|
||||
title: "Custom Title",
|
||||
author: "Specific Author",
|
||||
description: "Custom desc",
|
||||
tags: ~w(alpha beta)
|
||||
}
|
||||
|
||||
post = Post.build("x/2026/01-01-x.md", attrs, "<p>x</p>")
|
||||
|
||||
assert post.title == "Custom Title"
|
||||
assert post.author == "Specific Author"
|
||||
assert post.tags == ["alpha", "beta"]
|
||||
end
|
||||
|
||||
test "stores rendered HTML body" do
|
||||
html = "<h1>Hello</h1><p>World</p>"
|
||||
|
||||
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
|
||||
|
||||
assert post.body == html
|
||||
end
|
||||
|
||||
test "defaults published to true" do
|
||||
post = Post.build("x/2026/01-01-x.md", valid_attrs(), "<p>x</p>")
|
||||
|
||||
assert post.published == true
|
||||
end
|
||||
|
||||
test "allows overriding published to false" do
|
||||
attrs = Map.put(valid_attrs(), :published, false)
|
||||
|
||||
post = Post.build("x/2026/01-01-x.md", attrs, "<p>x</p>")
|
||||
|
||||
assert post.published == false
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_attrs do
|
||||
%{
|
||||
title: "Title",
|
||||
author: "Author",
|
||||
description: "Desc",
|
||||
tags: ["tag"]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
defmodule Blogex.RegistryTest do
|
||||
use ExUnit.Case
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
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"]
|
||||
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"]
|
||||
end
|
||||
|
||||
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 configured blog modules" do
|
||||
assert Registry.blogs() == [AlphaBlog, BetaBlog]
|
||||
end
|
||||
|
||||
test "returns empty list when unconfigured" do
|
||||
Application.delete_env(:blogex, :blogs)
|
||||
|
||||
assert Registry.blogs() == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_blog!/1" do
|
||||
test "returns module by blog_id" do
|
||||
assert Registry.get_blog!(:alpha) == AlphaBlog
|
||||
end
|
||||
|
||||
test "raises for unknown blog_id" do
|
||||
assert_raise Blogex.NotFoundError, fn ->
|
||||
Registry.get_blog!(:nonexistent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_blog/1" do
|
||||
test "returns nil for unknown blog_id" do
|
||||
assert Registry.get_blog(:nonexistent) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_posts/0" do
|
||||
test "merges posts from all blogs" do
|
||||
ids = Registry.all_posts() |> Enum.map(& &1.id)
|
||||
|
||||
assert "a1" in ids
|
||||
assert "b1" in ids
|
||||
end
|
||||
|
||||
test "sorts merged posts newest first" do
|
||||
[first, second] = Registry.all_posts()
|
||||
|
||||
assert first.id == "b1"
|
||||
assert second.id == "a1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_tags/0" do
|
||||
test "merges and deduplicates tags from all blogs" do
|
||||
tags = Registry.all_tags()
|
||||
|
||||
assert "elixir" in tags
|
||||
assert "devops" in tags
|
||||
assert length(tags) == length(Enum.uniq(tags))
|
||||
end
|
||||
end
|
||||
|
||||
describe "blogs_map/0" do
|
||||
test "returns map keyed by blog_id" do
|
||||
map = Registry.blogs_map()
|
||||
|
||||
assert map[:alpha] == AlphaBlog
|
||||
assert map[:beta] == BetaBlog
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
defmodule Blogex.RouterTest do
|
||||
use ExUnit.Case
|
||||
use Plug.Test
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.Test.FakeBlog
|
||||
|
||||
setup do
|
||||
posts = [
|
||||
build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]),
|
||||
build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]),
|
||||
build(id: "draft", published: false, date: ~D[2026-03-12])
|
||||
]
|
||||
|
||||
{:ok, _} = FakeBlog.start(posts,
|
||||
blog_id: :test,
|
||||
title: "Test Blog",
|
||||
description: "Test",
|
||||
base_path: "/blog/test"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp call(method, path) do
|
||||
conn(method, path)
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
describe "GET /feed.xml" do
|
||||
test "returns RSS XML" do
|
||||
conn = call(:get, "/feed.xml")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "application/rss+xml"
|
||||
assert conn.resp_body =~ "<rss version=\"2.0\""
|
||||
end
|
||||
|
||||
test "includes published posts" do
|
||||
conn = call(:get, "/feed.xml")
|
||||
|
||||
assert conn.resp_body =~ "first-post"
|
||||
refute conn.resp_body =~ "draft"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /atom.xml" do
|
||||
test "returns Atom XML" do
|
||||
conn = call(:get, "/atom.xml")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "application/atom+xml"
|
||||
assert conn.resp_body =~ "<feed xmlns="
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /:slug" do
|
||||
test "returns post as JSON" do
|
||||
conn = call(:get, "/first-post")
|
||||
|
||||
assert conn.status == 200
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["id"] == "first-post"
|
||||
assert body["title"] == "First"
|
||||
end
|
||||
|
||||
test "returns 404 for unknown slug" do
|
||||
conn = call(:get, "/nonexistent")
|
||||
|
||||
assert conn.status == 404
|
||||
end
|
||||
|
||||
test "returns 404 for draft post" do
|
||||
conn = call(:get, "/draft")
|
||||
|
||||
assert conn.status == 404
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /tag/:tag" do
|
||||
test "returns posts matching tag" do
|
||||
conn = call(:get, "/tag/elixir")
|
||||
|
||||
assert conn.status == 200
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["tag"] == "elixir"
|
||||
assert length(body["posts"]) == 1
|
||||
assert hd(body["posts"])["id"] == "first-post"
|
||||
end
|
||||
|
||||
test "returns empty list for unknown tag" do
|
||||
conn = call(:get, "/tag/unknown")
|
||||
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["posts"] == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /" do
|
||||
test "returns paginated post list" do
|
||||
conn = call(:get, "/")
|
||||
|
||||
assert conn.status == 200
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert is_list(body["posts"])
|
||||
assert body["total_entries"] == 2
|
||||
end
|
||||
|
||||
test "excludes draft posts from listing" do
|
||||
conn = call(:get, "/")
|
||||
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
ids = Enum.map(body["posts"], & &1["id"])
|
||||
refute "draft" in ids
|
||||
end
|
||||
end
|
||||
|
||||
defp get_content_type(conn) do
|
||||
conn
|
||||
|> Plug.Conn.get_resp_header("content-type")
|
||||
|> List.first("")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
defmodule Blogex.SEOTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.SEO
|
||||
|
||||
defmodule StubBlog do
|
||||
def base_path, do: "/blog/eng"
|
||||
end
|
||||
|
||||
@base_url "https://example.com"
|
||||
|
||||
describe "meta_tags/3" do
|
||||
test "includes post title and description" do
|
||||
post = build(title: "My Title", description: "My Description")
|
||||
|
||||
meta = SEO.meta_tags(post, @base_url, StubBlog)
|
||||
|
||||
assert meta.title == "My Title"
|
||||
assert meta.description == "My Description"
|
||||
end
|
||||
|
||||
test "builds canonical URL from base_url and post id" do
|
||||
post = build(id: "hello-world")
|
||||
|
||||
meta = SEO.meta_tags(post, @base_url, StubBlog)
|
||||
|
||||
assert meta.og_url == "https://example.com/blog/eng/hello-world"
|
||||
end
|
||||
|
||||
test "includes OpenGraph article metadata" do
|
||||
post = build(author: "Alice", date: ~D[2026-06-15], tags: ["a", "b"])
|
||||
|
||||
meta = SEO.meta_tags(post, @base_url, StubBlog)
|
||||
|
||||
assert meta.og_type == "article"
|
||||
assert meta.article_author == "Alice"
|
||||
assert meta.article_published_time == "2026-06-15"
|
||||
assert meta.article_tags == ["a", "b"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "sitemap/2" do
|
||||
defmodule BlogA do
|
||||
def base_path, do: "/blog/a"
|
||||
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-a", date: ~D[2026-01-01])]
|
||||
end
|
||||
|
||||
defmodule BlogB do
|
||||
def base_path, do: "/blog/b"
|
||||
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-b", date: ~D[2026-02-01])]
|
||||
end
|
||||
|
||||
test "includes URLs from all blog modules" do
|
||||
xml = SEO.sitemap([BlogA, BlogB], @base_url)
|
||||
|
||||
assert xml =~ "<loc>https://example.com/blog/a/post-a</loc>"
|
||||
assert xml =~ "<loc>https://example.com/blog/b/post-b</loc>"
|
||||
end
|
||||
|
||||
test "produces valid sitemap XML" do
|
||||
xml = SEO.sitemap([BlogA], @base_url)
|
||||
|
||||
assert xml =~ ~s(<?xml version="1.0")
|
||||
assert xml =~ ~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
|
||||
assert xml =~ "</urlset>"
|
||||
end
|
||||
|
||||
test "includes lastmod from post date" do
|
||||
xml = SEO.sitemap([BlogA], @base_url)
|
||||
|
||||
assert xml =~ "<lastmod>2026-01-01</lastmod>"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
defmodule Blogex.Test.FakeBlog do
|
||||
@moduledoc """
|
||||
A test double that implements the same interface as a `use Blogex.Blog`
|
||||
module, but backed by an Agent so tests can control the post data.
|
||||
|
||||
## Usage in tests
|
||||
|
||||
setup do
|
||||
posts = [PostBuilder.build(id: "hello"), PostBuilder.build(id: "world")]
|
||||
Blogex.Test.FakeBlog.start(posts, blog_id: :engineering, title: "Eng Blog")
|
||||
:ok
|
||||
end
|
||||
|
||||
Then pass `Blogex.Test.FakeBlog` anywhere a blog module is expected.
|
||||
"""
|
||||
|
||||
use Agent
|
||||
|
||||
@defaults [
|
||||
blog_id: :test_blog,
|
||||
title: "Test Blog",
|
||||
description: "A blog for tests",
|
||||
base_path: "/blog/test"
|
||||
]
|
||||
|
||||
def start(posts \\ [], opts \\ []) do
|
||||
opts = Keyword.merge(@defaults, opts)
|
||||
|
||||
state = %{
|
||||
posts: posts,
|
||||
blog_id: opts[:blog_id],
|
||||
title: opts[:title],
|
||||
description: opts[:description],
|
||||
base_path: opts[:base_path]
|
||||
}
|
||||
|
||||
case Agent.start(fn -> state end, name: __MODULE__) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} ->
|
||||
Agent.update(__MODULE__, fn _ -> state end)
|
||||
{:ok, pid}
|
||||
end
|
||||
end
|
||||
|
||||
def stop, do: Agent.stop(__MODULE__)
|
||||
|
||||
defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key))
|
||||
|
||||
def blog_id, do: get(:blog_id)
|
||||
def title, do: get(:title)
|
||||
def description, do: get(:description)
|
||||
def base_path, do: get(:base_path)
|
||||
|
||||
def all_posts do
|
||||
get(:posts)
|
||||
|> Enum.filter(& &1.published)
|
||||
|> Enum.sort_by(& &1.date, {:desc, Date})
|
||||
end
|
||||
|
||||
def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)
|
||||
|
||||
def all_tags do
|
||||
all_posts()
|
||||
|> Enum.flat_map(& &1.tags)
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
def posts_by_tag(tag) do
|
||||
Enum.filter(all_posts(), fn post -> tag in post.tags end)
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Enum.find(all_posts(), &(&1.id == id)) ||
|
||||
raise Blogex.NotFoundError, "post #{inspect(id)} not found"
|
||||
end
|
||||
|
||||
def get_post(id) do
|
||||
Enum.find(all_posts(), &(&1.id == id))
|
||||
end
|
||||
|
||||
def paginate(page \\ 1, per_page \\ 10) do
|
||||
posts = all_posts()
|
||||
total = length(posts)
|
||||
total_pages = max(ceil(total / per_page), 1)
|
||||
|
||||
entries =
|
||||
posts
|
||||
|> Enum.drop((page - 1) * per_page)
|
||||
|> Enum.take(per_page)
|
||||
|
||||
%{
|
||||
entries: entries,
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
total_entries: total,
|
||||
total_pages: total_pages
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
defmodule Blogex.Test.PostBuilder do
|
||||
@moduledoc """
|
||||
Builds `%Blogex.Post{}` structs for tests.
|
||||
|
||||
Call `build/0` for a post with sensible defaults, or `build/1`
|
||||
with a keyword list to override only the fields that matter for
|
||||
your specific test.
|
||||
|
||||
build() # generic post
|
||||
build(title: "Specific Title") # override one field
|
||||
build(tags: ~w(elixir otp), blog: :eng) # override several
|
||||
build(published: false) # draft post
|
||||
"""
|
||||
|
||||
@defaults %{
|
||||
id: "a-blog-post",
|
||||
title: "A Blog Post",
|
||||
author: "Test Author",
|
||||
body: "<p>Post body.</p>",
|
||||
description: "A test post",
|
||||
date: ~D[2026-01-15],
|
||||
tags: ["general"],
|
||||
blog: :test_blog,
|
||||
published: true
|
||||
}
|
||||
|
||||
@doc "Build a post with defaults, merging any overrides."
|
||||
def build(overrides \\ []) do
|
||||
attrs = Map.merge(@defaults, Map.new(overrides))
|
||||
struct!(Blogex.Post, attrs)
|
||||
end
|
||||
|
||||
@doc "Build a list of n posts with sequential dates (newest first)."
|
||||
def build_many(n, overrides \\ []) do
|
||||
Enum.map(1..n, fn i ->
|
||||
build(
|
||||
Keyword.merge(
|
||||
[
|
||||
id: "post-#{i}",
|
||||
title: "Post #{i}",
|
||||
date: Date.add(~D[2026-01-01], n - i)
|
||||
],
|
||||
overrides
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
defmodule Blogex.Test.Setup do
|
||||
@moduledoc """
|
||||
Reusable setup blocks for blog tests.
|
||||
"""
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
@doc """
|
||||
Starts a FakeBlog with a standard set of posts.
|
||||
Returns `%{blog: module, posts: posts}`.
|
||||
"""
|
||||
def with_blog(context \\ %{}, opts \\ []) do
|
||||
posts = Keyword.get(opts, :posts, default_posts())
|
||||
blog_opts = Keyword.drop(opts, [:posts])
|
||||
|
||||
{:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts)
|
||||
|
||||
Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts})
|
||||
end
|
||||
|
||||
@doc "A small set of posts covering common scenarios."
|
||||
def default_posts do
|
||||
[
|
||||
build(
|
||||
id: "newest-post",
|
||||
date: ~D[2026-03-10],
|
||||
tags: ["elixir", "otp"],
|
||||
published: true
|
||||
),
|
||||
build(
|
||||
id: "middle-post",
|
||||
date: ~D[2026-02-15],
|
||||
tags: ["elixir", "testing"],
|
||||
published: true
|
||||
),
|
||||
build(
|
||||
id: "oldest-post",
|
||||
date: ~D[2026-01-05],
|
||||
tags: ["devops"],
|
||||
published: true
|
||||
),
|
||||
build(
|
||||
id: "draft-post",
|
||||
date: ~D[2026-03-12],
|
||||
tags: ["elixir"],
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
ExUnit.start()
|
||||
Reference in New Issue
Block a user