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
|
||||
Reference in New Issue
Block a user