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