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