add custom page data model with split changesets and CRUD context
Stage 1 of custom CMS pages. Adds type/published/meta/nav fields to pages schema, splits changeset into system vs custom (with slug format validation and reserved path exclusion), adds create/update/delete functions with auto-redirect on slug change, and warms custom pages in ETS cache. 62 pages tests, 1426 total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
356e336eef
commit
cf627bd585
@ -343,7 +343,7 @@ defmodule Berrypod.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp find_page_usages(image_id) do
|
defp find_page_usages(image_id) do
|
||||||
Berrypod.Pages.list_pages()
|
Berrypod.Pages.list_all_pages()
|
||||||
|> Enum.flat_map(fn page ->
|
|> Enum.flat_map(fn page ->
|
||||||
page.blocks
|
page.blocks
|
||||||
|> Enum.filter(fn block ->
|
|> Enum.filter(fn block ->
|
||||||
@ -357,7 +357,7 @@ defmodule Berrypod.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp scan_pages_for_image_ids do
|
defp scan_pages_for_image_ids do
|
||||||
Berrypod.Pages.list_pages()
|
Berrypod.Pages.list_all_pages()
|
||||||
|> Enum.flat_map(fn page ->
|
|> Enum.flat_map(fn page ->
|
||||||
Enum.flat_map(page.blocks, fn block ->
|
Enum.flat_map(page.blocks, fn block ->
|
||||||
case get_in(block, ["settings", "image_id"]) do
|
case get_in(block, ["settings", "image_id"]) do
|
||||||
|
|||||||
@ -5,7 +5,8 @@ defmodule Berrypod.Pages do
|
|||||||
Every page is a flat list of blocks stored as JSON. This module provides
|
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.
|
the public API for reading, writing, and loading block data for pages.
|
||||||
|
|
||||||
Lookup order: ETS cache -> DB -> defaults.
|
Lookup order: ETS cache -> DB -> defaults (system pages only).
|
||||||
|
Custom pages return nil when not in DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Berrypod.Repo
|
alias Berrypod.Repo
|
||||||
@ -18,8 +19,9 @@ defmodule Berrypod.Pages do
|
|||||||
@doc """
|
@doc """
|
||||||
Gets a page definition by slug.
|
Gets a page definition by slug.
|
||||||
|
|
||||||
Checks the ETS cache first, falls back to DB, then to built-in defaults.
|
Checks the ETS cache first, falls back to DB, then to built-in defaults
|
||||||
Returns a map with `:slug`, `:title`, and `:blocks` keys.
|
(for system pages only). Returns a map with page fields, or nil for
|
||||||
|
unknown custom slugs.
|
||||||
"""
|
"""
|
||||||
def get_page(slug) do
|
def get_page(slug) do
|
||||||
case PageCache.get(slug) do
|
case PageCache.get(slug) do
|
||||||
@ -32,14 +34,27 @@ defmodule Berrypod.Pages do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a page from the DB, falling back to defaults. Used by PageCache.warm/0.
|
Gets a page from the DB. System slugs fall back to defaults; custom slugs
|
||||||
|
return nil when not found. Used by PageCache.warm/0.
|
||||||
"""
|
"""
|
||||||
def get_page_from_db(slug) do
|
def get_page_from_db(slug) do
|
||||||
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
||||||
%Page{} = page -> page_to_map(page)
|
%Page{} = page ->
|
||||||
nil -> Defaults.for_slug(slug)
|
page_to_map(page)
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
if Page.system_slug?(slug) do
|
||||||
|
Defaults.for_slug(slug)
|
||||||
|
else
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Gets the raw Page struct by slug. Returns nil if not in DB."
|
||||||
|
def get_page_struct(slug) do
|
||||||
|
Repo.one(from p in Page, where: p.slug == ^slug)
|
||||||
|
end
|
||||||
|
|
||||||
defp get_page_uncached(slug) do
|
defp get_page_uncached(slug) do
|
||||||
page_data = get_page_from_db(slug)
|
page_data = get_page_from_db(slug)
|
||||||
@ -54,33 +69,53 @@ defmodule Berrypod.Pages do
|
|||||||
page_data
|
page_data
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Lists all pages with their current definitions."
|
@doc "Lists all 14 system pages with their current definitions."
|
||||||
def list_pages do
|
def list_system_pages do
|
||||||
Page.valid_slugs()
|
Page.system_slugs()
|
||||||
|> Enum.map(&get_page/1)
|
|> Enum.map(&get_page/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def list_pages, do: list_system_pages()
|
||||||
|
|
||||||
|
@doc "Lists all custom pages, ordered by title."
|
||||||
|
def list_custom_pages do
|
||||||
|
from(p in Page, where: p.type == "custom", order_by: [asc: p.title])
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(&page_to_map/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Lists all pages (system + custom)."
|
||||||
|
def list_all_pages do
|
||||||
|
list_system_pages() ++ list_custom_pages()
|
||||||
|
end
|
||||||
|
|
||||||
# ── Write ─────────────────────────────────────────────────────────
|
# ── Write ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Saves a page's block list. Creates or updates the DB row and invalidates
|
Saves a page's block list. Creates or updates the DB row and invalidates
|
||||||
the cache.
|
the cache. Uses the appropriate changeset based on page type.
|
||||||
|
|
||||||
Returns `{:ok, page}` or `{:error, changeset}`.
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
||||||
"""
|
"""
|
||||||
def save_page(slug, attrs) do
|
def save_page(slug, attrs) do
|
||||||
existing = Repo.one(from p in Page, where: p.slug == ^slug)
|
existing = Repo.one(from p in Page, where: p.slug == ^slug)
|
||||||
|
|
||||||
|
changeset_fn =
|
||||||
|
if Page.system_slug?(slug),
|
||||||
|
do: &Page.system_changeset/2,
|
||||||
|
else: &Page.custom_changeset/2
|
||||||
|
|
||||||
result =
|
result =
|
||||||
case existing do
|
case existing do
|
||||||
nil ->
|
nil ->
|
||||||
%Page{}
|
%Page{}
|
||||||
|> Page.changeset(Map.merge(%{slug: slug}, attrs))
|
|> changeset_fn.(Map.merge(%{slug: slug}, attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
%Page{} = page ->
|
%Page{} = page ->
|
||||||
page
|
page
|
||||||
|> Page.changeset(attrs)
|
|> changeset_fn.(attrs)
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -94,6 +129,80 @@ defmodule Berrypod.Pages do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a new custom page.
|
||||||
|
|
||||||
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
||||||
|
"""
|
||||||
|
def create_custom_page(attrs) do
|
||||||
|
result =
|
||||||
|
%Page{}
|
||||||
|
|> Page.custom_changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, page} ->
|
||||||
|
PageCache.put(page.slug, page_to_map(page))
|
||||||
|
{:ok, page}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a custom page. Creates an auto redirect if the slug changes.
|
||||||
|
|
||||||
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
||||||
|
"""
|
||||||
|
def update_custom_page(%Page{type: "custom"} = page, attrs) do
|
||||||
|
old_slug = page.slug
|
||||||
|
|
||||||
|
result =
|
||||||
|
page
|
||||||
|
|> Page.custom_changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, updated} ->
|
||||||
|
PageCache.invalidate(old_slug)
|
||||||
|
PageCache.put(updated.slug, page_to_map(updated))
|
||||||
|
|
||||||
|
if old_slug != updated.slug do
|
||||||
|
Berrypod.Redirects.create_auto(%{
|
||||||
|
from_path: "/#{old_slug}",
|
||||||
|
to_path: "/#{updated.slug}",
|
||||||
|
source: "auto_slug_change"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, updated}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_custom_page(%Page{type: "system"}, _attrs), do: {:error, :system_page}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a custom page.
|
||||||
|
|
||||||
|
Returns `{:ok, page}` or `{:error, :system_page}`.
|
||||||
|
"""
|
||||||
|
def delete_custom_page(%Page{type: "custom"} = page) do
|
||||||
|
case Repo.delete(page) do
|
||||||
|
{:ok, deleted} ->
|
||||||
|
PageCache.invalidate(deleted.slug)
|
||||||
|
{:ok, deleted}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Resets a page to its default block list. Deletes the DB row (if any)
|
Resets a page to its default block list. Deletes the DB row (if any)
|
||||||
and invalidates the cache.
|
and invalidates the cache.
|
||||||
@ -124,6 +233,16 @@ defmodule Berrypod.Pages do
|
|||||||
# ── Helpers ───────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp page_to_map(%Page{} = page) do
|
defp page_to_map(%Page{} = page) do
|
||||||
%{slug: page.slug, title: page.title, blocks: page.blocks}
|
%{
|
||||||
|
slug: page.slug,
|
||||||
|
title: page.title,
|
||||||
|
blocks: page.blocks,
|
||||||
|
type: page.type || "system",
|
||||||
|
published: page.published,
|
||||||
|
meta_description: page.meta_description,
|
||||||
|
show_in_nav: page.show_in_nav,
|
||||||
|
nav_label: page.nav_label,
|
||||||
|
nav_position: page.nav_position
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -12,7 +12,7 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
|
|
||||||
@doc "Returns default definitions for all pages."
|
@doc "Returns default definitions for all pages."
|
||||||
def all do
|
def all do
|
||||||
Berrypod.Pages.Page.valid_slugs()
|
Berrypod.Pages.Page.system_slugs()
|
||||||
|> Enum.map(&for_slug/1)
|
|> Enum.map(&for_slug/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -33,6 +33,10 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
defp title("order_detail"), do: "Order detail"
|
defp title("order_detail"), do: "Order detail"
|
||||||
defp title("error"), do: "Error"
|
defp title("error"), do: "Error"
|
||||||
|
|
||||||
|
defp title(slug) do
|
||||||
|
slug |> String.replace(~r/[-_]/, " ") |> String.capitalize()
|
||||||
|
end
|
||||||
|
|
||||||
# ── Block lists ─────────────────────────────────────────────────
|
# ── Block lists ─────────────────────────────────────────────────
|
||||||
|
|
||||||
defp blocks("home") do
|
defp blocks("home") do
|
||||||
@ -200,6 +204,8 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp blocks(_slug), do: []
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp block(type, settings \\ %{}) do
|
defp block(type, settings \\ %{}) do
|
||||||
|
|||||||
@ -5,27 +5,79 @@ defmodule Berrypod.Pages.Page do
|
|||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
@valid_slugs ~w(
|
@system_slugs ~w(
|
||||||
home about delivery privacy terms contact
|
home about delivery privacy terms contact
|
||||||
collection pdp cart search
|
collection pdp cart search
|
||||||
checkout_success orders order_detail error
|
checkout_success orders order_detail error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@reserved_paths ~w(
|
||||||
|
about delivery privacy terms contact
|
||||||
|
collections products cart search checkout orders
|
||||||
|
coming-soon admin health image_cache images favicon
|
||||||
|
sitemap.xml robots.txt setup dev
|
||||||
|
)
|
||||||
|
|
||||||
schema "pages" do
|
schema "pages" do
|
||||||
field :slug, :string
|
field :slug, :string
|
||||||
field :title, :string
|
field :title, :string
|
||||||
field :blocks, {:array, :map}, default: []
|
field :blocks, {:array, :map}, default: []
|
||||||
|
field :type, :string, default: "system"
|
||||||
|
field :published, :boolean, default: true
|
||||||
|
field :meta_description, :string
|
||||||
|
field :show_in_nav, :boolean, default: false
|
||||||
|
field :nav_label, :string
|
||||||
|
field :nav_position, :integer
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(page, attrs) do
|
def system_changeset(page, attrs) do
|
||||||
page
|
page
|
||||||
|> cast(attrs, [:slug, :title, :blocks])
|
|> cast(attrs, [:slug, :title, :blocks])
|
||||||
|> validate_required([:slug, :title, :blocks])
|
|> validate_required([:slug, :title, :blocks])
|
||||||
|> validate_inclusion(:slug, @valid_slugs)
|
|> validate_inclusion(:slug, @system_slugs)
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_slugs, do: @valid_slugs
|
def custom_changeset(page, attrs) do
|
||||||
|
page
|
||||||
|
|> cast(attrs, [
|
||||||
|
:slug,
|
||||||
|
:title,
|
||||||
|
:blocks,
|
||||||
|
:type,
|
||||||
|
:published,
|
||||||
|
:meta_description,
|
||||||
|
:show_in_nav,
|
||||||
|
:nav_label,
|
||||||
|
:nav_position
|
||||||
|
])
|
||||||
|
|> validate_required([:slug, :title])
|
||||||
|
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
||||||
|
message: "must be lowercase letters, numbers, and hyphens only"
|
||||||
|
)
|
||||||
|
|> validate_exclusion(:slug, @system_slugs ++ @reserved_paths, message: "is reserved")
|
||||||
|
|> validate_length(:slug, max: 100)
|
||||||
|
|> validate_length(:title, max: 200)
|
||||||
|
|> validate_length(:meta_description, max: 300)
|
||||||
|
|> put_defaults()
|
||||||
|
|> unique_constraint(:slug)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_defaults(changeset) do
|
||||||
|
changeset
|
||||||
|
|> put_change(:type, "custom")
|
||||||
|
|> maybe_put(:blocks, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only puts the change if no explicit value was provided in the changeset
|
||||||
|
defp maybe_put(changeset, field, default) do
|
||||||
|
if get_change(changeset, field), do: changeset, else: put_change(changeset, field, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
def system_slugs, do: @system_slugs
|
||||||
|
def reserved_paths, do: @reserved_paths
|
||||||
|
def system_slug?(slug), do: slug in @system_slugs
|
||||||
|
def reserved_path?(slug), do: slug in @reserved_paths
|
||||||
end
|
end
|
||||||
|
|||||||
@ -49,12 +49,25 @@ defmodule Berrypod.Pages.PageCache do
|
|||||||
@doc "Warms the cache by loading all pages from the DB."
|
@doc "Warms the cache by loading all pages from the DB."
|
||||||
def warm do
|
def warm do
|
||||||
alias Berrypod.Pages
|
alias Berrypod.Pages
|
||||||
|
alias Berrypod.Pages.Page
|
||||||
|
|
||||||
for slug <- Berrypod.Pages.Page.valid_slugs() do
|
for slug <- Page.system_slugs() do
|
||||||
page = Pages.get_page_from_db(slug)
|
page = Pages.get_page_from_db(slug)
|
||||||
put(slug, page)
|
put(slug, page)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
custom_slugs =
|
||||||
|
Berrypod.Repo.all(from p in Page, where: p.type == "custom", select: p.slug)
|
||||||
|
|
||||||
|
for slug <- custom_slugs do
|
||||||
|
case Pages.get_page_from_db(slug) do
|
||||||
|
nil -> :ok
|
||||||
|
page -> put(slug, page)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddCustomPageFields do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:pages) do
|
||||||
|
add :type, :string, default: "system"
|
||||||
|
add :published, :boolean, default: true
|
||||||
|
add :meta_description, :string
|
||||||
|
add :show_in_nav, :boolean, default: false
|
||||||
|
add :nav_label, :string
|
||||||
|
add :nav_position, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -46,11 +46,24 @@ defmodule Berrypod.PagesTest do
|
|||||||
assert is_map(block["settings"]), "block missing settings"
|
assert is_map(block["settings"]), "block missing settings"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns nil for unknown custom slug" do
|
||||||
|
assert Pages.get_page("does-not-exist") == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_pages/0" do
|
test "returns page data for created custom page" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "my-page", title: "My page"})
|
||||||
|
page = Pages.get_page("my-page")
|
||||||
|
|
||||||
|
assert page.slug == "my-page"
|
||||||
|
assert page.title == "My page"
|
||||||
|
assert page.type == "custom"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_system_pages/0" do
|
||||||
test "returns all 14 pages" do
|
test "returns all 14 pages" do
|
||||||
pages = Pages.list_pages()
|
pages = Pages.list_system_pages()
|
||||||
|
|
||||||
assert length(pages) == 14
|
assert length(pages) == 14
|
||||||
slugs = Enum.map(pages, & &1.slug)
|
slugs = Enum.map(pages, & &1.slug)
|
||||||
@ -60,6 +73,43 @@ defmodule Berrypod.PagesTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "list_custom_pages/0" do
|
||||||
|
test "returns empty list when no custom pages exist" do
|
||||||
|
assert Pages.list_custom_pages() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns custom pages ordered by title" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "zebra", title: "Zebra"})
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "apple", title: "Apple"})
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "mango", title: "Mango"})
|
||||||
|
|
||||||
|
pages = Pages.list_custom_pages()
|
||||||
|
titles = Enum.map(pages, & &1.title)
|
||||||
|
assert titles == ["Apple", "Mango", "Zebra"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not include system pages" do
|
||||||
|
{:ok, _} = Pages.save_page("home", %{title: "Home page", blocks: []})
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "custom-one", title: "Custom"})
|
||||||
|
|
||||||
|
pages = Pages.list_custom_pages()
|
||||||
|
slugs = Enum.map(pages, & &1.slug)
|
||||||
|
assert "custom-one" in slugs
|
||||||
|
refute "home" in slugs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_all_pages/0" do
|
||||||
|
test "includes both system and custom pages" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||||
|
|
||||||
|
pages = Pages.list_all_pages()
|
||||||
|
slugs = Enum.map(pages, & &1.slug)
|
||||||
|
assert "home" in slugs
|
||||||
|
assert "faq" in slugs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "save_page/2" do
|
describe "save_page/2" do
|
||||||
test "creates a new page in the DB" do
|
test "creates a new page in the DB" do
|
||||||
blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
|
blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
|
||||||
@ -91,14 +141,144 @@ defmodule Berrypod.PagesTest do
|
|||||||
assert page.title == "New home"
|
assert page.title == "New home"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid slug" do
|
test "rejects invalid slug format" do
|
||||||
assert {:error, changeset} =
|
assert {:error, changeset} =
|
||||||
Pages.save_page("nonexistent", %{title: "Bad", blocks: []})
|
Pages.save_page("UPPER CASE", %{title: "Bad", blocks: []})
|
||||||
|
|
||||||
assert errors_on(changeset).slug
|
assert errors_on(changeset).slug
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "create_custom_page/1" do
|
||||||
|
test "creates a custom page with valid slug" do
|
||||||
|
assert {:ok, page} = Pages.create_custom_page(%{slug: "our-story", title: "Our story"})
|
||||||
|
|
||||||
|
assert page.slug == "our-story"
|
||||||
|
assert page.title == "Our story"
|
||||||
|
assert page.type == "custom"
|
||||||
|
assert page.blocks == []
|
||||||
|
assert page.published == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects duplicate slug" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||||
|
assert {:error, changeset} = Pages.create_custom_page(%{slug: "faq", title: "FAQ 2"})
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects system slug" do
|
||||||
|
assert {:error, changeset} = Pages.create_custom_page(%{slug: "home", title: "Home"})
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects reserved path" do
|
||||||
|
assert {:error, changeset} = Pages.create_custom_page(%{slug: "admin", title: "Admin"})
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid slug format" do
|
||||||
|
assert {:error, changeset} =
|
||||||
|
Pages.create_custom_page(%{slug: "My Page", title: "My page"})
|
||||||
|
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects slug with leading hyphen" do
|
||||||
|
assert {:error, changeset} = Pages.create_custom_page(%{slug: "-bad", title: "Bad"})
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects slug with trailing hyphen" do
|
||||||
|
assert {:error, changeset} = Pages.create_custom_page(%{slug: "bad-", title: "Bad"})
|
||||||
|
assert errors_on(changeset).slug
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts single-character slug" do
|
||||||
|
assert {:ok, page} = Pages.create_custom_page(%{slug: "x", title: "X"})
|
||||||
|
assert page.slug == "x"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "populates cache on create" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "cached-page", title: "Cached"})
|
||||||
|
assert {:ok, data} = PageCache.get("cached-page")
|
||||||
|
assert data.title == "Cached"
|
||||||
|
assert data.type == "custom"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_custom_page/1" do
|
||||||
|
test "deletes a custom page" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "temp", title: "Temp"})
|
||||||
|
page_struct = Pages.get_page_struct("temp")
|
||||||
|
assert {:ok, _} = Pages.delete_custom_page(page_struct)
|
||||||
|
assert Pages.get_page("temp") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refuses to delete a system page" do
|
||||||
|
{:ok, page} = Pages.save_page("home", %{title: "Home page", blocks: []})
|
||||||
|
assert {:error, :system_page} = Pages.delete_custom_page(page)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalidates cache on delete" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "bye", title: "Bye"})
|
||||||
|
page_struct = Pages.get_page_struct("bye")
|
||||||
|
Pages.delete_custom_page(page_struct)
|
||||||
|
assert :miss = PageCache.get("bye")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_custom_page/2" do
|
||||||
|
test "updates title" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "updates", title: "V1"})
|
||||||
|
page = Pages.get_page_struct("updates")
|
||||||
|
assert {:ok, updated} = Pages.update_custom_page(page, %{title: "V2"})
|
||||||
|
assert updated.title == "V2"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "slug change creates redirect" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "old-name", title: "Page"})
|
||||||
|
page = Pages.get_page_struct("old-name")
|
||||||
|
assert {:ok, updated} = Pages.update_custom_page(page, %{slug: "new-name"})
|
||||||
|
assert updated.slug == "new-name"
|
||||||
|
|
||||||
|
assert {:ok, redirect} = Berrypod.Redirects.lookup("/old-name")
|
||||||
|
assert redirect.to_path == "/new-name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "slug change invalidates old cache and populates new" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "rename-me", title: "Page"})
|
||||||
|
page = Pages.get_page_struct("rename-me")
|
||||||
|
{:ok, _} = Pages.update_custom_page(page, %{slug: "renamed"})
|
||||||
|
|
||||||
|
assert :miss = PageCache.get("rename-me")
|
||||||
|
assert {:ok, cached} = PageCache.get("renamed")
|
||||||
|
assert cached.slug == "renamed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates meta and nav fields" do
|
||||||
|
{:ok, _} = Pages.create_custom_page(%{slug: "meta-test", title: "Meta"})
|
||||||
|
page = Pages.get_page_struct("meta-test")
|
||||||
|
|
||||||
|
{:ok, updated} =
|
||||||
|
Pages.update_custom_page(page, %{
|
||||||
|
meta_description: "A test page",
|
||||||
|
show_in_nav: true,
|
||||||
|
nav_label: "Test",
|
||||||
|
nav_position: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
assert updated.meta_description == "A test page"
|
||||||
|
assert updated.show_in_nav == true
|
||||||
|
assert updated.nav_label == "Test"
|
||||||
|
assert updated.nav_position == 5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refuses to update a system page" do
|
||||||
|
{:ok, page} = Pages.save_page("home", %{title: "Home page", blocks: []})
|
||||||
|
assert {:error, :system_page} = Pages.update_custom_page(page, %{title: "Nope"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "reset_page/1" do
|
describe "reset_page/1" do
|
||||||
test "restores defaults after customisation" do
|
test "restores defaults after customisation" do
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
@ -220,6 +400,20 @@ defmodule Berrypod.PagesTest do
|
|||||||
refute Map.has_key?(cart_types, "product_hero")
|
refute Map.has_key?(cart_types, "product_hero")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "allowed_for/1 returns only portable blocks for custom pages" do
|
||||||
|
custom_types = BlockTypes.allowed_for("my-custom-page")
|
||||||
|
|
||||||
|
# Portable blocks are included
|
||||||
|
assert Map.has_key?(custom_types, "hero")
|
||||||
|
assert Map.has_key?(custom_types, "featured_products")
|
||||||
|
assert Map.has_key?(custom_types, "image_text")
|
||||||
|
|
||||||
|
# Page-specific blocks are excluded
|
||||||
|
refute Map.has_key?(custom_types, "cart_items")
|
||||||
|
refute Map.has_key?(custom_types, "product_hero")
|
||||||
|
refute Map.has_key?(custom_types, "contact_form")
|
||||||
|
end
|
||||||
|
|
||||||
test "every block type has required fields" do
|
test "every block type has required fields" do
|
||||||
for {type, def} <- BlockTypes.all() do
|
for {type, def} <- BlockTypes.all() do
|
||||||
assert is_binary(def.name), "#{type} missing name"
|
assert is_binary(def.name), "#{type} missing name"
|
||||||
@ -313,6 +507,14 @@ defmodule Berrypod.PagesTest do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "unknown slug returns humanised title and empty blocks" do
|
||||||
|
page = Defaults.for_slug("our-story")
|
||||||
|
|
||||||
|
assert page.slug == "our-story"
|
||||||
|
assert page.title == "Our story"
|
||||||
|
assert page.blocks == []
|
||||||
|
end
|
||||||
|
|
||||||
test "generate_block_id/0 produces unique prefixed IDs" do
|
test "generate_block_id/0 produces unique prefixed IDs" do
|
||||||
id1 = Defaults.generate_block_id()
|
id1 = Defaults.generate_block_id()
|
||||||
id2 = Defaults.generate_block_id()
|
id2 = Defaults.generate_block_id()
|
||||||
@ -344,4 +546,22 @@ defmodule Berrypod.PagesTest do
|
|||||||
assert :miss = PageCache.get(slug)
|
assert :miss = PageCache.get(slug)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Page schema" do
|
||||||
|
test "system_slugs/0 returns 14 slugs" do
|
||||||
|
assert length(Page.system_slugs()) == 14
|
||||||
|
end
|
||||||
|
|
||||||
|
test "system_slug?/1 returns true for system slugs" do
|
||||||
|
assert Page.system_slug?("home")
|
||||||
|
assert Page.system_slug?("cart")
|
||||||
|
refute Page.system_slug?("my-page")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reserved_path?/1 returns true for reserved paths" do
|
||||||
|
assert Page.reserved_path?("admin")
|
||||||
|
assert Page.reserved_path?("collections")
|
||||||
|
refute Page.reserved_path?("faq")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user