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