feat: add Products context with provider integration (Phase 1)

Implement the schema foundation for syncing products from POD providers
like Printify. This includes encrypted credential storage, product/variant
schemas, and an Oban worker for background sync.

New modules:
- Vault: AES-256-GCM encryption for API keys
- Products context: CRUD and sync operations for products
- Provider behaviour: abstraction for POD provider implementations
- ProductSyncWorker: Oban job for async product sync

Schemas: ProviderConnection, Product, ProductImage, ProductVariant

Also reorganizes Printify client to lib/simpleshop_theme/clients/ and
mockup generator to lib/simpleshop_theme/mockups/ for better structure.

134 tests added covering all new functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 08:32:24 +00:00
parent 62faf86abe
commit c5c06d9979
29 changed files with 4162 additions and 8 deletions

View File

@@ -0,0 +1,65 @@
defmodule SimpleshopTheme.Products.ProductImageTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Products.ProductImage
import SimpleshopTheme.ProductsFixtures
describe "changeset/2" do
setup do
product = product_fixture()
{:ok, product: product}
end
test "valid attributes create a valid changeset", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
test "requires product_id", %{product: _product} do
attrs = valid_product_image_attrs() |> Map.delete(:product_id)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).product_id
end
test "requires src", %{product: product} do
attrs =
valid_product_image_attrs(%{product_id: product.id})
|> Map.delete(:src)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).src
end
test "defaults position to 0", %{product: product} do
attrs =
valid_product_image_attrs(%{product_id: product.id})
|> Map.delete(:position)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
test "accepts optional alt text", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id, alt: "Product image alt text"})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
assert changeset.changes.alt == "Product image alt text"
end
test "allows nil alt text", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id, alt: nil})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
end
end

View File

@@ -0,0 +1,248 @@
defmodule SimpleshopTheme.Products.ProductTest do
use SimpleshopTheme.DataCase, async: true
alias SimpleshopTheme.Products.Product
import SimpleshopTheme.ProductsFixtures
describe "changeset/2" do
setup do
conn = provider_connection_fixture()
{:ok, conn: conn}
end
test "valid attributes create a valid changeset", %{conn: conn} do
attrs = valid_product_attrs(%{provider_connection_id: conn.id})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "requires provider_connection_id", %{conn: _conn} do
attrs = valid_product_attrs() |> Map.put(:provider_connection_id, nil)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_connection_id
end
test "requires provider_product_id", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:provider_product_id)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_product_id
end
test "requires title", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:title)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).title
end
test "requires slug", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:slug)
|> Map.delete(:title)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).slug
end
test "validates status is in allowed list", %{conn: conn} do
attrs = valid_product_attrs(%{provider_connection_id: conn.id, status: "invalid"})
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).status
end
test "accepts all valid statuses", %{conn: conn} do
for status <- Product.statuses() do
attrs = valid_product_attrs(%{provider_connection_id: conn.id, status: status})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?, "Expected #{status} to be valid"
end
end
test "defaults status to active", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:status)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "defaults visible to true", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:visible)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "stores provider_data as map", %{conn: conn} do
provider_data = %{"blueprint_id" => 145, "print_provider_id" => 29, "extra" => "value"}
attrs = valid_product_attrs(%{provider_connection_id: conn.id, provider_data: provider_data})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.provider_data == provider_data
end
end
describe "slug generation" do
setup do
conn = provider_connection_fixture()
{:ok, conn: conn}
end
test "generates slug from title when slug not provided", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "My Awesome Product"
})
|> Map.delete(:slug)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.slug == "my-awesome-product"
end
test "uses provided slug over generated one", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "My Awesome Product",
slug: "custom-slug"
})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.slug == "custom-slug"
end
test "handles special characters in title for slug generation", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "Product (Special) & More!"
})
|> Map.delete(:slug)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
# Special chars removed, spaces become dashes, consecutive dashes collapsed
assert changeset.changes.slug == "product-special-more"
end
end
describe "compute_checksum/1" do
test "generates consistent checksum for same data" do
data = %{"title" => "Test", "price" => 100}
checksum1 = Product.compute_checksum(data)
checksum2 = Product.compute_checksum(data)
assert checksum1 == checksum2
end
test "generates different checksum for different data" do
data1 = %{"title" => "Test", "price" => 100}
data2 = %{"title" => "Test", "price" => 200}
checksum1 = Product.compute_checksum(data1)
checksum2 = Product.compute_checksum(data2)
assert checksum1 != checksum2
end
test "returns 16-character hex string" do
data = %{"key" => "value"}
checksum = Product.compute_checksum(data)
assert is_binary(checksum)
assert String.length(checksum) == 16
assert Regex.match?(~r/^[a-f0-9]+$/, checksum)
end
test "returns nil for non-map input" do
assert Product.compute_checksum(nil) == nil
assert Product.compute_checksum("string") == nil
assert Product.compute_checksum(123) == nil
end
test "handles nested maps" do
data = %{
"title" => "Test",
"options" => [
%{"name" => "Size", "values" => ["S", "M", "L"]}
]
}
checksum = Product.compute_checksum(data)
assert is_binary(checksum)
end
end
describe "unique constraints" do
test "enforces unique slug" do
_product1 = product_fixture(%{slug: "unique-product"})
conn = provider_connection_fixture(%{provider_type: "gelato"})
assert {:error, changeset} =
SimpleshopTheme.Products.create_product(
valid_product_attrs(%{
provider_connection_id: conn.id,
slug: "unique-product"
})
)
assert "has already been taken" in errors_on(changeset).slug
end
test "enforces unique provider_connection_id + provider_product_id" do
conn = provider_connection_fixture()
_product1 = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
assert {:error, changeset} =
SimpleshopTheme.Products.create_product(
valid_product_attrs(%{
provider_connection_id: conn.id,
provider_product_id: "ext_123"
})
)
assert "has already been taken" in errors_on(changeset).provider_connection_id
end
end
describe "statuses/0" do
test "returns list of valid statuses" do
statuses = Product.statuses()
assert is_list(statuses)
assert "active" in statuses
assert "draft" in statuses
assert "archived" in statuses
end
end
end

View File

@@ -0,0 +1,201 @@
defmodule SimpleshopTheme.Products.ProductVariantTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Products.ProductVariant
import SimpleshopTheme.ProductsFixtures
describe "changeset/2" do
setup do
product = product_fixture()
{:ok, product: product}
end
test "valid attributes create a valid changeset", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
test "requires product_id", %{product: _product} do
attrs = valid_product_variant_attrs() |> Map.delete(:product_id)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).product_id
end
test "requires provider_variant_id", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:provider_variant_id)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_variant_id
end
test "requires title", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:title)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).title
end
test "requires price", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:price)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).price
end
test "validates price is non-negative", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, price: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).price
end
test "validates compare_at_price is non-negative when provided", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, compare_at_price: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).compare_at_price
end
test "validates cost is non-negative when provided", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, cost: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).cost
end
test "stores options as map", %{product: product} do
options = %{"Size" => "Large", "Color" => "Red"}
attrs = valid_product_variant_attrs(%{product_id: product.id, options: options})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
assert changeset.changes.options == options
end
test "defaults is_enabled to true", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:is_enabled)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
test "defaults is_available to true", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:is_available)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
end
describe "profit/1" do
test "calculates profit correctly" do
variant = %ProductVariant{price: 2500, cost: 1200}
assert ProductVariant.profit(variant) == 1300
end
test "returns nil when cost is nil" do
variant = %ProductVariant{price: 2500, cost: nil}
assert ProductVariant.profit(variant) == nil
end
test "returns nil when price is nil" do
variant = %ProductVariant{price: nil, cost: 1200}
assert ProductVariant.profit(variant) == nil
end
test "handles zero values" do
variant = %ProductVariant{price: 0, cost: 0}
assert ProductVariant.profit(variant) == 0
end
end
describe "on_sale?/1" do
test "returns true when compare_at_price is higher than price" do
variant = %ProductVariant{price: 2000, compare_at_price: 2500}
assert ProductVariant.on_sale?(variant) == true
end
test "returns false when compare_at_price equals price" do
variant = %ProductVariant{price: 2500, compare_at_price: 2500}
assert ProductVariant.on_sale?(variant) == false
end
test "returns false when compare_at_price is lower than price" do
variant = %ProductVariant{price: 2500, compare_at_price: 2000}
assert ProductVariant.on_sale?(variant) == false
end
test "returns false when compare_at_price is nil" do
variant = %ProductVariant{price: 2500, compare_at_price: nil}
assert ProductVariant.on_sale?(variant) == false
end
end
describe "options_title/1" do
test "formats options as slash-separated string" do
variant = %ProductVariant{options: %{"Size" => "Large", "Color" => "Blue"}}
title = ProductVariant.options_title(variant)
# Map iteration order isn't guaranteed, so check both options are present
assert String.contains?(title, "Large")
assert String.contains?(title, "Blue")
assert String.contains?(title, " / ")
end
test "returns nil for empty options" do
variant = %ProductVariant{options: %{}}
assert ProductVariant.options_title(variant) == nil
end
test "returns nil for nil options" do
variant = %ProductVariant{options: nil}
assert ProductVariant.options_title(variant) == nil
end
test "handles single option" do
variant = %ProductVariant{options: %{"Size" => "Medium"}}
assert ProductVariant.options_title(variant) == "Medium"
end
end
describe "unique constraint" do
test "enforces unique product_id + provider_variant_id" do
product = product_fixture()
_variant1 = product_variant_fixture(%{product: product, provider_variant_id: "var_123"})
assert {:error, changeset} =
SimpleshopTheme.Products.create_product_variant(
valid_product_variant_attrs(%{
product_id: product.id,
provider_variant_id: "var_123"
})
)
assert "has already been taken" in errors_on(changeset).product_id
end
end
end

View File

@@ -0,0 +1,169 @@
defmodule SimpleshopTheme.Products.ProviderConnectionTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Vault
import SimpleshopTheme.ProductsFixtures
describe "changeset/2" do
test "valid attributes create a valid changeset" do
attrs = valid_provider_connection_attrs()
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
end
test "requires provider_type" do
attrs = valid_provider_connection_attrs() |> Map.delete(:provider_type)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_type
end
test "requires name" do
attrs = valid_provider_connection_attrs() |> Map.delete(:name)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "validates provider_type is in allowed list" do
attrs = valid_provider_connection_attrs(%{provider_type: "invalid_provider"})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).provider_type
end
test "accepts all valid provider types" do
for type <- ProviderConnection.provider_types() do
attrs = valid_provider_connection_attrs(%{provider_type: type})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?, "Expected #{type} to be valid"
end
end
test "encrypts api_key when provided" do
attrs = valid_provider_connection_attrs(%{api_key: "my_secret_key"})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
# api_key should be removed from changes
refute Map.has_key?(changeset.changes, :api_key)
# api_key_encrypted should be set
assert encrypted = changeset.changes.api_key_encrypted
assert is_binary(encrypted)
# Should decrypt to original
assert {:ok, "my_secret_key"} = Vault.decrypt(encrypted)
end
test "does not set api_key_encrypted when api_key is nil" do
attrs = valid_provider_connection_attrs() |> Map.delete(:api_key)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
refute Map.has_key?(changeset.changes, :api_key_encrypted)
end
test "defaults enabled to true" do
attrs = valid_provider_connection_attrs() |> Map.delete(:enabled)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
# enabled should use schema default
assert changeset.valid?
end
test "stores config as map" do
config = %{"shop_id" => "123", "extra" => "value"}
attrs = valid_provider_connection_attrs(%{config: config})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
assert changeset.changes.config == config
end
end
describe "sync_changeset/2" do
test "updates sync_status" do
conn = provider_connection_fixture()
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: "syncing"})
assert changeset.valid?
assert changeset.changes.sync_status == "syncing"
end
test "validates sync_status is in allowed list" do
conn = provider_connection_fixture()
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: "invalid"})
refute changeset.valid?
assert "is invalid" in errors_on(changeset).sync_status
end
test "accepts all valid sync statuses" do
conn = provider_connection_fixture()
for status <- ProviderConnection.sync_statuses() do
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: status})
assert changeset.valid?, "Expected #{status} to be valid"
end
end
test "updates last_synced_at" do
conn = provider_connection_fixture()
now = DateTime.utc_now() |> DateTime.truncate(:second)
changeset = ProviderConnection.sync_changeset(conn, %{last_synced_at: now})
assert changeset.valid?
assert changeset.changes.last_synced_at == now
end
end
describe "get_api_key/1" do
test "returns decrypted api_key" do
conn = provider_connection_fixture(%{api_key: "secret_key_123"})
assert ProviderConnection.get_api_key(conn) == "secret_key_123"
end
test "returns nil when api_key_encrypted is nil" do
conn = %ProviderConnection{api_key_encrypted: nil}
assert ProviderConnection.get_api_key(conn) == nil
end
test "returns nil on decryption failure" do
conn = %ProviderConnection{api_key_encrypted: "invalid_encrypted_data"}
assert ProviderConnection.get_api_key(conn) == nil
end
end
describe "provider_types/0" do
test "returns list of supported providers" do
types = ProviderConnection.provider_types()
assert is_list(types)
assert "printify" in types
assert "gelato" in types
assert "prodigi" in types
assert "printful" in types
end
end
describe "unique constraint" do
test "enforces unique provider_type" do
_first = provider_connection_fixture(%{provider_type: "printify"})
assert {:error, changeset} =
SimpleshopTheme.Products.create_provider_connection(
valid_provider_connection_attrs(%{provider_type: "printify"})
)
assert "has already been taken" in errors_on(changeset).provider_type
end
end
end

View File

@@ -0,0 +1,469 @@
defmodule SimpleshopTheme.ProductsTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
import SimpleshopTheme.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
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

View File

@@ -0,0 +1,69 @@
defmodule SimpleshopTheme.Sync.ProductSyncWorkerTest do
use SimpleshopTheme.DataCase, async: false
use Oban.Testing, repo: SimpleshopTheme.Repo
alias SimpleshopTheme.Sync.ProductSyncWorker
import SimpleshopTheme.ProductsFixtures
describe "perform/1" do
test "cancels for missing connection" do
fake_id = Ecto.UUID.generate()
assert {:cancel, :connection_not_found} =
perform_job(ProductSyncWorker, %{provider_connection_id: fake_id})
end
test "cancels for disabled connection" do
conn = provider_connection_fixture(%{enabled: false})
assert {:cancel, :connection_disabled} =
perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
end
end
describe "enqueue/1" do
test "creates a job with correct args" do
# Temporarily switch to manual mode to avoid inline execution
Oban.Testing.with_testing_mode(:manual, fn ->
conn = provider_connection_fixture()
assert {:ok, %Oban.Job{} = job} = ProductSyncWorker.enqueue(conn.id)
# In manual mode, args use atom keys
assert job.args == %{provider_connection_id: conn.id}
assert job.queue == "sync"
end)
end
end
describe "enqueue/2 with delay" do
test "schedules a job for later" do
Oban.Testing.with_testing_mode(:manual, fn ->
conn = provider_connection_fixture()
assert {:ok, %Oban.Job{} = job} = ProductSyncWorker.enqueue(conn.id, 60)
# In manual mode, args use atom keys
assert job.args == %{provider_connection_id: conn.id}
# Job should be scheduled in the future
assert DateTime.compare(job.scheduled_at, DateTime.utc_now()) == :gt
end)
end
end
describe "job creation" do
test "new/1 creates job changeset with provider_connection_id" do
changeset = ProductSyncWorker.new(%{provider_connection_id: "test-id"})
assert changeset.changes.args == %{provider_connection_id: "test-id"}
assert changeset.changes.queue == "sync"
end
test "new/2 with scheduled_at creates scheduled job" do
future = DateTime.add(DateTime.utc_now(), 60, :second)
changeset = ProductSyncWorker.new(%{provider_connection_id: "test-id"}, scheduled_at: future)
assert changeset.changes.scheduled_at == future
end
end
end

View File

@@ -0,0 +1,106 @@
defmodule SimpleshopTheme.VaultTest do
use SimpleshopTheme.DataCase, async: true
alias SimpleshopTheme.Vault
describe "encrypt/1 and decrypt/1" do
test "round-trips a string successfully" do
plaintext = "my_secret_api_key_12345"
assert {:ok, ciphertext} = Vault.encrypt(plaintext)
assert is_binary(ciphertext)
assert ciphertext != plaintext
assert {:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "produces different ciphertext for same plaintext (random IV)" do
plaintext = "same_secret"
{:ok, ciphertext1} = Vault.encrypt(plaintext)
{:ok, ciphertext2} = Vault.encrypt(plaintext)
assert ciphertext1 != ciphertext2
# Both should decrypt to same value
assert {:ok, ^plaintext} = Vault.decrypt(ciphertext1)
assert {:ok, ^plaintext} = Vault.decrypt(ciphertext2)
end
test "handles empty string" do
assert {:ok, ciphertext} = Vault.encrypt("")
assert {:ok, ""} = Vault.decrypt(ciphertext)
end
test "handles unicode characters" do
plaintext = "héllo wörld 你好 🎉"
{:ok, ciphertext} = Vault.encrypt(plaintext)
{:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "handles long strings" do
plaintext = String.duplicate("a", 10_000)
{:ok, ciphertext} = Vault.encrypt(plaintext)
{:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "encrypt/1 returns {:ok, nil} for nil input" do
assert {:ok, nil} = Vault.encrypt(nil)
end
test "decrypt/1 returns {:ok, nil} for nil input" do
assert {:ok, nil} = Vault.decrypt(nil)
end
test "decrypt/1 returns {:ok, empty} for empty string" do
assert {:ok, ""} = Vault.decrypt("")
end
test "decrypt/1 returns error for invalid ciphertext" do
assert {:error, :invalid_ciphertext} = Vault.decrypt("not_valid")
assert {:error, :invalid_ciphertext} = Vault.decrypt(<<1, 2, 3>>)
end
test "decrypt/1 returns error for tampered ciphertext" do
{:ok, ciphertext} = Vault.encrypt("secret")
# Tamper with the ciphertext (flip a bit in the middle)
tampered = :binary.bin_to_list(ciphertext)
middle = div(length(tampered), 2)
tampered = List.update_at(tampered, middle, &Bitwise.bxor(&1, 0xFF))
tampered = :binary.list_to_bin(tampered)
assert {:error, :decryption_failed} = Vault.decrypt(tampered)
end
end
describe "encrypt!/1 and decrypt!/1" do
test "round-trips successfully" do
plaintext = "test_secret"
ciphertext = Vault.encrypt!(plaintext)
decrypted = Vault.decrypt!(ciphertext)
assert decrypted == plaintext
end
test "encrypt!/1 raises on invalid input type" do
assert_raise FunctionClauseError, fn ->
Vault.encrypt!(123)
end
end
test "decrypt!/1 raises on invalid ciphertext" do
assert_raise RuntimeError, ~r/Decryption failed/, fn ->
Vault.decrypt!("invalid")
end
end
end
end

View File

@@ -0,0 +1,250 @@
defmodule SimpleshopTheme.ProductsFixtures do
@moduledoc """
Test helpers for creating entities via the `SimpleshopTheme.Products` context.
"""
alias SimpleshopTheme.Products
def unique_provider_product_id, do: "prov_#{System.unique_integer([:positive])}"
def unique_slug, do: "product-#{System.unique_integer([:positive])}"
def unique_variant_id, do: "var_#{System.unique_integer([:positive])}"
# Provider types to cycle through for unique constraint
@provider_types ["printify", "gelato", "prodigi", "printful"]
@doc """
Returns valid attributes for a provider connection.
Uses a unique provider type to avoid constraint violations.
"""
def valid_provider_connection_attrs(attrs \\ %{}) do
# Get a unique provider type if not specified
provider_type = attrs[:provider_type] || unique_provider_type()
Enum.into(attrs, %{
provider_type: provider_type,
name: "Test #{String.capitalize(provider_type)} Connection",
enabled: true,
api_key: "test_api_key_#{System.unique_integer([:positive])}",
config: %{"shop_id" => "12345"}
})
end
defp unique_provider_type do
# Use modulo to cycle through provider types
idx = rem(System.unique_integer([:positive]), length(@provider_types))
Enum.at(@provider_types, idx)
end
@doc """
Creates a provider connection fixture.
Since provider_type has a unique constraint, this will reuse an existing
connection of the same type if one exists.
"""
def provider_connection_fixture(attrs \\ %{}) do
provider_type = attrs[:provider_type] || unique_provider_type()
# Try to find existing connection of this type first
case Products.get_provider_connection_by_type(provider_type) do
nil ->
{:ok, conn} =
attrs
|> Map.put(:provider_type, provider_type)
|> valid_provider_connection_attrs()
|> Products.create_provider_connection()
conn
existing ->
# Return existing connection (update if attrs differ)
if map_size(Map.delete(attrs, :provider_type)) > 0 do
{:ok, updated} = Products.update_provider_connection(existing, attrs)
updated
else
existing
end
end
end
@doc """
Returns valid attributes for a product.
"""
def valid_product_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
provider_product_id: unique_provider_product_id(),
title: "Test Product",
description: "A test product description",
slug: unique_slug(),
status: "active",
visible: true,
category: "Apparel",
provider_data: %{"blueprint_id" => 145, "print_provider_id" => 29}
})
end
@doc """
Creates a product fixture.
Automatically creates a provider connection if not provided.
"""
def product_fixture(attrs \\ %{}) do
conn = attrs[:provider_connection] || provider_connection_fixture()
{:ok, product} =
attrs
|> Map.delete(:provider_connection)
|> valid_product_attrs()
|> Map.put(:provider_connection_id, conn.id)
|> Products.create_product()
product
end
@doc """
Returns valid attributes for a product image.
"""
def valid_product_image_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
src: "https://example.com/image-#{System.unique_integer([:positive])}.jpg",
position: 0,
alt: "Product image"
})
end
@doc """
Creates a product image fixture.
"""
def product_image_fixture(attrs \\ %{}) do
product = attrs[:product] || product_fixture()
{:ok, image} =
attrs
|> Map.delete(:product)
|> valid_product_image_attrs()
|> Map.put(:product_id, product.id)
|> Products.create_product_image()
image
end
@doc """
Returns valid attributes for a product variant.
"""
def valid_product_variant_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
provider_variant_id: unique_variant_id(),
title: "Medium / Black",
sku: "TEST-SKU-#{System.unique_integer([:positive])}",
price: 2500,
compare_at_price: nil,
cost: 1200,
options: %{"Size" => "Medium", "Color" => "Black"},
is_enabled: true,
is_available: true
})
end
@doc """
Creates a product variant fixture.
"""
def product_variant_fixture(attrs \\ %{}) do
product = attrs[:product] || product_fixture()
{:ok, variant} =
attrs
|> Map.delete(:product)
|> valid_product_variant_attrs()
|> Map.put(:product_id, product.id)
|> Products.create_product_variant()
variant
end
@doc """
Creates a complete product fixture with images and variants.
"""
def complete_product_fixture(attrs \\ %{}) do
conn = attrs[:provider_connection] || provider_connection_fixture()
product = product_fixture(Map.put(attrs, :provider_connection, conn))
# Create images
for i <- 0..1 do
product_image_fixture(%{
product: product,
position: i,
src: "https://example.com/#{product.slug}-#{i}.jpg"
})
end
# Create variants
for size <- ["Small", "Medium", "Large"], color <- ["Black", "White"] do
product_variant_fixture(%{
product: product,
title: "#{size} / #{color}",
options: %{"Size" => size, "Color" => color},
price: if(size == "Large", do: 2800, else: 2500)
})
end
# Return product with preloaded associations
SimpleshopTheme.Repo.preload(product, [:images, :variants])
end
@doc """
Returns a sample Printify product API response for testing normalization.
"""
def printify_product_response do
%{
"id" => "12345",
"title" => "Classic T-Shirt",
"description" => "A comfortable cotton t-shirt",
"blueprint_id" => 145,
"print_provider_id" => 29,
"tags" => ["apparel", "clothing"],
"options" => [
%{
"name" => "Colors",
"type" => "color",
"values" => [
%{"id" => 751, "title" => "Solid White"},
%{"id" => 752, "title" => "Black"}
]
},
%{
"name" => "Sizes",
"type" => "size",
"values" => [
%{"id" => 2, "title" => "S"},
%{"id" => 3, "title" => "M"},
%{"id" => 4, "title" => "L"}
]
}
],
"variants" => [
%{
"id" => 100,
"title" => "Solid White / S",
"sku" => "TSH-WH-S",
"price" => 2500,
"cost" => 1200,
"options" => [751, 2],
"is_enabled" => true,
"is_available" => true
},
%{
"id" => 101,
"title" => "Black / M",
"sku" => "TSH-BK-M",
"price" => 2500,
"cost" => 1200,
"options" => [752, 3],
"is_enabled" => true,
"is_available" => true
}
],
"images" => [
%{"src" => "https://printify.com/img1.jpg", "position" => 0},
%{"src" => "https://printify.com/img2.jpg", "position" => 1}
]
}
end
end