add page builder data model, cache, and block registry

Stage 1 of the page builder: Pages schema with 14 valid slugs,
BlockTypes registry (26 block types with settings schemas and data
loaders), Defaults module matching existing templates, ETS-backed
PageCache GenServer, and Pages context (get_page/save_page/reset_page
with cache -> DB -> defaults lookup). 34 tests, zero visual change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-26 17:45:07 +00:00
parent 0c54861eb6
commit 35f96e43a6
8 changed files with 1115 additions and 1 deletions

View File

@ -36,7 +36,9 @@ defmodule Berrypod.Application do
# Start to serve requests
BerrypodWeb.Endpoint,
# Theme CSS cache - must start after Endpoint for static_path/1 to work
Berrypod.Theme.CSSCache
Berrypod.Theme.CSSCache,
# Page definition cache - loads page block lists into ETS
Berrypod.Pages.PageCache
]
# See https://hexdocs.pm/elixir/Supervisor.html

126
lib/berrypod/pages.ex Normal file
View File

@ -0,0 +1,126 @@
defmodule Berrypod.Pages do
@moduledoc """
Context for database-driven page definitions.
Every page is a flat list of blocks stored as JSON. This module provides
the public API for reading, writing, and loading block data for pages.
Lookup order: ETS cache -> DB -> defaults.
"""
alias Berrypod.Repo
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
import Ecto.Query
# ── Read ──────────────────────────────────────────────────────────
@doc """
Gets a page definition by slug.
Checks the ETS cache first, falls back to DB, then to built-in defaults.
Returns a map with `:slug`, `:title`, and `:blocks` keys.
"""
def get_page(slug) do
case PageCache.get(slug) do
{:ok, page_data} -> page_data
:miss -> get_page_uncached(slug)
end
end
@doc """
Gets a page from the DB, falling back to defaults. Used by PageCache.warm/0.
"""
def get_page_from_db(slug) do
case Repo.one(from p in Page, where: p.slug == ^slug) do
%Page{} = page -> page_to_map(page)
nil -> Defaults.for_slug(slug)
end
end
defp get_page_uncached(slug) do
page_data = get_page_from_db(slug)
# Cache for next time (best-effort, ETS might not be up yet)
try do
PageCache.put(slug, page_data)
rescue
ArgumentError -> :ok
end
page_data
end
@doc "Lists all pages with their current definitions."
def list_pages do
Page.valid_slugs()
|> Enum.map(&get_page/1)
end
# ── Write ─────────────────────────────────────────────────────────
@doc """
Saves a page's block list. Creates or updates the DB row and invalidates
the cache.
Returns `{:ok, page}` or `{:error, changeset}`.
"""
def save_page(slug, attrs) do
existing = Repo.one(from p in Page, where: p.slug == ^slug)
result =
case existing do
nil ->
%Page{}
|> Page.changeset(Map.merge(%{slug: slug}, attrs))
|> Repo.insert()
%Page{} = page ->
page
|> Page.changeset(attrs)
|> Repo.update()
end
case result do
{:ok, page} ->
PageCache.invalidate(slug)
{:ok, page}
error ->
error
end
end
@doc """
Resets a page to its default block list. Deletes the DB row (if any)
and invalidates the cache.
"""
def reset_page(slug) do
case Repo.one(from p in Page, where: p.slug == ^slug) do
nil -> :ok
page -> Repo.delete(page)
end
PageCache.invalidate(slug)
:ok
end
# ── Block data loading ────────────────────────────────────────────
@doc """
Runs data loaders for all blocks on a page, returning a merged map
of extra assigns.
Only blocks with a `data_loader` in the BlockTypes registry trigger
a query. Blocks without data loaders rely on global or page-context assigns.
"""
def load_block_data(blocks, assigns) do
BlockTypes.load_data(blocks, assigns)
end
# ── Helpers ───────────────────────────────────────────────────────
defp page_to_map(%Page{} = page) do
%{slug: page.slug, title: page.title, blocks: page.blocks}
end
end

View File

@ -0,0 +1,300 @@
defmodule Berrypod.Pages.BlockTypes do
@moduledoc """
Registry of all block types available in the page builder.
Each block type has a name, icon, allowed pages, settings schema,
and an optional data loader that fetches extra data when the block
is present on a page.
"""
alias Berrypod.Products
alias Berrypod.Theme.PreviewData
@block_types %{
# ── Portable blocks (work on any page) ──────────────────────────
"hero" => %{
name: "Hero banner",
icon: "hero-megaphone",
allowed_on: :all,
settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "cta_text", label: "Button text", type: :text, default: ""},
%{key: "cta_href", label: "Button link", type: :text, default: ""},
%{
key: "variant",
label: "Style",
type: :select,
options: ~w(default page sunken error),
default: "default"
}
]
},
"featured_products" => %{
name: "Featured products",
icon: "hero-star",
allowed_on: :all,
settings_schema: [
%{key: "title", label: "Title", type: :text, default: "Featured products"},
%{key: "product_count", label: "Number of products", type: :number, default: 8}
],
data_loader: :load_featured_products
},
"image_text" => %{
name: "Image + text",
icon: "hero-photo",
allowed_on: :all,
settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""},
%{key: "description", label: "Description", type: :textarea, default: ""},
%{key: "image_url", label: "Image URL", type: :text, default: ""},
%{key: "link_text", label: "Link text", type: :text, default: ""},
%{key: "link_href", label: "Link URL", type: :text, default: ""}
]
},
"category_nav" => %{
name: "Category navigation",
icon: "hero-squares-2x2",
allowed_on: :all,
settings_schema: []
},
"newsletter_card" => %{
name: "Newsletter signup",
icon: "hero-envelope",
allowed_on: :all,
settings_schema: []
},
"social_links_card" => %{
name: "Social links",
icon: "hero-share",
allowed_on: :all,
settings_schema: []
},
"info_card" => %{
name: "Info card",
icon: "hero-information-circle",
allowed_on: :all,
settings_schema: [
%{key: "title", label: "Title", type: :text, default: ""},
%{key: "items", label: "Items", type: :json, default: []}
]
},
"trust_badges" => %{
name: "Trust badges",
icon: "hero-shield-check",
allowed_on: :all,
settings_schema: []
},
"reviews_section" => %{
name: "Customer reviews",
icon: "hero-chat-bubble-left-right",
allowed_on: :all,
settings_schema: [],
data_loader: :load_reviews
},
# ── PDP blocks ──────────────────────────────────────────────────
"product_hero" => %{
name: "Product hero",
icon: "hero-cube",
allowed_on: ["pdp"],
settings_schema: []
},
"breadcrumb" => %{
name: "Breadcrumb",
icon: "hero-chevron-right",
allowed_on: ["pdp"],
settings_schema: []
},
"product_details" => %{
name: "Product details",
icon: "hero-document-text",
allowed_on: ["pdp"],
settings_schema: []
},
"related_products" => %{
name: "Related products",
icon: "hero-squares-plus",
allowed_on: ["pdp"],
settings_schema: [],
data_loader: :load_related_products
},
# ── Collection blocks ───────────────────────────────────────────
"collection_header" => %{
name: "Collection header",
icon: "hero-tag",
allowed_on: ["collection"],
settings_schema: []
},
"filter_bar" => %{
name: "Filter bar",
icon: "hero-funnel",
allowed_on: ["collection"],
settings_schema: []
},
"product_grid" => %{
name: "Product grid",
icon: "hero-squares-2x2",
allowed_on: ["collection"],
settings_schema: []
},
# ── Cart blocks ─────────────────────────────────────────────────
"cart_items" => %{
name: "Cart items",
icon: "hero-shopping-cart",
allowed_on: ["cart"],
settings_schema: []
},
"order_summary" => %{
name: "Order summary",
icon: "hero-calculator",
allowed_on: ["cart"],
settings_schema: []
},
# ── Contact blocks ──────────────────────────────────────────────
"contact_form" => %{
name: "Contact form",
icon: "hero-envelope",
allowed_on: ["contact"],
settings_schema: [
%{key: "email", label: "Email", type: :text, default: "hello@example.com"}
]
},
"order_tracking_card" => %{
name: "Order tracking",
icon: "hero-truck",
allowed_on: ["contact"],
settings_schema: []
},
# ── Content blocks ──────────────────────────────────────────────
"content_body" => %{
name: "Page content",
icon: "hero-document-text",
allowed_on: ["about", "delivery", "privacy", "terms"],
settings_schema: [
%{key: "image_src", label: "Image", type: :text, default: ""},
%{key: "image_alt", label: "Image alt text", type: :text, default: ""}
]
},
# ── Checkout success ────────────────────────────────────────────
"checkout_result" => %{
name: "Checkout result",
icon: "hero-check-circle",
allowed_on: ["checkout_success"],
settings_schema: []
},
# ── Orders ──────────────────────────────────────────────────────
"order_card" => %{
name: "Order cards",
icon: "hero-clipboard-document-list",
allowed_on: ["orders"],
settings_schema: []
},
# ── Order detail ────────────────────────────────────────────────
"order_detail_card" => %{
name: "Order detail",
icon: "hero-clipboard-document",
allowed_on: ["order_detail"],
settings_schema: []
},
# ── Search ──────────────────────────────────────────────────────
"search_results" => %{
name: "Search results",
icon: "hero-magnifying-glass",
allowed_on: ["search"],
settings_schema: []
}
}
@doc "Returns the full block type registry."
def all, do: @block_types
@doc "Returns the definition for a single block type, or nil."
def get(type), do: Map.get(@block_types, type)
@doc "Returns block types allowed on the given page slug."
def allowed_for(page_slug) do
@block_types
|> Enum.filter(fn {_type, def} ->
def.allowed_on == :all or page_slug in def.allowed_on
end)
|> Map.new()
end
@doc """
Runs data loaders for a list of blocks, merging results into a single map.
Each block type can declare a `data_loader` a function that returns
extra assigns needed by that block. Only fires when the block is on the page.
"""
def load_data(blocks, assigns) do
blocks
|> Enum.reduce(%{}, fn block, acc ->
case get(block["type"]) do
%{data_loader: loader} ->
Map.merge(acc, run_loader(loader, assigns, block["settings"] || %{}))
_ ->
acc
end
end)
end
# ── Data loaders ────────────────────────────────────────────────
defp run_loader(:load_featured_products, assigns, settings) do
limit = settings["product_count"] || 8
products =
if assigns[:mode] == :preview do
PreviewData.products() |> Enum.take(limit)
else
Products.list_visible_products(limit: limit)
end
%{products: products}
end
defp run_loader(:load_related_products, assigns, _settings) do
products =
if assigns[:mode] == :preview do
PreviewData.products() |> Enum.take(4)
else
case assigns[:product] do
%{category: cat, id: id} when not is_nil(cat) ->
Products.list_visible_products(category: cat, limit: 4, exclude: id)
_ ->
[]
end
end
%{related_products: products}
end
defp run_loader(:load_reviews, _assigns, _settings) do
%{
reviews: PreviewData.reviews(),
average_rating: 5,
total_count: 24
}
end
end

View File

@ -0,0 +1,212 @@
defmodule Berrypod.Pages.Defaults do
@moduledoc """
Default block definitions for all pages.
These match the current static templates exactly. When a page has never
been customised, `Pages.get_page/1` returns these defaults. First edit
saves to the DB and overrides them.
"""
@doc "Returns the default page definition for the given slug."
def for_slug(slug), do: %{slug: slug, title: title(slug), blocks: blocks(slug)}
@doc "Returns default definitions for all pages."
def all do
Berrypod.Pages.Page.valid_slugs()
|> Enum.map(&for_slug/1)
end
# ── Page titles ─────────────────────────────────────────────────
defp title("home"), do: "Home page"
defp title("about"), do: "About"
defp title("delivery"), do: "Delivery"
defp title("privacy"), do: "Privacy policy"
defp title("terms"), do: "Terms & conditions"
defp title("contact"), do: "Contact"
defp title("collection"), do: "Collection"
defp title("pdp"), do: "Product page"
defp title("cart"), do: "Cart"
defp title("search"), do: "Search"
defp title("checkout_success"), do: "Checkout success"
defp title("orders"), do: "Orders"
defp title("order_detail"), do: "Order detail"
defp title("error"), do: "Error"
# ── Block lists ─────────────────────────────────────────────────
defp blocks("home") do
[
block("hero", %{
"title" => "Original designs, printed on demand",
"description" =>
"Welcome to the Berrypod demo store. This is where your hero text goes \u2013 something short and punchy about what makes your shop worth a browse.",
"cta_text" => "Shop the collection",
"cta_href" => "/collections/all",
"variant" => "default"
}),
block("category_nav"),
block("featured_products", %{
"title" => "Featured products",
"product_count" => 8
}),
block("image_text", %{
"title" => "Made with passion, printed with care",
"description" =>
"This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page.",
"image_url" => "/mockups/mountain-sunrise-print-3-800.webp",
"link_text" => "Learn more about the studio \u2192",
"link_href" => "/about"
})
]
end
defp blocks("about") do
[
block("hero", %{
"title" => "About the studio",
"description" => "",
"variant" => "sunken"
}),
block("content_body", %{
"image_src" => "/mockups/night-sky-blanket-3",
"image_alt" => "Night sky blanket"
})
]
end
defp blocks("delivery") do
[
block("hero", %{
"title" => "Delivery information",
"description" => "",
"variant" => "page"
}),
block("content_body")
]
end
defp blocks("privacy") do
[
block("hero", %{
"title" => "Privacy policy",
"description" => "",
"variant" => "page"
}),
block("content_body")
]
end
defp blocks("terms") do
[
block("hero", %{
"title" => "Terms & conditions",
"description" => "",
"variant" => "page"
}),
block("content_body")
]
end
defp blocks("contact") do
[
block("hero", %{
"title" => "Get in touch",
"description" =>
"Sample contact page for the demo store. Add your own message here \u2013 something friendly about how customers can reach you.",
"variant" => "page"
}),
block("contact_form", %{"email" => "hello@example.com"}),
block("order_tracking_card"),
block("info_card", %{
"title" => "Handy to know",
"items" => [
%{"label" => "Printing", "value" => "Example: 2-5 business days"},
%{"label" => "Delivery", "value" => "Example: 3-7 business days after printing"},
%{"label" => "Issues", "value" => "Example: Reprints for any defects"}
]
}),
block("newsletter_card"),
block("social_links_card")
]
end
defp blocks("collection") do
[
block("collection_header"),
block("filter_bar"),
block("product_grid")
]
end
defp blocks("pdp") do
[
block("breadcrumb"),
block("product_hero"),
block("trust_badges"),
block("product_details"),
block("reviews_section"),
block("related_products")
]
end
defp blocks("cart") do
[
block("cart_items"),
block("order_summary")
]
end
defp blocks("search") do
[
block("search_results")
]
end
defp blocks("checkout_success") do
[
block("checkout_result")
]
end
defp blocks("orders") do
[
block("order_card")
]
end
defp blocks("order_detail") do
[
block("order_detail_card")
]
end
defp blocks("error") do
[
block("hero", %{
"variant" => "error",
"cta_text" => "Go to Homepage",
"cta_href" => "/"
}),
block("featured_products", %{
"title" => "Featured products",
"product_count" => 4
})
]
end
# ── Helpers ─────────────────────────────────────────────────────
defp block(type, settings \\ %{}) do
%{
"id" => generate_block_id(),
"type" => type,
"settings" => settings
}
end
@doc "Generates a unique block ID."
def generate_block_id do
"blk_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
end
end

View File

@ -0,0 +1,31 @@
defmodule Berrypod.Pages.Page do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_slugs ~w(
home about delivery privacy terms contact
collection pdp cart search
checkout_success orders order_detail error
)
schema "pages" do
field :slug, :string
field :title, :string
field :blocks, {:array, :map}, default: []
timestamps(type: :utc_datetime)
end
def changeset(page, attrs) do
page
|> cast(attrs, [:slug, :title, :blocks])
|> validate_required([:slug, :title, :blocks])
|> validate_inclusion(:slug, @valid_slugs)
|> unique_constraint(:slug)
end
def valid_slugs, do: @valid_slugs
end

View File

@ -0,0 +1,86 @@
defmodule Berrypod.Pages.PageCache do
@moduledoc """
GenServer that maintains an ETS table for caching page definitions.
Same pattern as `Berrypod.Theme.CSSCache`. Pages are cached by slug
for O(1) lookups. Invalidated on save, warmed on startup.
"""
use GenServer
@table_name :page_cache
## Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Gets a cached page by slug. Returns `{:ok, page_data}` or `:miss`."
def get(slug) do
case :ets.lookup(@table_name, slug) do
[{^slug, page_data}] -> {:ok, page_data}
[] -> :miss
end
end
@doc "Caches a page definition by slug."
def put(slug, page_data) do
:ets.insert(@table_name, {slug, page_data})
:ok
end
@doc "Invalidates a single cached page."
def invalidate(slug) do
:ets.delete(@table_name, slug)
:ok
rescue
ArgumentError -> :ok
end
@doc "Invalidates all cached pages."
def invalidate_all do
:ets.delete_all_objects(@table_name)
:ok
rescue
ArgumentError -> :ok
end
@doc "Warms the cache by loading all pages from the DB."
def warm do
alias Berrypod.Pages
for slug <- Berrypod.Pages.Page.valid_slugs() do
page = Pages.get_page_from_db(slug)
put(slug, page)
end
:ok
end
## Server callbacks
@impl true
def init(_opts) do
:ets.new(@table_name, [
:set,
:public,
:named_table,
read_concurrency: true,
write_concurrency: false
])
{:ok, %{}, {:continue, :warm}}
end
@impl true
def handle_continue(:warm, state) do
try do
warm()
rescue
_ -> :ok
end
{:noreply, state}
end
end

View File

@ -0,0 +1,16 @@
defmodule Berrypod.Repo.Migrations.CreatePages do
use Ecto.Migration
def change do
create table(:pages, primary_key: false) do
add :id, :binary_id, primary_key: true
add :slug, :string, null: false
add :title, :string, null: false
add :blocks, :map, default: "[]"
timestamps(type: :utc_datetime)
end
create unique_index(:pages, [:slug])
end
end

View File

@ -0,0 +1,341 @@
defmodule Berrypod.PagesTest do
use Berrypod.DataCase, async: true
alias Berrypod.Pages
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
describe "get_page/1" do
test "returns defaults when nothing in DB" do
page = Pages.get_page("home")
assert page.slug == "home"
assert page.title == "Home page"
assert is_list(page.blocks)
assert length(page.blocks) == 4
types = Enum.map(page.blocks, & &1["type"])
assert types == ["hero", "category_nav", "featured_products", "image_text"]
end
test "returns DB version when page has been saved" do
{:ok, _} =
Pages.save_page("home", %{
title: "Custom home",
blocks: [%{"id" => "blk_test", "type" => "hero", "settings" => %{}}]
})
page = Pages.get_page("home")
assert page.slug == "home"
assert page.title == "Custom home"
assert length(page.blocks) == 1
end
test "each block has an id, type, and settings" do
page = Pages.get_page("contact")
for block <- page.blocks do
assert is_binary(block["id"]), "block missing id"
assert is_binary(block["type"]), "block missing type"
assert is_map(block["settings"]), "block missing settings"
end
end
end
describe "list_pages/0" do
test "returns all 14 pages" do
pages = Pages.list_pages()
assert length(pages) == 14
slugs = Enum.map(pages, & &1.slug)
assert "home" in slugs
assert "pdp" in slugs
assert "error" in slugs
end
end
describe "save_page/2" do
test "creates a new page in the DB" do
blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
assert {:ok, page} = Pages.save_page("about", %{title: "About us", blocks: blocks})
assert page.slug == "about"
assert page.title == "About us"
assert length(page.blocks) == 1
end
test "updates an existing page" do
{:ok, _} = Pages.save_page("about", %{title: "About v1", blocks: []})
{:ok, page} = Pages.save_page("about", %{title: "About v2", blocks: []})
assert page.title == "About v2"
assert Repo.aggregate(from(p in Page, where: p.slug == "about"), :count) == 1
end
test "invalidates cache on save" do
# Prime the cache
_page = Pages.get_page("home")
# Save a new version
{:ok, _} = Pages.save_page("home", %{title: "New home", blocks: []})
# Should return the new version
page = Pages.get_page("home")
assert page.title == "New home"
end
test "rejects invalid slug" do
assert {:error, changeset} =
Pages.save_page("nonexistent", %{title: "Bad", blocks: []})
assert errors_on(changeset).slug
end
end
describe "reset_page/1" do
test "restores defaults after customisation" do
{:ok, _} =
Pages.save_page("home", %{
title: "Custom",
blocks: [%{"id" => "blk_1", "type" => "hero", "settings" => %{}}]
})
assert :ok = Pages.reset_page("home")
page = Pages.get_page("home")
assert page.title == "Home page"
assert length(page.blocks) == 4
end
test "is a no-op for a page that was never saved" do
assert :ok = Pages.reset_page("cart")
end
end
describe "load_block_data/2" do
test "returns empty map when no blocks have data loaders" do
blocks = [
%{"type" => "hero", "settings" => %{}},
%{"type" => "category_nav", "settings" => %{}}
]
assert Pages.load_block_data(blocks, %{}) == %{}
end
test "loads review data when reviews_section block is present" do
blocks = [%{"type" => "reviews_section", "settings" => %{}}]
data = Pages.load_block_data(blocks, %{})
assert is_list(data.reviews)
assert data.average_rating == 5
assert data.total_count == 24
end
test "loads featured products with configurable count" do
blocks = [%{"type" => "featured_products", "settings" => %{"product_count" => 4}}]
data = Pages.load_block_data(blocks, %{mode: :preview})
assert is_list(data.products)
end
test "loads related products in preview mode" do
blocks = [%{"type" => "related_products", "settings" => %{}}]
data = Pages.load_block_data(blocks, %{mode: :preview})
assert is_list(data.related_products)
end
test "loads related products from DB with product context" do
blocks = [%{"type" => "related_products", "settings" => %{}}]
data = Pages.load_block_data(blocks, %{product: %{category: "Prints", id: "fake-id"}})
assert is_list(data.related_products)
end
test "merges data from multiple loaders" do
blocks = [
%{"type" => "featured_products", "settings" => %{}},
%{"type" => "reviews_section", "settings" => %{}}
]
data = Pages.load_block_data(blocks, %{mode: :preview})
assert Map.has_key?(data, :products)
assert Map.has_key?(data, :reviews)
end
end
describe "BlockTypes" do
test "all/0 returns all block types" do
types = BlockTypes.all()
assert is_map(types)
assert map_size(types) > 20
assert Map.has_key?(types, "hero")
assert Map.has_key?(types, "product_hero")
assert Map.has_key?(types, "search_results")
end
test "get/1 returns a block type definition" do
hero = BlockTypes.get("hero")
assert hero.name == "Hero banner"
assert hero.icon == "hero-megaphone"
assert hero.allowed_on == :all
assert is_list(hero.settings_schema)
end
test "get/1 returns nil for unknown type" do
assert BlockTypes.get("nonexistent") == nil
end
test "allowed_for/1 returns portable + page-specific blocks" do
pdp_types = BlockTypes.allowed_for("pdp")
# Portable blocks are included
assert Map.has_key?(pdp_types, "hero")
assert Map.has_key?(pdp_types, "featured_products")
# PDP-specific blocks are included
assert Map.has_key?(pdp_types, "product_hero")
assert Map.has_key?(pdp_types, "breadcrumb")
# Other page-specific blocks are excluded
refute Map.has_key?(pdp_types, "cart_items")
refute Map.has_key?(pdp_types, "contact_form")
end
test "allowed_for/1 includes only relevant page-specific blocks" do
cart_types = BlockTypes.allowed_for("cart")
assert Map.has_key?(cart_types, "cart_items")
assert Map.has_key?(cart_types, "order_summary")
refute Map.has_key?(cart_types, "product_hero")
end
test "every block type has required fields" do
for {type, def} <- BlockTypes.all() do
assert is_binary(def.name), "#{type} missing name"
assert is_binary(def.icon), "#{type} missing icon"
assert def.allowed_on == :all or is_list(def.allowed_on), "#{type} bad allowed_on"
assert is_list(def.settings_schema), "#{type} missing settings_schema"
end
end
test "settings_schema entries have required fields" do
for {type, def} <- BlockTypes.all(), setting <- def.settings_schema do
assert is_binary(setting.key), "#{type} setting missing key"
assert is_binary(setting.label), "#{type} setting missing label"
assert is_atom(setting.type), "#{type} setting missing type"
assert Map.has_key?(setting, :default), "#{type} setting #{setting.key} missing default"
end
end
end
describe "Defaults" do
test "all/0 returns 14 page definitions" do
all = Defaults.all()
assert length(all) == 14
end
test "every default page has blocks matching registered types" do
for page <- Defaults.all() do
assert is_binary(page.slug), "page missing slug"
assert is_binary(page.title), "page missing title"
assert is_list(page.blocks), "#{page.slug} blocks is not a list"
for block <- page.blocks do
assert BlockTypes.get(block["type"]),
"#{page.slug} has unknown block type: #{block["type"]}"
end
end
end
test "every default block type is allowed on its page" do
for page <- Defaults.all() do
allowed = BlockTypes.allowed_for(page.slug)
for block <- page.blocks do
assert Map.has_key?(allowed, block["type"]),
"#{block["type"]} not allowed on #{page.slug}"
end
end
end
test "block IDs are unique within each page" do
for page <- Defaults.all() do
ids = Enum.map(page.blocks, & &1["id"])
assert ids == Enum.uniq(ids), "duplicate block IDs on #{page.slug}"
end
end
test "home page has the expected blocks" do
page = Defaults.for_slug("home")
types = Enum.map(page.blocks, & &1["type"])
assert types == ["hero", "category_nav", "featured_products", "image_text"]
end
test "pdp page has the expected blocks" do
page = Defaults.for_slug("pdp")
types = Enum.map(page.blocks, & &1["type"])
assert types == [
"breadcrumb",
"product_hero",
"trust_badges",
"product_details",
"reviews_section",
"related_products"
]
end
test "contact page has the expected blocks" do
page = Defaults.for_slug("contact")
types = Enum.map(page.blocks, & &1["type"])
assert types == [
"hero",
"contact_form",
"order_tracking_card",
"info_card",
"newsletter_card",
"social_links_card"
]
end
test "generate_block_id/0 produces unique prefixed IDs" do
id1 = Defaults.generate_block_id()
id2 = Defaults.generate_block_id()
assert String.starts_with?(id1, "blk_")
assert String.starts_with?(id2, "blk_")
assert id1 != id2
end
end
describe "PageCache" do
test "get/1 returns :miss for uncached slug" do
assert :miss = PageCache.get("nonexistent_slug_#{System.unique_integer()}")
end
test "put/2 and get/1 round-trip" do
slug = "test_cache_#{System.unique_integer()}"
page_data = %{slug: slug, title: "Test", blocks: []}
assert :ok = PageCache.put(slug, page_data)
assert {:ok, ^page_data} = PageCache.get(slug)
end
test "invalidate/1 removes cached entry" do
slug = "test_invalidate_#{System.unique_integer()}"
PageCache.put(slug, %{slug: slug, title: "Test", blocks: []})
assert :ok = PageCache.invalidate(slug)
assert :miss = PageCache.get(slug)
end
end
end