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:
jamey
2026-02-13 01:26:39 +00:00
parent 0b4fe031b7
commit 35e0386abb
20 changed files with 1000 additions and 328 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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