add denormalized product fields and use Product structs throughout
Adds cheapest_price, compare_at_price, in_stock, on_sale columns to products table (recomputed from variants after each sync). Shop components now work with Product structs directly instead of plain maps from PreviewData. Renames .name to .title, adds Product display helpers (primary_image, hover_image, option_types) and ProductImage helpers (display_url, direct_url, source_width). Adds Products context query functions for storefront use (list_visible_products, get_visible_product, list_categories with DB-level sort/filter). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,4 +62,48 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Display helpers
|
||||
# =============================================================================
|
||||
|
||||
describe "display_url/2" do
|
||||
test "prefers local image_id over src" do
|
||||
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image) == "/images/abc-123/variant/800.webp"
|
||||
end
|
||||
|
||||
test "accepts custom size" do
|
||||
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image, 400) == "/images/abc-123/variant/400.webp"
|
||||
end
|
||||
|
||||
test "falls back to src when no image_id" do
|
||||
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
|
||||
assert ProductImage.display_url(image) == "https://cdn.example.com/img.jpg"
|
||||
end
|
||||
|
||||
test "returns nil when neither image_id nor src" do
|
||||
assert ProductImage.display_url(%{image_id: nil, src: nil}) == nil
|
||||
end
|
||||
|
||||
test "returns nil for nil input" do
|
||||
assert ProductImage.display_url(nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "source_width/1" do
|
||||
test "returns source_width from preloaded image" do
|
||||
image = %{image: %{source_width: 2400}}
|
||||
assert ProductImage.source_width(image) == 2400
|
||||
end
|
||||
|
||||
test "returns nil when image not preloaded" do
|
||||
assert ProductImage.source_width(%{}) == nil
|
||||
end
|
||||
|
||||
test "returns nil when source_width is nil" do
|
||||
assert ProductImage.source_width(%{image: %{source_width: nil}}) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -248,4 +248,64 @@ defmodule SimpleshopTheme.Products.ProductTest do
|
||||
assert "archived" in statuses
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Display helpers
|
||||
# =============================================================================
|
||||
|
||||
describe "primary_image/1" do
|
||||
test "returns image with lowest position" do
|
||||
product = %{images: [%{position: 2, src: "b.jpg"}, %{position: 0, src: "a.jpg"}]}
|
||||
assert Product.primary_image(product).src == "a.jpg"
|
||||
end
|
||||
|
||||
test "returns nil with no images" do
|
||||
assert Product.primary_image(%{images: []}) == nil
|
||||
end
|
||||
|
||||
test "returns nil when images not present" do
|
||||
assert Product.primary_image(%{}) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "hover_image/1" do
|
||||
test "returns second image by position" do
|
||||
product = %{images: [%{position: 0, src: "a.jpg"}, %{position: 1, src: "b.jpg"}]}
|
||||
assert Product.hover_image(product).src == "b.jpg"
|
||||
end
|
||||
|
||||
test "returns nil with fewer than 2 images" do
|
||||
assert Product.hover_image(%{images: [%{position: 0, src: "a.jpg"}]}) == nil
|
||||
end
|
||||
|
||||
test "returns nil with no images" do
|
||||
assert Product.hover_image(%{images: []}) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "option_types/1" do
|
||||
test "extracts from provider_data" do
|
||||
product = %{
|
||||
provider_data: %{
|
||||
"options" => [
|
||||
%{"name" => "Size", "values" => [%{"title" => "S"}, %{"title" => "M"}]},
|
||||
%{"name" => "Color", "values" => [%{"title" => "Red"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
types = Product.option_types(product)
|
||||
assert length(types) == 2
|
||||
assert hd(types) == %{name: "Size", values: ["S", "M"]}
|
||||
end
|
||||
|
||||
test "returns empty list when no provider_data" do
|
||||
assert Product.option_types(%{}) == []
|
||||
end
|
||||
|
||||
test "falls back to option_types field on mock data" do
|
||||
mock = %{option_types: [%{name: "Size", values: ["S", "M"]}]}
|
||||
assert Product.option_types(mock) == [%{name: "Size", values: ["S", "M"]}]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -418,6 +418,289 @@ defmodule SimpleshopTheme.ProductsTest do
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Storefront queries
|
||||
# =============================================================================
|
||||
|
||||
describe "recompute_cached_fields/1" do
|
||||
test "computes cheapest price from available variants" do
|
||||
product = product_fixture()
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 3000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 2000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 1500,
|
||||
is_enabled: false,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.cheapest_price == 2000
|
||||
end
|
||||
|
||||
test "sets cheapest_price to 0 when no available variants" do
|
||||
product = product_fixture()
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 2000,
|
||||
is_enabled: true,
|
||||
is_available: false
|
||||
})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.cheapest_price == 0
|
||||
end
|
||||
|
||||
test "sets in_stock based on available variants" do
|
||||
product = product_fixture()
|
||||
product_variant_fixture(%{product: product, is_enabled: true, is_available: true})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.in_stock == true
|
||||
end
|
||||
|
||||
test "sets in_stock false when no available variants" do
|
||||
product = product_fixture()
|
||||
product_variant_fixture(%{product: product, is_enabled: true, is_available: false})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.in_stock == false
|
||||
end
|
||||
|
||||
test "sets on_sale when any variant has compare_at_price > price" do
|
||||
product = product_fixture()
|
||||
product_variant_fixture(%{product: product, price: 2000, compare_at_price: 3000})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.on_sale == true
|
||||
end
|
||||
|
||||
test "sets on_sale false when no sale variants" do
|
||||
product = product_fixture()
|
||||
product_variant_fixture(%{product: product, price: 2000, compare_at_price: nil})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.on_sale == false
|
||||
end
|
||||
|
||||
test "stores compare_at_price from cheapest available variant" do
|
||||
product = product_fixture()
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 2000,
|
||||
compare_at_price: 3000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: product,
|
||||
price: 1500,
|
||||
compare_at_price: 2500,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
assert {:ok, updated} = Products.recompute_cached_fields(product)
|
||||
assert updated.cheapest_price == 1500
|
||||
assert updated.compare_at_price == 2500
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_product/1" do
|
||||
test "returns visible active product by slug" do
|
||||
product = product_fixture(%{slug: "test-product", visible: true, status: "active"})
|
||||
found = Products.get_visible_product("test-product")
|
||||
assert found.id == product.id
|
||||
end
|
||||
|
||||
test "returns nil for hidden product" do
|
||||
_product = product_fixture(%{slug: "hidden", visible: false, status: "active"})
|
||||
assert Products.get_visible_product("hidden") == nil
|
||||
end
|
||||
|
||||
test "returns nil for draft product" do
|
||||
_product = product_fixture(%{slug: "draft", visible: true, status: "draft"})
|
||||
assert Products.get_visible_product("draft") == nil
|
||||
end
|
||||
|
||||
test "preloads images and variants" do
|
||||
product = product_fixture(%{slug: "preloaded"})
|
||||
product_image_fixture(%{product: product})
|
||||
product_variant_fixture(%{product: product})
|
||||
|
||||
found = Products.get_visible_product("preloaded")
|
||||
assert length(found.images) == 1
|
||||
assert length(found.variants) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_visible_products/1" do
|
||||
test "returns only visible active products" do
|
||||
_visible = product_fixture(%{visible: true, status: "active"})
|
||||
_hidden = product_fixture(%{visible: false, status: "active"})
|
||||
_draft = product_fixture(%{visible: true, status: "draft"})
|
||||
|
||||
products = Products.list_visible_products()
|
||||
assert length(products) == 1
|
||||
end
|
||||
|
||||
test "filters by category" do
|
||||
_apparel = product_fixture(%{category: "Apparel"})
|
||||
_home = product_fixture(%{category: "Homewares"})
|
||||
|
||||
products = Products.list_visible_products(category: "Apparel")
|
||||
assert length(products) == 1
|
||||
assert hd(products).category == "Apparel"
|
||||
end
|
||||
|
||||
test "filters by on_sale" do
|
||||
sale = product_fixture()
|
||||
_regular = product_fixture()
|
||||
product_variant_fixture(%{product: sale, price: 2000, compare_at_price: 3000})
|
||||
Products.recompute_cached_fields(sale)
|
||||
|
||||
products = Products.list_visible_products(on_sale: true)
|
||||
assert length(products) == 1
|
||||
assert hd(products).id == sale.id
|
||||
end
|
||||
|
||||
test "filters by in_stock" do
|
||||
in_stock = product_fixture()
|
||||
out_of_stock = product_fixture()
|
||||
product_variant_fixture(%{product: in_stock, is_enabled: true, is_available: true})
|
||||
product_variant_fixture(%{product: out_of_stock, is_enabled: true, is_available: false})
|
||||
Products.recompute_cached_fields(in_stock)
|
||||
Products.recompute_cached_fields(out_of_stock)
|
||||
|
||||
products = Products.list_visible_products(in_stock: true)
|
||||
assert length(products) == 1
|
||||
assert hd(products).id == in_stock.id
|
||||
end
|
||||
|
||||
test "sorts by price ascending" do
|
||||
cheap = product_fixture()
|
||||
expensive = product_fixture()
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: cheap,
|
||||
price: 1000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: expensive,
|
||||
price: 5000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(cheap)
|
||||
Products.recompute_cached_fields(expensive)
|
||||
|
||||
products = Products.list_visible_products(sort: "price_asc")
|
||||
assert Enum.map(products, & &1.id) == [cheap.id, expensive.id]
|
||||
end
|
||||
|
||||
test "sorts by price descending" do
|
||||
cheap = product_fixture()
|
||||
expensive = product_fixture()
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: cheap,
|
||||
price: 1000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
product_variant_fixture(%{
|
||||
product: expensive,
|
||||
price: 5000,
|
||||
is_enabled: true,
|
||||
is_available: true
|
||||
})
|
||||
|
||||
Products.recompute_cached_fields(cheap)
|
||||
Products.recompute_cached_fields(expensive)
|
||||
|
||||
products = Products.list_visible_products(sort: "price_desc")
|
||||
assert Enum.map(products, & &1.id) == [expensive.id, cheap.id]
|
||||
end
|
||||
|
||||
test "sorts by name ascending" do
|
||||
b = product_fixture(%{title: "Banana"})
|
||||
a = product_fixture(%{title: "Apple"})
|
||||
|
||||
products = Products.list_visible_products(sort: "name_asc")
|
||||
assert Enum.map(products, & &1.id) == [a.id, b.id]
|
||||
end
|
||||
|
||||
test "limits results" do
|
||||
for _ <- 1..5, do: product_fixture()
|
||||
|
||||
products = Products.list_visible_products(limit: 3)
|
||||
assert length(products) == 3
|
||||
end
|
||||
|
||||
test "excludes a product by ID" do
|
||||
p1 = product_fixture()
|
||||
p2 = product_fixture()
|
||||
|
||||
products = Products.list_visible_products(exclude: p1.id)
|
||||
assert length(products) == 1
|
||||
assert hd(products).id == p2.id
|
||||
end
|
||||
|
||||
test "preloads images but not variants" do
|
||||
product = product_fixture()
|
||||
product_image_fixture(%{product: product})
|
||||
product_variant_fixture(%{product: product})
|
||||
|
||||
[loaded] = Products.list_visible_products()
|
||||
assert length(loaded.images) == 1
|
||||
assert %Ecto.Association.NotLoaded{} = loaded.variants
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_categories/0" do
|
||||
test "returns distinct categories from visible products" do
|
||||
product_fixture(%{category: "Apparel"})
|
||||
product_fixture(%{category: "Homewares"})
|
||||
product_fixture(%{category: "Apparel"})
|
||||
product_fixture(%{category: nil})
|
||||
|
||||
categories = Products.list_categories()
|
||||
assert length(categories) == 2
|
||||
assert Enum.map(categories, & &1.name) == ["Apparel", "Homewares"]
|
||||
assert Enum.map(categories, & &1.slug) == ["apparel", "homewares"]
|
||||
end
|
||||
|
||||
test "excludes categories from hidden products" do
|
||||
product_fixture(%{category: "Visible", visible: true})
|
||||
product_fixture(%{category: "Hidden", visible: false})
|
||||
|
||||
categories = Products.list_categories()
|
||||
assert length(categories) == 1
|
||||
assert hd(categories).name == "Visible"
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_product_variants/2" do
|
||||
test "creates new variants" do
|
||||
product = product_fixture()
|
||||
|
||||
@@ -17,10 +17,10 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
|
||||
|
||||
assert is_map(product)
|
||||
assert Map.has_key?(product, :id)
|
||||
assert Map.has_key?(product, :name)
|
||||
assert Map.has_key?(product, :title)
|
||||
assert Map.has_key?(product, :description)
|
||||
assert Map.has_key?(product, :price)
|
||||
assert Map.has_key?(product, :image_url)
|
||||
assert Map.has_key?(product, :cheapest_price)
|
||||
assert Map.has_key?(product, :images)
|
||||
assert Map.has_key?(product, :category)
|
||||
assert Map.has_key?(product, :in_stock)
|
||||
assert Map.has_key?(product, :on_sale)
|
||||
@@ -30,30 +30,26 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
|
||||
products = PreviewData.products()
|
||||
|
||||
for product <- products do
|
||||
assert is_integer(product.price)
|
||||
assert product.price > 0
|
||||
assert is_integer(product.cheapest_price)
|
||||
assert product.cheapest_price > 0
|
||||
|
||||
if product.compare_at_price do
|
||||
assert is_integer(product.compare_at_price)
|
||||
assert product.compare_at_price > product.price
|
||||
assert product.compare_at_price > product.cheapest_price
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "products have image URLs" do
|
||||
test "products have images" do
|
||||
products = PreviewData.products()
|
||||
|
||||
for product <- products do
|
||||
assert is_binary(product.image_url)
|
||||
# Images can be either local paths (starting with /) or full URLs
|
||||
assert String.starts_with?(product.image_url, "/") or
|
||||
String.starts_with?(product.image_url, "http")
|
||||
assert is_list(product.images)
|
||||
assert length(product.images) >= 1
|
||||
|
||||
if product.hover_image_url do
|
||||
assert is_binary(product.hover_image_url)
|
||||
|
||||
assert String.starts_with?(product.hover_image_url, "/") or
|
||||
String.starts_with?(product.hover_image_url, "http")
|
||||
for image <- product.images do
|
||||
assert is_integer(image.position)
|
||||
assert is_binary(image.src) or not is_nil(image.image_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -109,8 +105,8 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
|
||||
product = item.product
|
||||
assert is_map(product)
|
||||
assert Map.has_key?(product, :id)
|
||||
assert Map.has_key?(product, :name)
|
||||
assert Map.has_key?(product, :price)
|
||||
assert Map.has_key?(product, :title)
|
||||
assert Map.has_key?(product, :cheapest_price)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,7 +32,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
products = PreviewData.products()
|
||||
first_product = List.first(products)
|
||||
|
||||
assert html =~ first_product.name
|
||||
assert html =~ first_product.title
|
||||
end
|
||||
|
||||
test "displays category filter buttons", %{conn: conn} do
|
||||
@@ -71,7 +71,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
|
||||
products = PreviewData.products_by_category(category.slug)
|
||||
|
||||
for product <- products do
|
||||
assert html =~ product.name
|
||||
assert html =~ product.title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
|
||||
products = PreviewData.products()
|
||||
first_product = List.first(products)
|
||||
|
||||
assert html =~ first_product.name
|
||||
assert html =~ first_product.title
|
||||
end
|
||||
|
||||
test "renders image and text section", %{conn: conn} do
|
||||
|
||||
@@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
|
||||
assert html =~ product.name
|
||||
assert html =~ product.title
|
||||
end
|
||||
|
||||
test "renders product description", %{conn: conn} do
|
||||
@@ -31,7 +31,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
product = List.first(PreviewData.products())
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
|
||||
assert html =~ SimpleshopTheme.Cart.format_price(product.price)
|
||||
assert html =~ SimpleshopTheme.Cart.format_price(product.cheapest_price)
|
||||
end
|
||||
|
||||
test "renders breadcrumb with category link", %{conn: conn} do
|
||||
@@ -55,7 +55,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
|
||||
# Should show other products, not the current one
|
||||
other_product = Enum.at(PreviewData.products(), 1)
|
||||
assert html =~ other_product.name
|
||||
assert html =~ other_product.title
|
||||
end
|
||||
end
|
||||
|
||||
@@ -213,8 +213,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
product = List.first(PreviewData.products())
|
||||
|
||||
# Each image should have descriptive alt text
|
||||
assert html =~ "#{product.name} — image 1 of"
|
||||
assert html =~ "#{product.name} — image 2 of"
|
||||
assert html =~ "#{product.title} — image 1 of"
|
||||
assert html =~ "#{product.title} — image 2 of"
|
||||
end
|
||||
|
||||
test "renders dot indicators for multi-image gallery", %{conn: conn} do
|
||||
@@ -278,14 +278,14 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
product = Enum.at(PreviewData.products(), 1)
|
||||
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
|
||||
|
||||
assert html =~ product.name
|
||||
assert html =~ product.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.name
|
||||
assert html =~ first_product.title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user