berrypod/test/berrypod/pages_test.exs
jamey 3336b3aa26
All checks were successful
deploy / deploy (push) Successful in 1m24s
add page builder polish: utility blocks, templates, duplicate
New block types: spacer, divider, button/CTA, video embed (YouTube,
Vimeo with privacy-enhanced embeds, fallback for unknown URLs).

Page templates (blank, content, landing) shown when creating custom
pages. Duplicate page action on admin index with slug deduplication.

Fix block picker on shop edit sidebar being cut off on mobile by
accounting for bottom nav and making the grid scrollable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:33:25 +00:00

637 lines
20 KiB
Elixir

defmodule Berrypod.PagesTest do
use Berrypod.DataCase, async: false
alias Berrypod.Pages
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
setup do
# Clear cached pages between tests so save_page side effects don't leak
PageCache.invalidate_all()
:ok
end
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
test "returns nil for unknown custom slug" do
assert Pages.get_page("does-not-exist") == nil
end
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
pages = Pages.list_system_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 "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
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 format" do
assert {:error, changeset} =
Pages.save_page("UPPER CASE", %{title: "Bad", blocks: []})
assert errors_on(changeset).slug
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
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 "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
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 "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
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
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
describe "templates" do
test "templates/0 returns available templates" do
templates = Defaults.templates()
assert length(templates) == 3
keys = Enum.map(templates, & &1.key)
assert "blank" in keys
assert "content" in keys
assert "landing" in keys
end
test "template_blocks/1 returns blocks for content template" do
blocks = Defaults.template_blocks("content")
types = Enum.map(blocks, & &1["type"])
assert types == ["hero", "content_body"]
end
test "template_blocks/1 returns blocks for landing template" do
blocks = Defaults.template_blocks("landing")
types = Enum.map(blocks, & &1["type"])
assert "hero" in types
assert "featured_products" in types
assert "button" in types
end
test "template_blocks/1 returns empty list for blank" do
assert Defaults.template_blocks("blank") == []
end
test "template blocks have unique IDs" do
for tmpl <- Defaults.templates() do
blocks = Defaults.template_blocks(tmpl.key)
ids = Enum.map(blocks, & &1["id"])
assert ids == Enum.uniq(ids), "duplicate IDs in #{tmpl.key} template"
end
end
end
describe "duplicate_custom_page/1" do
test "creates a draft copy with -copy slug" do
{:ok, original} =
Pages.create_custom_page(%{
"slug" => "test-original",
"title" => "Test Page",
"blocks" => [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
})
{:ok, copy} = Pages.duplicate_custom_page(original)
assert copy.slug == "test-original-copy"
assert copy.title == "Test Page (copy)"
assert copy.published == false
assert length(copy.blocks) == 1
assert hd(copy.blocks)["type"] == "hero"
end
test "deduplicates slug when copy already exists" do
{:ok, original} =
Pages.create_custom_page(%{"slug" => "dup-test", "title" => "Dup Test"})
{:ok, _first_copy} = Pages.duplicate_custom_page(original)
{:ok, second_copy} = Pages.duplicate_custom_page(original)
assert second_copy.slug == "dup-test-copy-2"
end
end
end