wire shop LiveViews to DB queries and improve search UX
Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -288,15 +288,27 @@ defmodule SimpleshopTheme.Products.ProductTest do
|
||||
product = %{
|
||||
provider_data: %{
|
||||
"options" => [
|
||||
%{"name" => "Size", "values" => [%{"title" => "S"}, %{"title" => "M"}]},
|
||||
%{"name" => "Color", "values" => [%{"title" => "Red"}]}
|
||||
%{
|
||||
"name" => "Size",
|
||||
"type" => "size",
|
||||
"values" => [%{"title" => "S"}, %{"title" => "M"}]
|
||||
},
|
||||
%{
|
||||
"name" => "Color",
|
||||
"type" => "color",
|
||||
"values" => [%{"title" => "Red", "colors" => ["#FF0000"]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
types = Product.option_types(product)
|
||||
assert length(types) == 2
|
||||
assert hd(types) == %{name: "Size", values: ["S", "M"]}
|
||||
assert hd(types) == %{name: "Size", type: :size, values: [%{title: "S"}, %{title: "M"}]}
|
||||
|
||||
color_type = Enum.at(types, 1)
|
||||
assert color_type.type == :color
|
||||
assert hd(color_type.values) == %{title: "Red", hex: "#FF0000"}
|
||||
end
|
||||
|
||||
test "returns empty list when no provider_data" do
|
||||
|
||||
@@ -196,4 +196,18 @@ defmodule SimpleshopTheme.SearchTest do
|
||||
assert Search.search("alpine") != []
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_product/1" do
|
||||
test "removes a product from the index", %{ocean: ocean} do
|
||||
assert Search.search("ocean") != []
|
||||
|
||||
Search.remove_product(ocean.id)
|
||||
|
||||
assert Search.search("ocean") == []
|
||||
end
|
||||
|
||||
test "is a no-op for unindexed product" do
|
||||
assert Search.remove_product(-1) == :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,13 +3,44 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import SimpleshopTheme.AccountsFixtures
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
|
||||
:ok
|
||||
|
||||
pc = provider_connection_fixture()
|
||||
|
||||
print =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Mountain Sunrise Print",
|
||||
category: "Art Prints"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{product: print, title: "8x10", price: 1999})
|
||||
Products.recompute_cached_fields(print)
|
||||
|
||||
shirt =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Forest T-Shirt",
|
||||
category: "Apparel",
|
||||
on_sale: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: shirt,
|
||||
title: "Large",
|
||||
price: 2999,
|
||||
compare_at_price: 3999
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(shirt)
|
||||
|
||||
%{print: print, shirt: shirt}
|
||||
end
|
||||
|
||||
describe "Collection page" do
|
||||
@@ -20,29 +51,22 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
end
|
||||
|
||||
test "renders collection page for specific category", %{conn: conn} do
|
||||
category = List.first(PreviewData.categories())
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}")
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/art-prints")
|
||||
|
||||
assert html =~ category.name
|
||||
assert html =~ "Art Prints"
|
||||
end
|
||||
|
||||
test "displays products", %{conn: conn} do
|
||||
test "displays products", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/all")
|
||||
|
||||
products = PreviewData.products()
|
||||
first_product = List.first(products)
|
||||
|
||||
assert html =~ first_product.title
|
||||
assert html =~ print.title
|
||||
end
|
||||
|
||||
test "displays category filter buttons", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/all")
|
||||
|
||||
categories = PreviewData.categories()
|
||||
|
||||
for category <- categories do
|
||||
assert html =~ category.name
|
||||
end
|
||||
assert html =~ "Art Prints"
|
||||
assert html =~ "Apparel"
|
||||
end
|
||||
|
||||
test "displays sort dropdown", %{conn: conn} do
|
||||
@@ -64,15 +88,11 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
assert flash["error"] == "Collection not found"
|
||||
end
|
||||
|
||||
test "filters products by category", %{conn: conn} do
|
||||
category = List.first(PreviewData.categories())
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}")
|
||||
test "filters products by category", %{conn: conn, print: print, shirt: shirt} do
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/art-prints")
|
||||
|
||||
products = PreviewData.products_by_category(category.slug)
|
||||
|
||||
for product <- products do
|
||||
assert html =~ product.title
|
||||
end
|
||||
assert html =~ print.title
|
||||
refute html =~ shirt.title
|
||||
end
|
||||
end
|
||||
|
||||
@@ -140,8 +160,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
test "category links preserve sort order", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/all?sort=price_desc")
|
||||
|
||||
category = List.first(PreviewData.categories())
|
||||
assert html =~ "/collections/#{category.slug}?sort=price_desc"
|
||||
assert html =~ "/collections/art-prints?sort=price_desc"
|
||||
end
|
||||
|
||||
test "All button link preserves sort order", %{conn: conn} do
|
||||
@@ -153,9 +172,8 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
test "featured sort does not include query param in links", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/collections/all")
|
||||
|
||||
category = List.first(PreviewData.categories())
|
||||
assert html =~ ~s(href="/collections/#{category.slug}")
|
||||
refute html =~ "/collections/#{category.slug}?sort=featured"
|
||||
assert html =~ ~s(href="/collections/art-prints")
|
||||
refute html =~ "/collections/art-prints?sort=featured"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,13 +3,27 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import SimpleshopTheme.AccountsFixtures
|
||||
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
|
||||
:ok
|
||||
|
||||
conn = provider_connection_fixture()
|
||||
|
||||
product =
|
||||
product_fixture(%{
|
||||
provider_connection: conn,
|
||||
title: "Mountain Sunrise Print",
|
||||
category: "Art Prints"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{product: product, title: "8x10", price: 1999})
|
||||
|
||||
# Recompute so cheapest_price is set
|
||||
SimpleshopTheme.Products.recompute_cached_fields(product)
|
||||
|
||||
%{product: product}
|
||||
end
|
||||
|
||||
describe "Home page" do
|
||||
@@ -25,25 +39,17 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
|
||||
assert html =~ "Shop the collection"
|
||||
end
|
||||
|
||||
test "renders category navigation", %{conn: conn} do
|
||||
test "renders category navigation with real categories", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
categories = PreviewData.categories()
|
||||
|
||||
for category <- Enum.take(categories, 3) do
|
||||
assert html =~ category.name
|
||||
end
|
||||
assert html =~ "Art Prints"
|
||||
end
|
||||
|
||||
test "renders featured products section", %{conn: conn} do
|
||||
test "renders featured products section", %{conn: conn, product: product} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
assert html =~ "Featured products"
|
||||
|
||||
products = PreviewData.products()
|
||||
first_product = List.first(products)
|
||||
|
||||
assert html =~ first_product.title
|
||||
assert html =~ product.title
|
||||
end
|
||||
|
||||
test "renders image and text section", %{conn: conn} do
|
||||
@@ -56,8 +62,7 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
|
||||
test "renders header with shop name", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
# Header should be present (part of shop_layout)
|
||||
assert html =~ "SimpleShop"
|
||||
assert html =~ "Store Name"
|
||||
end
|
||||
|
||||
test "renders footer with links", %{conn: conn} do
|
||||
|
||||
@@ -3,142 +3,273 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import SimpleshopTheme.AccountsFixtures
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
|
||||
:ok
|
||||
|
||||
pc = provider_connection_fixture()
|
||||
|
||||
# Art print with size options and 2 images (for gallery tests)
|
||||
print =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Mountain Sunrise Print",
|
||||
category: "Art Prints",
|
||||
description: "A beautiful mountain sunrise art print",
|
||||
provider_data: %{
|
||||
"options" => [
|
||||
%{
|
||||
"name" => "Size",
|
||||
"type" => "size",
|
||||
"values" => [
|
||||
%{"title" => "8x10"},
|
||||
%{"title" => "12x18"},
|
||||
%{"title" => "18x24"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: print,
|
||||
title: "8x10",
|
||||
price: 1999,
|
||||
options: %{"Size" => "8x10"}
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: print,
|
||||
title: "12x18",
|
||||
price: 2400,
|
||||
options: %{"Size" => "12x18"}
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: print,
|
||||
title: "18x24",
|
||||
price: 3200,
|
||||
options: %{"Size" => "18x24"}
|
||||
})
|
||||
|
||||
product_image_fixture(%{
|
||||
product: print,
|
||||
position: 0,
|
||||
src: "https://example.com/print-1.jpg"
|
||||
})
|
||||
|
||||
product_image_fixture(%{
|
||||
product: print,
|
||||
position: 1,
|
||||
src: "https://example.com/print-2.jpg"
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(print)
|
||||
|
||||
# T-shirt with colour + size options (some sizes unavailable in white)
|
||||
shirt =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Forest T-Shirt",
|
||||
category: "Apparel",
|
||||
description: "A forest themed t-shirt",
|
||||
provider_data: %{
|
||||
"options" => [
|
||||
%{
|
||||
"name" => "Color",
|
||||
"type" => "color",
|
||||
"values" => [
|
||||
%{"title" => "Black", "colors" => ["#000000"]},
|
||||
%{"title" => "White", "colors" => ["#FFFFFF"]}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"name" => "Size",
|
||||
"type" => "size",
|
||||
"values" => [
|
||||
%{"title" => "M"},
|
||||
%{"title" => "L"},
|
||||
%{"title" => "XL"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
for color <- ["Black", "White"], size <- ["M", "L"] do
|
||||
product_variant_fixture(%{
|
||||
product: shirt,
|
||||
title: "#{color} / #{size}",
|
||||
price: 2999,
|
||||
options: %{"Color" => color, "Size" => size}
|
||||
})
|
||||
end
|
||||
|
||||
# Black / XL available
|
||||
product_variant_fixture(%{
|
||||
product: shirt,
|
||||
title: "Black / XL",
|
||||
price: 2999,
|
||||
options: %{"Color" => "Black", "Size" => "XL"}
|
||||
})
|
||||
|
||||
# White / XL unavailable
|
||||
product_variant_fixture(%{
|
||||
product: shirt,
|
||||
title: "White / XL",
|
||||
price: 2999,
|
||||
options: %{"Color" => "White", "Size" => "XL"},
|
||||
is_available: false
|
||||
})
|
||||
|
||||
product_image_fixture(%{
|
||||
product: shirt,
|
||||
position: 0,
|
||||
src: "https://example.com/shirt-1.jpg"
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(shirt)
|
||||
|
||||
# Another art print for related products
|
||||
related =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Ocean Waves Print",
|
||||
category: "Art Prints"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: related,
|
||||
title: "12x18",
|
||||
price: 2400,
|
||||
options: %{"Size" => "12x18"}
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(related)
|
||||
|
||||
%{print: print, shirt: shirt, related: related}
|
||||
end
|
||||
|
||||
describe "Product detail page" do
|
||||
test "renders product page with product name", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders product page with product name", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ product.title
|
||||
assert html =~ print.title
|
||||
end
|
||||
|
||||
test "renders product description", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders product description", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ product.description
|
||||
assert html =~ print.description
|
||||
end
|
||||
|
||||
test "renders product price", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders product price", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ SimpleshopTheme.Cart.format_price(product.cheapest_price)
|
||||
# Cheapest variant is 8x10 at £19.99
|
||||
assert html =~ "£19.99"
|
||||
end
|
||||
|
||||
test "renders breadcrumb with category link", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders breadcrumb with category link", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ product.category
|
||||
assert html =~ "Art Prints"
|
||||
assert html =~ "/collections/"
|
||||
end
|
||||
|
||||
test "renders add to cart button", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders add to cart button", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ "Add to basket"
|
||||
end
|
||||
|
||||
test "renders related products section", %{conn: conn} do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "renders related products section", %{conn: conn, print: print, related: related} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
# Should show other products, not the current one
|
||||
other_product = Enum.at(PreviewData.products(), 1)
|
||||
assert html =~ other_product.title
|
||||
assert html =~ related.title
|
||||
end
|
||||
end
|
||||
|
||||
describe "Variant selection" do
|
||||
test "renders variant selectors for product with options", %{conn: conn} do
|
||||
# Product "1" (Mountain Sunrise Art Print) has Size options
|
||||
{:ok, _view, html} = live(conn, ~p"/products/1")
|
||||
test "renders variant selectors for product with size options", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ "Size"
|
||||
assert html =~ "8×10"
|
||||
assert html =~ "12×18"
|
||||
assert html =~ "18×24"
|
||||
assert html =~ "8x10"
|
||||
assert html =~ "12x18"
|
||||
assert html =~ "18x24"
|
||||
end
|
||||
|
||||
test "renders color and size selectors for apparel", %{conn: conn} do
|
||||
# Product "6" (Forest Silhouette T-Shirt) has Color and Size options
|
||||
{:ok, _view, html} = live(conn, ~p"/products/6")
|
||||
test "renders colour and size selectors for apparel", %{conn: conn, shirt: shirt} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||
|
||||
assert html =~ "Color"
|
||||
assert html =~ "Size"
|
||||
end
|
||||
|
||||
test "selecting a size updates the price", %{conn: conn} do
|
||||
# Product "1" has variants: 8×10 = £19.99, 12×18 = £24.00, 18×24 = £32.00
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "selecting a size updates the price", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-value-value='18×24']")
|
||||
|> element("button[phx-value-value='18x24']")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "£32.00"
|
||||
end
|
||||
|
||||
test "selecting a colour updates available sizes", %{conn: conn} do
|
||||
# Product "6": White / XL and White / 2XL are unavailable
|
||||
{:ok, view, _html} = live(conn, ~p"/products/6")
|
||||
test "selecting a colour updates available sizes", %{conn: conn, shirt: shirt} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[aria-label='Select White']")
|
||||
|> render_click()
|
||||
|
||||
# XL should be disabled (unavailable in White)
|
||||
# XL should be disabled (unavailable in white)
|
||||
assert html =~ "disabled"
|
||||
end
|
||||
|
||||
test "shows single variant for products with one option", %{conn: conn} do
|
||||
# Product "2" (Ocean Waves Art Print) has a single size variant
|
||||
{:ok, _view, html} = live(conn, ~p"/products/2")
|
||||
test "shows variant for single-variant product", %{conn: conn, related: related} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{related.slug}")
|
||||
|
||||
assert html =~ "12×18"
|
||||
# Ocean Waves Print has no provider_data options, so shows "One size"
|
||||
assert html =~ "One size"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Quantity selector" do
|
||||
test "renders quantity selector with initial value of 1", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders quantity selector with initial value of 1", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, "button[phx-click='decrement_quantity']")
|
||||
assert has_element?(view, "button[phx-click='increment_quantity']")
|
||||
end
|
||||
|
||||
test "decrement button is disabled at quantity 1", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "decrement button is disabled at quantity 1", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, "button[phx-click='decrement_quantity'][disabled]")
|
||||
end
|
||||
|
||||
test "increment increases quantity", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "increment increases quantity", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-click='increment_quantity']")
|
||||
|> render_click()
|
||||
|
||||
# Quantity should now be 2, decrement no longer disabled
|
||||
# Quantity now 2, decrement no longer disabled
|
||||
refute html =~ ~s(phx-click="decrement_quantity" disabled)
|
||||
end
|
||||
|
||||
test "decrement decreases quantity", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "decrement decreases quantity", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
# Increment twice to get to 3
|
||||
view |> element("button[phx-click='increment_quantity']") |> render_click()
|
||||
@@ -153,8 +284,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
refute html =~ ~s(phx-click="decrement_quantity" disabled)
|
||||
end
|
||||
|
||||
test "quantity resets to 1 after adding to cart", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "quantity resets to 1 after adding to cart", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
# Increment to 3
|
||||
view |> element("button[phx-click='increment_quantity']") |> render_click()
|
||||
@@ -173,66 +304,61 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
end
|
||||
|
||||
describe "Add to cart" do
|
||||
test "add to cart opens the cart drawer", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "add to cart opens the cart drawer", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button", "Add to basket")
|
||||
|> render_click()
|
||||
|
||||
# Cart drawer should now be open (the aria live region gets updated)
|
||||
assert html =~ "added to cart"
|
||||
end
|
||||
|
||||
test "add to cart updates cart count", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "add to cart shows product in cart drawer", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button", "Add to basket")
|
||||
|> render_click()
|
||||
|
||||
# The cart drawer should show the item
|
||||
assert html =~ "Mountain Sunrise Art Print"
|
||||
assert html =~ "Mountain Sunrise Print"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Product gallery" do
|
||||
test "renders carousel with hook and accessibility attrs", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/1")
|
||||
test "renders carousel with hook and accessibility attrs", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ ~s(phx-hook="ProductImageScroll")
|
||||
assert html =~ ~s(role="region")
|
||||
assert html =~ ~s(aria-label="Product images")
|
||||
end
|
||||
|
||||
test "renders all gallery images with alt text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/1")
|
||||
test "renders all gallery images with alt text", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
product = List.first(PreviewData.products())
|
||||
|
||||
# Each image should have descriptive alt text
|
||||
assert html =~ "#{product.title} — image 1 of"
|
||||
assert html =~ "#{product.title} — image 2 of"
|
||||
assert html =~ "#{print.title} — image 1 of"
|
||||
assert html =~ "#{print.title} — image 2 of"
|
||||
end
|
||||
|
||||
test "renders dot indicators for multi-image gallery", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders dot indicators for multi-image gallery", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, ".product-image-dots")
|
||||
assert has_element?(view, ".product-image-dot")
|
||||
end
|
||||
|
||||
test "renders thumbnail grid for multi-image gallery", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders thumbnail grid for multi-image gallery", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, ".pdp-gallery-thumbs")
|
||||
assert has_element?(view, ".pdp-thumbnail")
|
||||
end
|
||||
|
||||
test "renders prev/next navigation arrows", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders prev/next navigation arrows", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, "button.pdp-nav-prev")
|
||||
assert has_element?(view, "button.pdp-nav-next")
|
||||
@@ -240,20 +366,20 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
assert has_element?(view, ~s(button[aria-label="Next image"]))
|
||||
end
|
||||
|
||||
test "renders lightbox dialog", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders lightbox dialog", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, "dialog#pdp-lightbox")
|
||||
end
|
||||
|
||||
test "renders lightbox click target for desktop", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
test "renders lightbox click target for desktop", %{conn: conn, print: print} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert has_element?(view, ".pdp-lightbox-click")
|
||||
end
|
||||
|
||||
test "thumbnails have correct aria-labels", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/1")
|
||||
test "thumbnails have correct aria-labels", %{conn: conn, print: print} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
|
||||
|
||||
assert html =~ "View image 1 of"
|
||||
assert html =~ "View image 2 of"
|
||||
@@ -261,31 +387,25 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
end
|
||||
|
||||
describe "Product gallery edge cases" do
|
||||
import Phoenix.LiveViewTest
|
||||
test "single image renders without carousel or dots", %{conn: conn, shirt: shirt} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||
|
||||
test "single image renders without carousel or dots", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/1")
|
||||
|
||||
# The live page always has multiple images due to padding, so test
|
||||
# that the component correctly renders by checking structure exists
|
||||
# (single-image case would need component-level testing)
|
||||
assert has_element?(view, ".pdp-gallery-carousel")
|
||||
# Shirt has only 1 image — should render single view, not carousel
|
||||
assert has_element?(view, ".pdp-gallery-single")
|
||||
refute has_element?(view, ".product-image-dots")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Navigation" do
|
||||
test "product links navigate to correct product page", %{conn: conn} do
|
||||
product = Enum.at(PreviewData.products(), 1)
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
test "product links navigate to correct product page", %{conn: conn, shirt: shirt} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||
|
||||
assert html =~ product.title
|
||||
assert html =~ shirt.title
|
||||
end
|
||||
|
||||
test "falls back to first product for unknown ID", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/products/nonexistent")
|
||||
|
||||
first_product = List.first(PreviewData.products())
|
||||
assert html =~ first_product.title
|
||||
test "unknown slug redirects to collections", %{conn: conn} do
|
||||
assert {:error, {:live_redirect, %{to: "/collections/all"}}} =
|
||||
live(conn, ~p"/products/nonexistent")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
131
test/simpleshop_theme_web/live/shop/search_integration_test.exs
Normal file
131
test/simpleshop_theme_web/live/shop/search_integration_test.exs
Normal file
@@ -0,0 +1,131 @@
|
||||
defmodule SimpleshopThemeWeb.Shop.SearchIntegrationTest do
|
||||
use SimpleshopThemeWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import SimpleshopTheme.AccountsFixtures
|
||||
import SimpleshopTheme.ProductsFixtures
|
||||
|
||||
alias SimpleshopTheme.Search
|
||||
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
|
||||
|
||||
pc = provider_connection_fixture()
|
||||
|
||||
mountain =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Mountain Sunrise Print",
|
||||
description: "A landscape scene at dawn",
|
||||
category: "Art Prints"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{product: mountain, title: "8x10", price: 1999})
|
||||
SimpleshopTheme.Products.recompute_cached_fields(mountain)
|
||||
|
||||
ocean =
|
||||
product_fixture(%{
|
||||
provider_connection: pc,
|
||||
title: "Ocean Waves Notebook",
|
||||
description: "Spiral-bound notebook with ocean art",
|
||||
category: "Stationery"
|
||||
})
|
||||
|
||||
product_variant_fixture(%{product: ocean, title: "A5", price: 1299})
|
||||
SimpleshopTheme.Products.recompute_cached_fields(ocean)
|
||||
|
||||
Search.rebuild_index()
|
||||
|
||||
%{mountain: mountain, ocean: ocean}
|
||||
end
|
||||
|
||||
describe "search event" do
|
||||
test "returns matching products", %{conn: conn, mountain: mountain} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||
|
||||
assert html =~ mountain.title
|
||||
assert html =~ ~p"/products/#{mountain.slug}"
|
||||
end
|
||||
|
||||
test "shows no results message for unmatched query", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "xyznonexistent"})
|
||||
|
||||
assert html =~ "No products found"
|
||||
end
|
||||
|
||||
test "ignores short queries", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "a"})
|
||||
|
||||
refute html =~ "No products found"
|
||||
refute html =~ ~s(role="option")
|
||||
end
|
||||
end
|
||||
|
||||
describe "clear_search event" do
|
||||
test "resets search state", %{conn: conn, mountain: mountain} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
# First search to get results
|
||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||
assert html =~ mountain.title
|
||||
|
||||
# Clear search
|
||||
html = render_hook(view, "clear_search", %{})
|
||||
|
||||
refute html =~ ~s(No products found)
|
||||
# Results list should be gone
|
||||
refute html =~ ~s(role="option")
|
||||
end
|
||||
end
|
||||
|
||||
describe "search results rendering" do
|
||||
test "result links use navigate for LiveView navigation", %{conn: conn, mountain: mountain} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||
|
||||
assert html =~ ~s(href="/products/#{mountain.slug}")
|
||||
assert html =~ ~s(data-phx-link="redirect")
|
||||
end
|
||||
|
||||
test "results have ARIA listbox and option roles", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||
|
||||
assert html =~ ~s(role="listbox")
|
||||
assert html =~ ~s(role="option")
|
||||
end
|
||||
|
||||
test "search input has combobox ARIA attributes", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
assert html =~ ~s(role="combobox")
|
||||
assert html =~ ~s(aria-autocomplete="list")
|
||||
assert html =~ ~s(aria-controls="search-results-list")
|
||||
end
|
||||
|
||||
test "multiple results render in order", %{conn: conn, mountain: mountain, ocean: ocean} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "print notebook"})
|
||||
|
||||
assert html =~ mountain.title || html =~ ocean.title
|
||||
end
|
||||
|
||||
test "shows category and price in results", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
html = render_hook(view, "search", %{"value" => "mountain"})
|
||||
|
||||
assert html =~ "Art Prints"
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user