berrypod/test/berrypod/products_test.exs
jamey 9528700862 rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:23:15 +00:00

755 lines
24 KiB
Elixir

defmodule Berrypod.ProductsTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products
alias Berrypod.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
import Berrypod.ProductsFixtures
# =============================================================================
# Provider Connections
# =============================================================================
describe "list_provider_connections/0" do
test "returns empty list when no connections exist" do
assert Products.list_provider_connections() == []
end
test "returns all provider connections" do
conn1 = provider_connection_fixture(%{provider_type: "printify"})
conn2 = provider_connection_fixture(%{provider_type: "gelato"})
connections = Products.list_provider_connections()
assert length(connections) == 2
assert Enum.any?(connections, &(&1.id == conn1.id))
assert Enum.any?(connections, &(&1.id == conn2.id))
end
end
describe "get_provider_connection/1" do
test "returns the connection with given id" do
conn = provider_connection_fixture()
assert Products.get_provider_connection(conn.id).id == conn.id
end
test "returns nil for non-existent id" do
assert Products.get_provider_connection(Ecto.UUID.generate()) == nil
end
end
describe "get_provider_connection_by_type/1" do
test "returns the connection with given provider_type" do
conn = provider_connection_fixture(%{provider_type: "printify"})
assert Products.get_provider_connection_by_type("printify").id == conn.id
end
test "returns nil for non-existent type" do
assert Products.get_provider_connection_by_type("nonexistent") == nil
end
end
describe "create_provider_connection/1" do
test "creates a provider connection with valid attrs" do
attrs = valid_provider_connection_attrs()
assert {:ok, %ProviderConnection{} = conn} = Products.create_provider_connection(attrs)
assert conn.provider_type == attrs.provider_type
assert conn.name == attrs.name
assert conn.enabled == true
end
test "returns error changeset with invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Products.create_provider_connection(%{})
end
end
describe "update_provider_connection/2" do
test "updates the connection with valid attrs" do
conn = provider_connection_fixture()
assert {:ok, updated} = Products.update_provider_connection(conn, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
test "returns error changeset with invalid attrs" do
conn = provider_connection_fixture()
assert {:error, %Ecto.Changeset{}} =
Products.update_provider_connection(conn, %{provider_type: "invalid"})
end
end
describe "delete_provider_connection/1" do
test "deletes the connection" do
conn = provider_connection_fixture()
assert {:ok, %ProviderConnection{}} = Products.delete_provider_connection(conn)
assert Products.get_provider_connection(conn.id) == nil
end
end
describe "update_sync_status/3" do
test "updates sync status" do
conn = provider_connection_fixture()
assert {:ok, updated} = Products.update_sync_status(conn, "syncing")
assert updated.sync_status == "syncing"
end
test "updates sync status with timestamp" do
conn = provider_connection_fixture()
now = DateTime.utc_now() |> DateTime.truncate(:second)
assert {:ok, updated} = Products.update_sync_status(conn, "completed", now)
assert updated.sync_status == "completed"
assert updated.last_synced_at == now
end
end
# =============================================================================
# Products
# =============================================================================
describe "list_products/1" do
test "returns empty list when no products exist" do
assert Products.list_products() == []
end
test "returns all products" do
product1 = product_fixture()
product2 = product_fixture()
products = Products.list_products()
assert length(products) == 2
ids = Enum.map(products, & &1.id)
assert product1.id in ids
assert product2.id in ids
end
test "filters by visible" do
_visible = product_fixture(%{visible: true})
_hidden = product_fixture(%{visible: false})
visible_products = Products.list_products(visible: true)
assert length(visible_products) == 1
assert hd(visible_products).visible == true
end
test "filters by status" do
_active = product_fixture(%{status: "active"})
_draft = product_fixture(%{status: "draft"})
active_products = Products.list_products(status: "active")
assert length(active_products) == 1
assert hd(active_products).status == "active"
end
test "filters by category" do
_apparel = product_fixture(%{category: "Apparel"})
_homewares = product_fixture(%{category: "Homewares"})
apparel_products = Products.list_products(category: "Apparel")
assert length(apparel_products) == 1
assert hd(apparel_products).category == "Apparel"
end
test "filters by provider_connection_id" do
conn1 = provider_connection_fixture(%{provider_type: "printify"})
conn2 = provider_connection_fixture(%{provider_type: "gelato"})
product1 = product_fixture(%{provider_connection: conn1})
_product2 = product_fixture(%{provider_connection: conn2})
products = Products.list_products(provider_connection_id: conn1.id)
assert length(products) == 1
assert hd(products).id == product1.id
end
test "preloads associations" do
product = product_fixture()
_image = product_image_fixture(%{product: product})
_variant = product_variant_fixture(%{product: product})
[loaded] = Products.list_products(preload: [:images, :variants])
assert length(loaded.images) == 1
assert length(loaded.variants) == 1
end
end
describe "get_product/2" do
test "returns the product with given id" do
product = product_fixture()
assert Products.get_product(product.id).id == product.id
end
test "returns nil for non-existent id" do
assert Products.get_product(Ecto.UUID.generate()) == nil
end
test "preloads associations when requested" do
product = product_fixture()
_image = product_image_fixture(%{product: product})
loaded = Products.get_product(product.id, preload: [:images])
assert length(loaded.images) == 1
end
end
describe "get_product_by_slug/2" do
test "returns the product with given slug" do
product = product_fixture(%{slug: "my-product"})
assert Products.get_product_by_slug("my-product").id == product.id
end
test "returns nil for non-existent slug" do
assert Products.get_product_by_slug("nonexistent") == nil
end
end
describe "get_product_by_provider/2" do
test "returns the product by provider connection and product id" do
conn = provider_connection_fixture()
product = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
assert Products.get_product_by_provider(conn.id, "ext_123").id == product.id
end
test "returns nil when not found" do
conn = provider_connection_fixture()
assert Products.get_product_by_provider(conn.id, "nonexistent") == nil
end
end
describe "create_product/1" do
test "creates a product with valid attrs" do
conn = provider_connection_fixture()
attrs = valid_product_attrs(%{provider_connection_id: conn.id})
assert {:ok, %Product{} = product} = Products.create_product(attrs)
assert product.title == attrs.title
assert product.provider_product_id == attrs.provider_product_id
end
test "returns error changeset with invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Products.create_product(%{})
end
end
describe "update_product/2" do
test "updates the product with valid attrs" do
product = product_fixture()
assert {:ok, updated} = Products.update_product(product, %{title: "Updated Title"})
assert updated.title == "Updated Title"
end
end
describe "delete_product/1" do
test "deletes the product" do
product = product_fixture()
assert {:ok, %Product{}} = Products.delete_product(product)
assert Products.get_product(product.id) == nil
end
end
describe "upsert_product/2" do
test "creates new product when not exists" do
conn = provider_connection_fixture()
attrs = %{
provider_product_id: "new_ext_123",
title: "New Product",
slug: "new-product",
provider_data: %{"key" => "value"}
}
assert {:ok, product, :created} = Products.upsert_product(conn, attrs)
assert product.title == "New Product"
assert product.provider_connection_id == conn.id
end
test "updates existing product when checksum differs" do
conn = provider_connection_fixture()
existing = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
attrs = %{
provider_product_id: "ext_123",
title: "Updated Title",
slug: existing.slug,
provider_data: %{"different" => "data"}
}
assert {:ok, product, :updated} = Products.upsert_product(conn, attrs)
assert product.id == existing.id
assert product.title == "Updated Title"
end
test "returns unchanged when checksum matches" do
conn = provider_connection_fixture()
provider_data = %{"key" => "value"}
existing =
product_fixture(%{
provider_connection: conn,
provider_product_id: "ext_123",
provider_data: provider_data,
checksum: Product.compute_checksum(provider_data)
})
attrs = %{
provider_product_id: "ext_123",
title: "Different Title",
slug: existing.slug,
provider_data: provider_data
}
assert {:ok, product, :unchanged} = Products.upsert_product(conn, attrs)
assert product.id == existing.id
# Title should NOT be updated since checksum matched
assert product.title == existing.title
end
end
# =============================================================================
# Product Images
# =============================================================================
describe "create_product_image/1" do
test "creates a product image" do
product = product_fixture()
attrs = valid_product_image_attrs(%{product_id: product.id})
assert {:ok, %ProductImage{} = image} = Products.create_product_image(attrs)
assert image.product_id == product.id
end
end
describe "delete_product_images/1" do
test "deletes all images for a product" do
product = product_fixture()
_image1 = product_image_fixture(%{product: product})
_image2 = product_image_fixture(%{product: product})
assert {2, nil} = Products.delete_product_images(product)
loaded = Products.get_product(product.id, preload: [:images])
assert loaded.images == []
end
end
describe "sync_product_images/2" do
test "replaces all images" do
product = product_fixture()
_old_image = product_image_fixture(%{product: product})
new_images = [
%{src: "https://new.com/1.jpg"},
%{src: "https://new.com/2.jpg"}
]
results = Products.sync_product_images(product, new_images)
assert length(results) == 2
assert Enum.all?(results, &match?({:ok, _}, &1))
loaded = Products.get_product(product.id, preload: [:images])
assert length(loaded.images) == 2
end
test "assigns positions based on list order" do
product = product_fixture()
images = [
%{src: "https://new.com/first.jpg"},
%{src: "https://new.com/second.jpg"}
]
Products.sync_product_images(product, images)
loaded = Products.get_product(product.id, preload: [:images])
sorted = Enum.sort_by(loaded.images, & &1.position)
assert Enum.at(sorted, 0).position == 0
assert Enum.at(sorted, 1).position == 1
end
end
# =============================================================================
# Product Variants
# =============================================================================
describe "create_product_variant/1" do
test "creates a product variant" do
product = product_fixture()
attrs = valid_product_variant_attrs(%{product_id: product.id})
assert {:ok, %ProductVariant{} = variant} = Products.create_product_variant(attrs)
assert variant.product_id == product.id
end
end
describe "update_product_variant/2" do
test "updates the variant" do
variant = product_variant_fixture()
assert {:ok, updated} = Products.update_product_variant(variant, %{price: 3000})
assert updated.price == 3000
end
end
describe "delete_product_variants/1" do
test "deletes all variants for a product" do
product = product_fixture()
_variant1 = product_variant_fixture(%{product: product})
_variant2 = product_variant_fixture(%{product: product})
assert {2, nil} = Products.delete_product_variants(product)
loaded = Products.get_product(product.id, preload: [:variants])
assert loaded.variants == []
end
end
describe "get_variant_by_provider/2" do
test "returns variant by product and provider variant id" do
product = product_fixture()
variant = product_variant_fixture(%{product: product, provider_variant_id: "var_123"})
assert Products.get_variant_by_provider(product.id, "var_123").id == variant.id
end
test "returns nil when not found" do
product = product_fixture()
assert Products.get_variant_by_provider(product.id, "nonexistent") == nil
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()
variants = [
%{provider_variant_id: "v1", title: "Small", price: 2000},
%{provider_variant_id: "v2", title: "Large", price: 2500}
]
results = Products.sync_product_variants(product, variants)
assert length(results) == 2
assert Enum.all?(results, &match?({:ok, _}, &1))
loaded = Products.get_product(product.id, preload: [:variants])
assert length(loaded.variants) == 2
end
test "updates existing variants" do
product = product_fixture()
existing =
product_variant_fixture(%{product: product, provider_variant_id: "v1", price: 2000})
variants = [
%{provider_variant_id: "v1", title: "Small Updated", price: 2200}
]
Products.sync_product_variants(product, variants)
updated = Repo.get!(ProductVariant, existing.id)
assert updated.title == "Small Updated"
assert updated.price == 2200
end
test "removes variants not in incoming list" do
product = product_fixture()
_keep = product_variant_fixture(%{product: product, provider_variant_id: "keep"})
_remove = product_variant_fixture(%{product: product, provider_variant_id: "remove"})
variants = [
%{provider_variant_id: "keep", title: "Keep", price: 2000}
]
Products.sync_product_variants(product, variants)
loaded = Products.get_product(product.id, preload: [:variants])
assert length(loaded.variants) == 1
assert hd(loaded.variants).provider_variant_id == "keep"
end
end
end