From c5c06d9979ee54dc0849c3c2c21c3b760dae940e Mon Sep 17 00:00:00 2001 From: Jamey Greenwood Date: Thu, 29 Jan 2026 08:32:24 +0000 Subject: [PATCH] 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 --- config/config.exs | 5 +- docs/plans/products-context.md | 1183 +++++++++++++++++ lib/mix/tasks/generate_mockups.ex | 2 +- lib/simpleshop_theme/cldr.ex | 12 + .../client.ex => clients/printify.ex} | 33 +- .../generator.ex} | 4 +- lib/simpleshop_theme/products.ex | 338 +++++ lib/simpleshop_theme/products/product.ex | 108 ++ .../products/product_image.ex | 33 + .../products/product_variant.ex | 98 ++ .../products/provider_connection.ex | 97 ++ lib/simpleshop_theme/providers/printify.ex | 251 ++++ lib/simpleshop_theme/providers/provider.ex | 75 ++ .../sync/product_sync_worker.ex | 148 +++ lib/simpleshop_theme/vault.ex | 101 ++ mix.exs | 4 +- mix.lock | 9 + ...0128235845_create_provider_connections.exs | 20 + .../20260128235846_create_products.exs | 27 + .../20260128235847_create_product_images.exs | 18 + ...20260128235848_create_product_variants.exs | 27 + .../products/product_image_test.exs | 65 + .../products/product_test.exs | 248 ++++ .../products/product_variant_test.exs | 201 +++ .../products/provider_connection_test.exs | 169 +++ test/simpleshop_theme/products_test.exs | 469 +++++++ .../sync/product_sync_worker_test.exs | 69 + test/simpleshop_theme/vault_test.exs | 106 ++ test/support/fixtures/products_fixtures.ex | 250 ++++ 29 files changed, 4162 insertions(+), 8 deletions(-) create mode 100644 docs/plans/products-context.md create mode 100644 lib/simpleshop_theme/cldr.ex rename lib/simpleshop_theme/{printify/client.ex => clients/printify.ex} (83%) rename lib/simpleshop_theme/{printify/mockup_generator.ex => mockups/generator.ex} (99%) create mode 100644 lib/simpleshop_theme/products.ex create mode 100644 lib/simpleshop_theme/products/product.ex create mode 100644 lib/simpleshop_theme/products/product_image.ex create mode 100644 lib/simpleshop_theme/products/product_variant.ex create mode 100644 lib/simpleshop_theme/products/provider_connection.ex create mode 100644 lib/simpleshop_theme/providers/printify.ex create mode 100644 lib/simpleshop_theme/providers/provider.ex create mode 100644 lib/simpleshop_theme/sync/product_sync_worker.ex create mode 100644 lib/simpleshop_theme/vault.ex create mode 100644 priv/repo/migrations/20260128235845_create_provider_connections.exs create mode 100644 priv/repo/migrations/20260128235846_create_products.exs create mode 100644 priv/repo/migrations/20260128235847_create_product_images.exs create mode 100644 priv/repo/migrations/20260128235848_create_product_variants.exs create mode 100644 test/simpleshop_theme/products/product_image_test.exs create mode 100644 test/simpleshop_theme/products/product_test.exs create mode 100644 test/simpleshop_theme/products/product_variant_test.exs create mode 100644 test/simpleshop_theme/products/provider_connection_test.exs create mode 100644 test/simpleshop_theme/products_test.exs create mode 100644 test/simpleshop_theme/sync/product_sync_worker_test.exs create mode 100644 test/simpleshop_theme/vault_test.exs create mode 100644 test/support/fixtures/products_fixtures.ex diff --git a/config/config.exs b/config/config.exs index 72f8eef..209d48e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -80,6 +80,9 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# ex_money configuration for currency handling +config :ex_money, default_cldr_backend: SimpleshopTheme.Cldr + # Oban configuration for background jobs config :simpleshop_theme, Oban, engine: Oban.Engines.Lite, @@ -87,7 +90,7 @@ config :simpleshop_theme, Oban, plugins: [ {Oban.Plugins.Pruner, max_age: 60} ], - queues: [images: 2] + queues: [images: 2, sync: 1] # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/docs/plans/products-context.md b/docs/plans/products-context.md new file mode 100644 index 0000000..0ce2817 --- /dev/null +++ b/docs/plans/products-context.md @@ -0,0 +1,1183 @@ +# Plan: Products Context with Provider Integration + +## Goal +Build a Products context that syncs products from external POD providers (Printify first), stores them locally, and enables order submission for fulfillment. + +## Core Flow +1. **Connect** - Authenticate with provider (API key or OAuth) +2. **Sync** - Fetch products from provider, upsert locally +3. **Order** - Submit orders to provider for fulfillment + +--- + +## Current Domain Analysis + +SimpleShop has **6 well-defined domains** with clear boundaries: + +| Domain | Purpose | Schemas | Public Functions | +|--------|---------|---------|------------------| +| **Accounts** | User authentication & sessions | User, UserToken | 15 | +| **Settings** | Theme configuration persistence | Setting, ThemeSettings | 6 | +| **Theme** | CSS generation, caching, presets | (structs only) | N/A | +| **Media** | Image upload/storage | Image | 8 | +| **Images** | Image optimization pipeline | (Oban workers) | N/A | +| **Printify** | External API integration | (no schemas) | API client | + +The new **Products** context will be a new top-level domain that: +- Owns product/variant schemas +- Coordinates with Printify (and future providers) for sync +- Provides data to shop LiveViews (replacing PreviewData) + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SimpleshopTheme │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ WEB LAYER │ │ +│ │ SimpleshopThemeWeb │ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌─────────────────────────┐ │ │ +│ │ │ Shop LiveViews │ │ Admin LiveViews│ │ Theme Editor LiveView │ │ │ +│ │ │ - ProductShow │ │ - UserLogin │ │ - ThemeLive.Index │ │ │ +│ │ │ - Collection │ │ - UserSettings │ │ - Preview iframe │ │ │ +│ │ │ - Cart │ │ │ │ │ │ │ +│ │ └───────┬─────────┘ └────────────────┘ └────────┬───────────────┘ │ │ +│ └──────────┼────────────────────────────────────────┼──────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ DOMAIN LAYER │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Accounts │ │ Settings │ │ Theme │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ • User │ │ • Setting │ │ • CSSCache │ │ │ +│ │ │ • UserToken │ │ • Theme │ │ • CSSGen │ │ │ +│ │ │ │ │ Settings │ │ • Presets │ │ │ +│ │ │ │ │ │ │ • Fonts │ │ │ +│ │ │ │ │ │ │ • Preview │ │ │ +│ │ └─────────────┘ └──────┬──────┘ │ Data (*) │ │ │ +│ │ │ └─────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────────────┐ ┌──────▼──────┐ ┌─────────────┐ │ │ +│ │ │ Media │ │ Images │ │ Printify │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ • Image │◄──│ • Optimizer │ │ • Client │ │ │ +│ │ │ (upload) │ │ • Worker │ │ • Catalog │ (NEW) │ │ +│ │ │ │ │ • Cache │ │ • Mockup │ │ │ +│ │ └─────────────┘ └─────────────┘ └──────┬──────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────────────────────────────────────▼───────────────────┐ │ │ +│ │ │ Products (NEW) │ │ │ +│ │ │ │ │ │ +│ │ │ ┌─────────────────────┐ ┌────────────────────────────┐ │ │ │ +│ │ │ │ Provider Layer │ │ Product Layer │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ • ProviderConnection│───▶│ • Product │ │ │ │ +│ │ │ │ • Provider behaviour│ │ • ProductImage │ │ │ │ +│ │ │ │ • PrintifyProvider │ │ • ProductVariant │ │ │ │ +│ │ │ │ • (future providers)│ │ │ │ │ │ +│ │ │ └─────────────────────┘ └────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Sync Layer │ │ │ │ +│ │ │ │ • ProductSyncWorker (Oban) │ │ │ │ +│ │ │ │ • Upsert logic │ │ │ │ +│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Orders (NEW) │ │ │ +│ │ │ │ │ │ +│ │ │ • Order • submit_to_provider/1 │ │ │ +│ │ │ • OrderLineItem • track_status/1 │ │ │ +│ │ └────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ INFRASTRUCTURE │ │ +│ │ Repo (SQLite) • Vault (encryption) • Oban (background jobs) │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +(*) PreviewData will be replaced by Products context queries +``` + +--- + +## Database ERD + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CURRENT SCHEMA │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ users │ │ users_tokens │ │ +│ ├──────────────────┤ ├──────────────────┤ │ +│ │ id (binary_id) │◄────────│ user_id (FK) │ │ +│ │ email │ │ token │ │ +│ │ hashed_password │ │ context │ │ +│ │ confirmed_at │ │ sent_to │ │ +│ │ inserted_at │ │ inserted_at │ │ +│ │ updated_at │ └──────────────────┘ │ +│ └──────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ settings │ │ images │ │ +│ ├──────────────────┤ ├──────────────────┤ │ +│ │ id (binary_id) │ │ id (binary_id) │ │ +│ │ key │ │ filename │ │ +│ │ value (JSON) │ │ content_type │ │ +│ │ inserted_at │ │ byte_size │ │ +│ │ updated_at │ │ width │ │ +│ └──────────────────┘ │ height │ │ +│ │ storage_key │ │ +│ │ inserted_at │ │ +│ │ updated_at │ │ +│ └──────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ NEW SCHEMA (Products) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────┐ │ +│ │ provider_connections │ │ +│ ├────────────────────────┤ │ +│ │ id (binary_id) │ │ +│ │ provider_type │──┐ "printify" | "prodigi" | "gelato" │ +│ │ name │ │ │ +│ │ enabled │ │ │ +│ │ api_key_encrypted │ │ (Vault encrypted) │ +│ │ config (JSON) │ │ {"shop_id": "123"} │ +│ │ last_synced_at │ │ │ +│ │ sync_status │ │ │ +│ │ inserted_at │ │ │ +│ │ updated_at │ │ │ +│ └───────────┬────────────┘ │ │ +│ │ │ │ +│ │ 1 │ │ +│ ▼ * │ │ +│ ┌────────────────────────┐ │ │ +│ │ products │ │ │ +│ ├────────────────────────┤ │ │ +│ │ id (binary_id) │ │ │ +│ │ provider_connection_id │◄─┘ │ +│ │ provider_product_id │ External ID (unique per connection) │ +│ │ title │ │ +│ │ description │ │ +│ │ slug │ URL-friendly (unique globally) │ +│ │ status │ "active" | "draft" | "archived" │ +│ │ visible │ │ +│ │ category │ │ +│ │ option_types (JSON) │ ["size", "color"] - display order │ +│ │ provider_data (JSON) │ Provider-specific fields (blueprint_id etc) │ +│ │ inserted_at │ │ +│ │ updated_at │ │ +│ └───────────┬────────────┘ │ +│ │ │ +│ │ 1 │ +│ ┌─────────┴─────────┐ │ +│ ▼ * ▼ * │ +│ ┌──────────────────┐ ┌──────────────────────┐ │ +│ │ product_images │ │ product_variants │ │ +│ ├──────────────────┤ ├──────────────────────┤ │ +│ │ id (binary_id) │ │ id (binary_id) │ │ +│ │ product_id (FK) │ │ product_id (FK) │ │ +│ │ src │ │ provider_variant_id │ │ +│ │ position │ │ title │ "Large / Black" │ +│ │ alt │ │ sku │ │ +│ │ inserted_at │ │ price │ Selling price (pence) │ +│ │ updated_at │ │ compare_at_price │ For sale display │ +│ └──────────────────┘ │ cost │ Provider cost (profit) │ +│ │ options (JSON) │ {"size": "L", "color": ""} │ +│ │ is_enabled │ │ +│ │ is_available │ │ +│ │ inserted_at │ │ +│ │ updated_at │ │ +│ └──────────────────────┘ │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ NEW SCHEMA (Orders) │ +│ │ +│ Customer sees ONE order. Fulfillments are internal (one per provider). │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────┐ │ +│ │ orders │ Customer-facing order │ +│ ├────────────────────────┤ │ +│ │ id (binary_id) │ │ +│ │ order_number │ "SS-250125-A1B2" (customer sees this) │ +│ │ status │ pending → processing → shipped → delivered │ +│ │ payment_status │ pending | paid | refunded │ +│ │ customer_email │ │ +│ │ shipping_address (JSON)│ │ +│ │ subtotal │ (Money - ex_money) │ +│ │ shipping_cost │ (Money - ex_money) │ +│ │ total │ (Money - ex_money) │ +│ │ total_cost │ Provider costs (for profit) │ +│ │ inserted_at │ │ +│ │ updated_at │ │ +│ └───────────┬────────────┘ │ +│ │ │ +│ │ 1 │ +│ ┌─────────┴─────────────────────────┐ │ +│ ▼ * ▼ * │ +│ ┌────────────────────────┐ ┌─────────────────────────┐ │ +│ │ order_fulfillments │ │ order_line_items │ │ +│ ├────────────────────────┤ ├─────────────────────────┤ │ +│ │ id (binary_id) │ │ id (binary_id) │ │ +│ │ order_id (FK) │ │ order_id (FK) │ │ +│ │ provider_connection_id │ │ order_fulfillment_id(FK)│ Which provider │ +│ │ provider_order_id │ │ product_variant_id (FK) │ │ +│ │ status │ │ quantity │ │ +│ │ tracking_number │ │ unit_price │ │ +│ │ tracking_url │ │ unit_cost │ │ +│ │ shipped_at │ │ inserted_at │ │ +│ │ inserted_at │ │ updated_at │ │ +│ │ updated_at │ └─────────────────────────┘ │ +│ └────────────────────────┘ │ +│ │ +│ ┌────────────────────────┐ │ +│ │ order_events │ Timeline visible to customer │ +│ ├────────────────────────┤ │ +│ │ id (binary_id) │ │ +│ │ order_id (FK) │ │ +│ │ type │ "placed" | "paid" | "shipped" | "delivered" │ +│ │ message │ "Your T-Shirt has shipped via Royal Mail" │ +│ │ metadata (JSON) │ {tracking_number, carrier, etc} │ +│ │ inserted_at │ │ +│ └────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Printify Code Reuse + +### Existing Client (Highly Reusable) + +The existing `Printify.Client` is directly reusable: + +```elixir +# HTTP infrastructure (no changes needed) +Client.get/2, Client.post/3, Client.delete/2 + +# Shop management +Client.get_shops/0, Client.get_shop_id/0 + +# Product operations (already implemented) +Client.get_product/2, Client.create_product/2, Client.delete_product/2 +``` + +**To add:** +```elixir +Client.list_products/1 # GET /shops/:shop_id/products.json +Client.create_order/2 # POST /shops/:shop_id/orders.json +Client.get_order/2 # GET /shops/:shop_id/orders/:id.json +``` + +### MockupGenerator Refactoring + +Extract reusable helpers into new `Printify.Catalog` module: + +| Function | Current | Target | +|----------|---------|--------| +| `find_blueprint/1` | MockupGenerator | `Printify.Catalog` | +| `find_print_provider/2` | MockupGenerator | `Printify.Catalog` | +| `get_variant_info/1` | MockupGenerator | `Printify.Catalog` | +| `calculate_cover_scale/2` | MockupGenerator | Keep (mockup-specific) | +| `product_definitions/0` | MockupGenerator | Keep (sample data) | + +**Module structure:** +``` +lib/simpleshop_theme/clients/ +├── printify.ex # Printify HTTP client (moved from printify/client.ex) +├── gelato.ex # Gelato HTTP client +└── prodigi.ex # Prodigi HTTP client + +lib/simpleshop_theme/providers/ +├── provider.ex # Behaviour definition +├── printify.ex # Printify implementation (uses Clients.Printify) +├── gelato.ex # Gelato implementation (uses Clients.Gelato) +└── prodigi.ex # Prodigi implementation (uses Clients.Prodigi) + +lib/simpleshop_theme/mockups/ +└── generator.ex # Mockup generation (currently uses Clients.Printify) + # Provider-agnostic location for future flexibility + +lib/simpleshop_theme/printify/ +└── catalog.ex # Blueprint/variant discovery helpers (Printify-specific) +``` + +Each provider module uses its corresponding client. The mockup generator is in a provider-agnostic location. + +--- + +## Database Schema + +### Tables Overview + +| Table | Purpose | +|-------|---------| +| `provider_connections` | Provider credentials + config (one per type) | +| `products` | Core product data synced from provider | +| `product_images` | Product images (multiple per product) | +| `product_variants` | Size/color variants with pricing | +| `orders` | Customer orders | +| `order_line_items` | Items within orders | + +### Key Design Decisions + +1. **Provider IDs on product directly** (not a separate linking table) + - `products.provider_product_id` stores external ID + - `product_variants.provider_variant_id` stores external variant ID + - Unique constraint: `[:provider_connection_id, :provider_product_id]` + +2. **Credentials encrypted in database** + - Use `SimpleshopTheme.Vault` for at-rest encryption + - `api_key_encrypted`, `oauth_access_token_encrypted` fields + +3. **Cost tracking for profit calculation** + - `product_variants.cost` - cost from provider + - `order_line_items.unit_cost` - snapshot at order time + - `orders.total_cost` - sum for profit reporting + +4. **Provider type as enum** (not separate table) + - Hardcoded: `~w(printify prodigi gelato)` + - Keeps type safety for provider-specific logic + +--- + +## Schema Details + +### provider_connections +```elixir +field :provider_type, :string # "printify", "prodigi", "gelato" +field :name, :string # "My Printify Account" +field :enabled, :boolean +field :api_key_encrypted, :binary # Encrypted API key +field :config, :map # {"shop_id": "12345"} +field :last_synced_at, :utc_datetime +field :sync_status, :string # pending, syncing, completed, failed +``` + +### products +```elixir +belongs_to :provider_connection +field :provider_product_id, :string # External ID from provider +field :title, :string +field :description, :text +field :slug, :string # URL-friendly, unique +field :status, :string # active, draft, archived +field :visible, :boolean +field :category, :string +field :option_types, {:array, :string} # ["Colors", "Sizes"] - option names in display order + # Extracted from provider's product.options[].name on sync + # Used to order the variant selector UI +field :provider_data, :map # Provider-specific fields stored here + # Printify: %{"blueprint_id" => 145, "print_provider_id" => 29, ...} + # Keeps products table generic across providers +``` + +### product_variants +```elixir +belongs_to :product +field :provider_variant_id, :string # CRITICAL: The ID we send back to provider when ordering + # Printify: variant_id (e.g., "12345") + # Printful: sync_variant_id (e.g., "4752058849") + # Gelato: productUid (e.g., "apparel_product_gca_t-shirt_gsi_m_gco_white") + # Prodigi: sku (e.g., "GLOBAL-CAN-10x10") +field :title, :string # "Large / Black" +field :sku, :string # Shop's internal SKU (optional) +field :price, :integer # Selling price (pence) +field :compare_at_price, :integer # Original price (for sales) +field :cost, :integer # Provider cost (for profit) +field :options, :map # {"size": "Large", "color": "Black"} + # JSON is appropriate here because: + # - Options always loaded with variant (no join needed) + # - No need to query "find all Large variants" + # - Matches provider data format (easy sync) + # - Simpler than a separate options table +field :is_enabled, :boolean +field :is_available, :boolean # In stock at provider +``` + +### orders (customer-facing) +```elixir +# Customer sees ONE order, even if fulfilled by multiple providers +field :order_number, :string # "SS-250125-A1B2" (customer-visible) +field :status, :string # Aggregate: pending → processing → shipped → delivered +field :payment_status, :string # pending, paid, refunded +field :customer_email, :string +field :shipping_address, :map +field :subtotal, Money.Ecto.Map.Type # Using ex_money for currency safety +field :shipping_cost, Money.Ecto.Map.Type +field :total, Money.Ecto.Map.Type +field :total_cost, Money.Ecto.Map.Type # Sum of provider costs + +has_many :order_fulfillments # One per provider +has_many :order_line_items +has_many :order_events # Timeline for customer +``` + +### order_fulfillments (one per provider) +```elixir +belongs_to :order +belongs_to :provider_connection +field :provider_order_id, :string # External ID after submission +field :status, :string # pending → submitted → processing → shipped +field :tracking_number, :string +field :tracking_url, :string +field :shipped_at, :utc_datetime + +has_many :order_line_items # Items fulfilled by this provider +``` + +### order_line_items +```elixir +belongs_to :order +belongs_to :order_fulfillment # Links to which provider ships this item +belongs_to :product_variant +field :quantity, :integer +field :unit_price, Money.Ecto.Map.Type +field :unit_cost, Money.Ecto.Map.Type + +# Per-item tracking (provider may ship items separately) +field :status, :string # pending → shipped → delivered +field :tracking_number, :string +field :tracking_url, :string +field :shipped_at, :utc_datetime +``` + +### order_events (customer timeline) +```elixir +belongs_to :order +field :type, :string # Event types (see below) +field :message, :string # "Your T-Shirt and Mug have shipped!" +field :metadata, :map # {tracking_number, carrier, items, etc} + +# Event types: +# - "placed" → Order placed +# - "paid" → Payment confirmed +# - "fulfillment_shipped" → Some items shipped (granular per-fulfillment) +# - "shipped" → ALL items now shipped +# - "delivered" → ALL items delivered + +# Example metadata for fulfillment_shipped: +# %{fulfillment_id: "uuid", items: ["T-Shirt", "Mug"], +# carrier: "Royal Mail", tracking_number: "RM123", tracking_url: "..."} +``` + +--- + +## Variant Selector UX Pattern + +Products with multiple option types (e.g., size × color × sleeves) can have many variants: +- 4 sizes × 4 colors × 2 sleeves = **32 variants** + +### Data Model + +Each variant stores its specific combination as JSON with **human-readable labels**: +```elixir +%{id: "v1", options: %{"size" => "M", "color" => "Black", "sleeves" => "Short"}, price: 2500} +%{id: "v2", options: %{"size" => "M", "color" => "Black", "sleeves" => "Long"}, price: 2700} +# ... etc +``` + +### Provider Option Structure (Important) + +Providers like Printify use a **two-tier option system**: + +**Product level** - defines options with IDs and labels: +```json +{ + "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" } + ]} + ] +} +``` + +**Variant level** - only stores option IDs: +```json +{ "id": 12345, "options": [751, 2], "title": "Solid White / S" } +``` + +### Sync Strategy: Denormalize to Labels + +During sync, we **resolve IDs to labels** and store the human-readable version: + +```elixir +def normalize_variant(provider_variant, product_options) do + # Build ID -> label lookup from product options + option_lookup = build_option_lookup(product_options) + # => %{751 => {"Colors", "Solid White"}, 2 => {"Sizes", "S"}} + + # Convert variant's [751, 2] to %{"Colors" => "Solid White", "Sizes" => "S"} + options = + provider_variant["options"] + |> Enum.map(&Map.get(option_lookup, &1)) + |> Enum.reject(&is_nil/1) + |> Map.new() + + %{ + provider_variant_id: to_string(provider_variant["id"]), + title: provider_variant["title"], + options: options, + # ... other fields + } +end +``` + +**Why denormalize?** +- No need to join to an options table on every product page load +- Labels rarely change (and if they do, re-sync updates them) +- Simpler queries, faster rendering +- Keeps our schema provider-agnostic (we just store labels, not provider-specific IDs) + +### Provider Compatibility + +All major POD providers can be normalized to our `options: %{"Size" => "M", "Color" => "Black"}` schema: + +| Provider | Raw Variant Structure | Normalization | +|----------|----------------------|---------------| +| **Printify** | `options: [751, 2]` (IDs only, labels at product level) | Resolve IDs → labels during sync | +| **Printful** | `color: "Black", size: "M"` (flat attributes) | Map attribute keys to standard names | +| **Gelato** | `variantOptions: [{name: "Size", value: "M"}]` (array) | Convert array to map | +| **Prodigi** | `attributes: {"wrap": "Black"}` (object) | Direct mapping | + +Each provider's `normalize_variant/2` function handles the transformation: + +```elixir +# Printify - resolve IDs to labels +%{"options" => [751, 2]} → %{"Colors" => "Solid White", "Sizes" => "S"} + +# Printful - rename keys +%{"color" => "Black", "size" => "M"} → %{"Color" => "Black", "Size" => "M"} + +# Gelato - array to map +[%{"name" => "Size", "value" => "M"}] → %{"Size" => "M"} + +# Prodigi - direct (already a map) +%{"wrap" => "Black"} → %{"Wrap" => "Black"} +``` + +Sources: +- [Printful API Documentation](https://developers.printful.com/docs/) +- [Gelato API - Get Template](https://dashboard.gelato.com/docs/ecommerce/templates/get/) +- [Prodigi API Documentation](https://www.prodigi.com/print-api/docs/reference/) + +### Why JSON Options Work Well + +| Use Case | Query Pattern | Efficient? | +|----------|---------------|------------| +| Product page - list variants | `where(product_id: ^id)` | Yes - no JSON query | +| Cart - get variant by ID | `Repo.get(Variant, id)` | Yes - no JSON query | +| Display options on page | Access `variant.options` | Yes - in-memory map | +| Find matching variant | `Enum.find` on loaded list | Yes - all variants loaded anyway | + +A separate `variant_options` table would add joins and complexity without benefit. + +### LiveView Implementation + +**On mount**, load product with all variants and extract option types: +```elixir +def mount(_params, _session, socket) do + product = Products.get_product_with_variants(slug) + + # Extract unique option names and values from variants + option_types = extract_option_types(product.variants) + # => %{ + # "size" => ["S", "M", "L", "XL"], + # "color" => ["Black", "Green", "Blue", "Grey"], + # "sleeves" => ["Short", "Long"] + # } + + {:ok, assign(socket, product: product, option_types: option_types, selected: %{})} +end + +defp extract_option_types(variants) do + variants + |> Enum.flat_map(fn v -> Map.to_list(v.options) end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Map.new(fn {k, v} -> {k, Enum.uniq(v)} end) +end +``` + +### Cascading Selector UI + +Standard e-commerce pattern with availability filtering: + +``` +┌─────────────────────────────────────┐ +│ Size: [S] [M] [L] [XL] │ ← User selects "M" +├─────────────────────────────────────┤ +│ Color: [Black] [Green] [Blue] │ ← Grey disabled (no M+Grey combo) +├─────────────────────────────────────┤ +│ Sleeves: [Short] [Long] │ ← User selects "Long" +├─────────────────────────────────────┤ +│ Price: £27.00 │ +│ [Add to Cart] │ ← Adds variant "M/Black/Long" +└─────────────────────────────────────┘ +``` + +**Key behaviors:** +1. User selects options one at a time +2. Unavailable combinations are disabled/greyed out +3. Once all options selected, show matching variant's price +4. Add to cart uses the specific variant ID + +### Selection Handler + +```elixir +def handle_event("select_option", %{"option" => option, "value" => value}, socket) do + selected = Map.put(socket.assigns.selected, option, value) + + # Find matching variant (if all options selected) + current_variant = find_matching_variant(socket.assigns.product.variants, selected) + + # Determine which options are still available given current selection + available_options = compute_available_options(socket.assigns.product.variants, selected) + + {:noreply, assign(socket, + selected: selected, + current_variant: current_variant, + available_options: available_options + )} +end + +defp find_matching_variant(variants, selected) do + Enum.find(variants, fn v -> Map.equal?(v.options, selected) end) +end + +defp compute_available_options(variants, selected) do + # For each unselected option type, find values that have + # at least one variant matching current selection + # (filters out impossible combinations) +end +``` + +### Optional: Store Option Order on Product + +For consistent selector ordering, optionally store on product: +```elixir +field :option_types, {:array, :string} # ["size", "color", "sleeves"] +``` + +This defines display order. Can be extracted from provider API on first sync. + +--- + +## Provider Behaviour + +```elixir +@callback provider_type() :: String.t() +@callback test_connection(conn) :: {:ok, map()} | {:error, term()} +@callback fetch_products(conn) :: {:ok, [map()]} | {:error, term()} +@callback normalize_product(raw_product) :: map() # Transform to our schema +@callback normalize_variant(raw_variant, product) :: map() # Transform + resolve options +@callback build_order_item(variant, quantity) :: map() # Build provider-specific order item +@callback submit_order(conn, order) :: {:ok, %{provider_order_id: String.t()}} | {:error, term()} +@callback get_order_status(conn, provider_order_id) :: {:ok, map()} | {:error, term()} +``` + +Each provider (Printify, Prodigi, Gelato) implements this behaviour with provider-specific API calls and data normalization. + +--- + +## Order Submission Flow + +When submitting an order, we must send **provider-specific variant identifiers** back to each provider. + +### What Each Provider Needs + +| Provider | Order Item Fields Required | Source in Our Schema | +|----------|---------------------------|---------------------| +| **Printify** | `product_id`, `variant_id`, `print_provider_id` | `product.provider_product_id`, `variant.provider_variant_id`, `product.provider_data["print_provider_id"]` | +| **Printful** | `sync_variant_id` | `variant.provider_variant_id` | +| **Gelato** | `productUid`, `itemReferenceId` | `variant.provider_variant_id`, our `order_line_item.id` | +| **Prodigi** | `sku`, `attributes` | `variant.provider_variant_id`, `variant.options` | + +### Provider `build_order_item/2` Examples + +```elixir +# Printify +def build_order_item(variant, quantity) do + product = variant.product + %{ + "product_id" => product.provider_product_id, + "variant_id" => String.to_integer(variant.provider_variant_id), + "print_provider_id" => product.provider_data["print_provider_id"], + "quantity" => quantity + } +end + +# Printful +def build_order_item(variant, quantity) do + %{ + "sync_variant_id" => String.to_integer(variant.provider_variant_id), + "quantity" => quantity + } +end + +# Gelato +def build_order_item(variant, quantity) do + %{ + "productUid" => variant.provider_variant_id, + "quantity" => quantity, + "itemReferenceId" => Ecto.UUID.generate() + } +end + +# Prodigi +def build_order_item(variant, quantity) do + %{ + "sku" => variant.provider_variant_id, + "copies" => quantity, + "attributes" => variant.options # {"wrap" => "Black"} etc. + } +end +``` + +### Order Submission Implementation + +```elixir +def submit_to_provider(%Order{} = order) do + order = Repo.preload(order, [:provider_connection, line_items: [product_variant: :product]]) + provider = Provider.for_type(order.provider_connection.provider_type) + + # Build provider-specific line items + items = Enum.map(order.line_items, fn item -> + provider.build_order_item(item.product_variant, item.quantity) + end) + + # Submit via provider behaviour + case provider.submit_order(order.provider_connection, %{ + line_items: items, + shipping_address: order.shipping_address, + external_id: order.order_number + }) do + {:ok, %{provider_order_id: provider_id}} -> + order + |> Order.changeset(%{provider_order_id: provider_id, status: "submitted"}) + |> Repo.update() + + {:error, reason} -> + {:error, reason} + end +end +``` + +### Key Insight + +The `provider_variant_id` field serves a **dual purpose**: +1. **Sync**: We receive it from the provider and store it +2. **Order**: We send it back to the provider when fulfilling + +This is why it's stored as a string - different providers use different formats (integers, UUIDs, compound UIDs), but we treat them all as opaque identifiers. + +Sources: +- [Printify API Reference](https://developers.printify.com/) +- [Printful API Documentation](https://developers.printful.com/docs/) +- [Gelato API - Create Order](https://dashboard.gelato.com/docs/orders/v4/create/) +- [Prodigi API Documentation](https://www.prodigi.com/print-api/docs/reference/) + +--- + +## Sync Strategy + +1. Fetch all products from provider via API +2. For each product: + - Find existing by `[provider_connection_id, provider_product_id]` + - Upsert product with changeset + - Delete removed images, insert new ones + - Upsert variants (delete removed, update existing, insert new) +3. Update `provider_connection.last_synced_at` + +Sync runs via Oban worker for async processing with retry on failure. + +--- + +## Files to Create + +### Dependencies +Add to `mix.exs`: +- `{:ex_money, "~> 5.0"}` - Currency handling +- `{:ex_money_sql, "~> 1.0"}` - Ecto types for Money + +### Migrations +- `*_create_provider_connections.exs` +- `*_create_products.exs` +- `*_create_product_images.exs` +- `*_create_product_variants.exs` +- `*_create_orders.exs` +- `*_create_order_fulfillments.exs` +- `*_create_order_line_items.exs` +- `*_create_order_events.exs` +- `*_create_admin_notifications.exs` + +### Schemas +- `lib/simpleshop_theme/products/provider_connection.ex` +- `lib/simpleshop_theme/products/product.ex` +- `lib/simpleshop_theme/products/product_image.ex` +- `lib/simpleshop_theme/products/product_variant.ex` +- `lib/simpleshop_theme/orders/order.ex` +- `lib/simpleshop_theme/orders/order_fulfillment.ex` +- `lib/simpleshop_theme/orders/order_line_item.ex` +- `lib/simpleshop_theme/orders/order_event.ex` +- `lib/simpleshop_theme/admin_notifications/notification.ex` + +### Contexts +- `lib/simpleshop_theme/products.ex` - Product queries, sync logic +- `lib/simpleshop_theme/orders.ex` - Order creation, submission +- `lib/simpleshop_theme/admin_notifications.ex` - Admin notification management + +### Providers +- `lib/simpleshop_theme/providers/provider.ex` - Behaviour definition +- `lib/simpleshop_theme/providers/printify.ex` - Printify implementation + +### Workers +- `lib/simpleshop_theme/sync/product_sync_worker.ex` - Oban worker + +### Webhooks +- `lib/simpleshop_theme_web/controllers/webhook_controller.ex` +- `lib/simpleshop_theme/webhooks/printify_handler.ex` + +### Notifiers +- `lib/simpleshop_theme_web/notifiers/customer_notifier.ex` - Customer emails + +### Support +- `lib/simpleshop_theme/vault.ex` - Credential encryption + +--- + +## Files to Modify + +- `lib/simpleshop_theme/printify/client.ex` → Move to `lib/simpleshop_theme/clients/printify.ex` +- `lib/simpleshop_theme/printify/mockup_generator.ex` → Move to `lib/simpleshop_theme/mockups/generator.ex` +- `lib/simpleshop_theme/theme/preview_data.ex` - Query real products when available +- `lib/simpleshop_theme_web/live/shop_live/*.ex` - Use Products context instead of PreviewData + +--- + +## Implementation Phases + +### Phase 1: Schema Foundation +1. Create Vault module for credential encryption +2. Create all migrations +3. Create all schema modules +4. Run migrations + +### Phase 2: Products Context +1. Create Products context with CRUD +2. Implement sync/upsert logic +3. Create ProductSyncWorker (Oban) +4. Update Printify.Client with get_products endpoint + +### Phase 3: Provider Abstraction +1. Create Provider behaviour +2. Implement Printify provider +3. Connect admin UI for provider setup +4. Test sync flow end-to-end + +### Phase 4: Orders +1. Create Orders context +2. Implement order submission to provider +3. Add order status tracking + +### Phase 5: Integration +1. Update PreviewData to use real products +2. Update shop LiveViews +3. Update cart to use real variant IDs + +--- + +## Notifications Strategy + +### Admin Notifications (Internal) +Stored in database, shown in admin dashboard: + +```elixir +schema "admin_notifications" do + field :type, :string # "sync_error", "order_failed", "webhook_error" + field :severity, :string # "info", "warning", "error" + field :title, :string + field :message, :string + field :metadata, :map + field :read_at, :utc_datetime + timestamps() +end +``` + +**Use cases:** Sync failures, order submission errors, provider API issues. + +### Customer Notifications +Two mechanisms: +1. **Transactional emails** via `CustomerNotifier` (order confirmation, shipping updates) +2. **Order events** table for timeline display on order page + +```elixir +# order_events - visible to customer on their order page +schema "order_events" do + belongs_to :order + field :type, :string # "placed", "paid", "shipped", "delivered" + field :message, :string # Human-readable status + field :metadata, :map # {tracking_number, carrier, fulfillment_id} + timestamps() +end +``` + +### Files to Create +- `lib/simpleshop_theme/admin_notifications.ex` - Admin notification context +- `lib/simpleshop_theme/admin_notifications/notification.ex` - Schema +- `lib/simpleshop_theme/orders/order_event.ex` - Customer-facing event schema +- `lib/simpleshop_theme_web/notifiers/customer_notifier.ex` - Emails + +--- + +## Data Integrity: Checksum Approach + +Use checksums to detect changes during sync (more robust than `updated_at` across providers): + +```elixir +# On product schema +field :provider_checksum, :string + +# Computing checksum +def compute_checksum(provider_data) do + provider_data + |> normalize_for_checksum() + |> Jason.encode!(pretty: false) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) +end + +defp normalize_for_checksum(data) do + data + |> Map.drop(["created_at", "updated_at"]) # Ignore volatile fields + |> sort_keys_recursively() +end + +# During sync +def needs_update?(existing_product, provider_data) do + new_checksum = compute_checksum(provider_data) + existing_product.provider_checksum != new_checksum +end +``` + +**Benefits:** +- Works regardless of provider timestamp support +- Catches any field change +- Avoids unnecessary database writes +- Stable across JSON key ordering variations + +--- + +## Edge Cases (Follow-up Implementation Notes) + +| Edge Case | Expected Behavior | +|-----------|-------------------| +| **Provider API down during sync** | Oban retries with exponential backoff (30s → 5min). After max_attempts (3), mark sync as `failed`. Create admin notification. | +| **Product deleted on provider** | Compare local products with provider list during sync. Mark missing products as `status: "archived"` (don't hard delete - preserves order history). | +| **Variant prices change mid-cart** | Cart stores `cached_price` at add-time. On checkout, re-validate current prices. Show warning to customer if price increased, allow proceed if decreased. | +| **`provider_variant_id` format changes** | Already stored as string. Provider behaviour's `normalize_variant/2` handles format. If API version changes, update provider module. | +| **Provider returns malformed data** | Validate in `normalize_product/1`. Log error, skip malformed product, continue batch. Don't fail entire sync. | +| **Rate limiting from provider** | Single sync worker (queue concurrency: 1). Add configurable delay between paginated requests. Respect Retry-After headers. | +| **Sync interrupted mid-batch** | Track sync progress in `provider_connection.sync_metadata`. On resume, continue from last successful page. | +| **Duplicate slugs across providers** | Generate slug with provider suffix on collision: `awesome-shirt-abc123` where `abc123` is short provider product ID. | +| **Webhook signature validation** | Each provider has different signing. Validate HMAC signature before processing. Reject invalid webhooks with 401. | +| **Order submission fails** | Keep order as `status: "pending"`. Retry via Oban worker. After max retries, mark as `status: "failed"`, notify admin. | + +--- + +## Testing Strategy + +### Test Setup + +**Mocking External APIs (Mox pattern):** +```elixir +# test/support/mocks.ex +Mox.defmock(SimpleshopTheme.Clients.MockPrintify, for: SimpleshopTheme.Clients.PrintifyBehaviour) + +# config/test.exs +config :simpleshop_theme, :printify_client, SimpleshopTheme.Clients.MockPrintify +``` + +**Oban testing:** +```elixir +use Oban.Testing, repo: SimpleshopTheme.Repo +# Jobs run synchronously in tests via perform_job/2 +``` + +### Unit Tests + +| Module | Test Coverage | +|--------|---------------| +| **Products.Product** | Changeset validation, slug generation, status transitions | +| **Products.ProductVariant** | Options validation, price/cost integrity | +| **Products.ProviderConnection** | Credential encryption/decryption, config validation | +| **Providers.Printify** | `normalize_product/1`, `normalize_variant/2`, `build_order_item/2` | +| **Orders.Order** | Status aggregation from fulfillments, total calculation | +| **Orders.OrderFulfillment** | Status transitions, tracking updates | +| **AdminNotifications** | Create, list_unread, mark_read | + +### Integration Tests + +| Flow | Test Scenarios | +|------|----------------| +| **Product Sync** | Successful sync, partial failures, pagination, checksum skip, archived products | +| **Order Creation** | Single provider, multi-provider split into fulfillments | +| **Order Submission** | Success, API failure with retry, partial fulfillment submission | +| **Webhook Handling** | Valid signature, invalid signature rejection, status updates | +| **Variant Selector** | Option extraction, availability filtering, variant matching | + +### Example Test Cases + +```elixir +# test/simpleshop_theme/products_test.exs +describe "sync_products/1" do + test "syncs products from provider" do + conn = provider_connection_fixture() + + expect(MockPrintify, :list_products, fn _shop_id -> + {:ok, [printify_product_fixture()]} + end) + + assert {:ok, %{synced: 1, failed: 0}} = Products.sync_products(conn) + assert [product] = Products.list_products() + assert product.title == "Test Product" + end + + test "continues on single product failure" do + expect(MockPrintify, :list_products, fn _ -> + {:ok, [valid_product(), malformed_product()]} + end) + + assert {:ok, %{synced: 1, failed: 1}} = Products.sync_products(conn) + end + + test "skips unchanged products via checksum" do + existing = product_fixture(provider_checksum: "abc123") + + expect(MockPrintify, :list_products, fn _ -> + {:ok, [product_with_same_checksum()]} + end) + + assert {:ok, %{synced: 0, skipped: 1}} = Products.sync_products(conn) + end +end + +# test/simpleshop_theme/orders_test.exs +describe "create_order_from_cart/1" do + test "splits cart into fulfillments by provider" do + printify_variant = variant_fixture(provider: :printify) + gelato_variant = variant_fixture(provider: :gelato) + + cart = [ + %{variant_id: printify_variant.id, quantity: 1}, + %{variant_id: gelato_variant.id, quantity: 2} + ] + + assert {:ok, order} = Orders.create_order_from_cart(cart, customer_params()) + assert length(order.order_fulfillments) == 2 + end +end + +# test/simpleshop_theme/sync/product_sync_worker_test.exs +describe "perform/1" do + test "retries on API failure" do + expect(MockPrintify, :list_products, fn _ -> {:error, :timeout} end) + + assert {:error, :timeout} = perform_job(ProductSyncWorker, %{conn_id: conn.id}) + # Oban will retry automatically + end + + test "creates admin notification on final failure" do + # After max_attempts... + assert [notification] = AdminNotifications.list_unread() + assert notification.type == "sync_error" + end +end +``` + +### Test Fixtures + +```elixir +# test/support/fixtures/products_fixtures.ex +def provider_connection_fixture(attrs \\ %{}) do + {:ok, conn} = Products.create_provider_connection(%{ + provider_type: "printify", + name: "Test Connection", + api_key: "test_key", + config: %{"shop_id" => "123"} + } |> Map.merge(attrs)) + conn +end + +def product_fixture(attrs \\ %{}) do + conn = attrs[:provider_connection] || provider_connection_fixture() + {:ok, product} = Products.create_product(%{ + provider_connection_id: conn.id, + provider_product_id: "ext_#{System.unique_integer()}", + title: "Test Product", + slug: "test-product-#{System.unique_integer()}", + status: "active" + } |> Map.merge(attrs)) + product +end + +def printify_product_fixture do + %{ + "id" => "12345", + "title" => "Test Product", + "description" => "A test product", + "options" => [%{"name" => "Size", "values" => [%{"id" => 1, "title" => "M"}]}], + "variants" => [%{"id" => 100, "options" => [1], "price" => 2500}], + "images" => [%{"src" => "https://example.com/img.jpg", "position" => 0}] + } +end +``` + +### Coverage Goals + +| Area | Target | +|------|--------| +| Schema modules | 100% (validation, associations) | +| Context functions | 90%+ (all public functions) | +| Provider implementations | 90%+ (normalize, build functions) | +| Oban workers | 80%+ (happy path, error handling) | +| LiveView interactions | 70%+ (critical user flows) | + +--- + +## Verification (Manual) + - Create Printify connection with API key + - `Products.test_provider_connection(conn)` returns shop info + +2. **Product Sync** + - Run `ProductSyncWorker.enqueue(conn.id)` + - Products appear in database with images and variants + - Re-sync updates existing, doesn't duplicate + +3. **Order Submission** + - Create order from cart + - `Orders.submit_to_provider(order)` returns provider_order_id + - Order status updates from provider + +4. **Shop Pages** + - `/collections/all` shows real products + - `/products/:slug` shows product details + - Add to cart works with real variant IDs diff --git a/lib/mix/tasks/generate_mockups.ex b/lib/mix/tasks/generate_mockups.ex index 3fffb05..be22bd0 100644 --- a/lib/mix/tasks/generate_mockups.ex +++ b/lib/mix/tasks/generate_mockups.ex @@ -32,7 +32,7 @@ defmodule Mix.Tasks.GenerateMockups do use Mix.Task - alias SimpleshopTheme.Printify.MockupGenerator + alias SimpleshopTheme.Mockups.Generator, as: MockupGenerator @shortdoc "Generates product mockups using Printify API" diff --git a/lib/simpleshop_theme/cldr.ex b/lib/simpleshop_theme/cldr.ex new file mode 100644 index 0000000..f941dc8 --- /dev/null +++ b/lib/simpleshop_theme/cldr.ex @@ -0,0 +1,12 @@ +defmodule SimpleshopTheme.Cldr do + @moduledoc """ + CLDR backend for internationalization and currency formatting. + + Used by ex_money for currency handling. + """ + + use Cldr, + locales: ["en"], + default_locale: "en", + providers: [Cldr.Number, Money] +end diff --git a/lib/simpleshop_theme/printify/client.ex b/lib/simpleshop_theme/clients/printify.ex similarity index 83% rename from lib/simpleshop_theme/printify/client.ex rename to lib/simpleshop_theme/clients/printify.ex index 8a52459..92f640a 100644 --- a/lib/simpleshop_theme/printify/client.ex +++ b/lib/simpleshop_theme/clients/printify.ex @@ -1,4 +1,4 @@ -defmodule SimpleshopTheme.Printify.Client do +defmodule SimpleshopTheme.Clients.Printify do @moduledoc """ HTTP client for the Printify API. @@ -9,10 +9,14 @@ defmodule SimpleshopTheme.Printify.Client do @base_url "https://api.printify.com/v1" @doc """ - Get the API token from environment. + Get the API token. + + Checks process dictionary first (for provider connections with stored credentials), + then falls back to environment variable (for development/mockup generation). """ def api_token do - System.get_env("PRINTIFY_API_TOKEN") || + Process.get(:printify_api_key) || + System.get_env("PRINTIFY_API_TOKEN") || raise "PRINTIFY_API_TOKEN environment variable is not set" end @@ -148,6 +152,15 @@ defmodule SimpleshopTheme.Printify.Client do get("/shops/#{shop_id}/products/#{product_id}.json") end + @doc """ + List all products in a shop. + """ + def list_products(shop_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + page = Keyword.get(opts, :page, 1) + get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}") + end + @doc """ Delete a product from a shop. """ @@ -155,6 +168,20 @@ defmodule SimpleshopTheme.Printify.Client do delete("/shops/#{shop_id}/products/#{product_id}.json") end + @doc """ + Create an order in a shop. + """ + def create_order(shop_id, order_data) do + post("/shops/#{shop_id}/orders.json", order_data) + end + + @doc """ + Get an order by ID. + """ + def get_order(shop_id, order_id) do + get("/shops/#{shop_id}/orders/#{order_id}.json") + end + @doc """ Download a file from a URL to a local path. """ diff --git a/lib/simpleshop_theme/printify/mockup_generator.ex b/lib/simpleshop_theme/mockups/generator.ex similarity index 99% rename from lib/simpleshop_theme/printify/mockup_generator.ex rename to lib/simpleshop_theme/mockups/generator.ex index 8f8954f..a4bd350 100644 --- a/lib/simpleshop_theme/printify/mockup_generator.ex +++ b/lib/simpleshop_theme/mockups/generator.ex @@ -1,4 +1,4 @@ -defmodule SimpleshopTheme.Printify.MockupGenerator do +defmodule SimpleshopTheme.Mockups.Generator do @moduledoc """ Generates product mockups using the Printify API. @@ -11,7 +11,7 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do 6. Optionally cleaning up created products """ - alias SimpleshopTheme.Printify.Client + alias SimpleshopTheme.Clients.Printify, as: Client @output_dir "priv/static/mockups" diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex new file mode 100644 index 0000000..c5c3613 --- /dev/null +++ b/lib/simpleshop_theme/products.ex @@ -0,0 +1,338 @@ +defmodule SimpleshopTheme.Products do + @moduledoc """ + The Products context. + + Manages products synced from POD providers, including provider connections, + products, images, and variants. + """ + + import Ecto.Query + alias SimpleshopTheme.Repo + alias SimpleshopTheme.Products.{ProviderConnection, Product, ProductImage, ProductVariant} + + # ============================================================================= + # Provider Connections + # ============================================================================= + + @doc """ + Returns the list of provider connections. + """ + def list_provider_connections do + Repo.all(ProviderConnection) + end + + @doc """ + Gets a single provider connection. + """ + def get_provider_connection(id) do + Repo.get(ProviderConnection, id) + end + + @doc """ + Gets a provider connection by type. + """ + def get_provider_connection_by_type(provider_type) do + Repo.get_by(ProviderConnection, provider_type: provider_type) + end + + @doc """ + Creates a provider connection. + """ + def create_provider_connection(attrs \\ %{}) do + %ProviderConnection{} + |> ProviderConnection.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a provider connection. + """ + def update_provider_connection(%ProviderConnection{} = conn, attrs) do + conn + |> ProviderConnection.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a provider connection. + """ + def delete_provider_connection(%ProviderConnection{} = conn) do + Repo.delete(conn) + end + + @doc """ + Updates the sync status of a provider connection. + """ + def update_sync_status(%ProviderConnection{} = conn, status, synced_at \\ nil) do + attrs = %{sync_status: status} + attrs = if synced_at, do: Map.put(attrs, :last_synced_at, synced_at), else: attrs + + conn + |> ProviderConnection.sync_changeset(attrs) + |> Repo.update() + end + + # ============================================================================= + # Products + # ============================================================================= + + @doc """ + Returns the list of products. + + ## Options + + * `:visible` - filter by visibility (boolean) + * `:status` - filter by status (string) + * `:category` - filter by category (string) + * `:provider_connection_id` - filter by provider connection + * `:preload` - list of associations to preload + + """ + def list_products(opts \\ []) do + Product + |> apply_product_filters(opts) + |> order_by([p], desc: p.inserted_at) + |> maybe_preload(opts[:preload]) + |> Repo.all() + end + + @doc """ + Gets a single product by ID. + """ + def get_product(id, opts \\ []) do + Product + |> maybe_preload(opts[:preload]) + |> Repo.get(id) + end + + @doc """ + Gets a single product by slug. + """ + def get_product_by_slug(slug, opts \\ []) do + Product + |> maybe_preload(opts[:preload]) + |> Repo.get_by(slug: slug) + end + + @doc """ + Gets a product by provider connection and provider product ID. + """ + def get_product_by_provider(provider_connection_id, provider_product_id) do + Repo.get_by(Product, + provider_connection_id: provider_connection_id, + provider_product_id: provider_product_id + ) + end + + @doc """ + Creates a product. + """ + def create_product(attrs \\ %{}) do + %Product{} + |> Product.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a product. + """ + def update_product(%Product{} = product, attrs) do + product + |> Product.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a product. + """ + def delete_product(%Product{} = product) do + Repo.delete(product) + end + + @doc """ + Upserts a product from provider data. + + Creates a new product if one doesn't exist for the given provider connection + and provider product ID. Updates the existing product if checksum differs. + + Returns `{:ok, product, :created | :updated | :unchanged}`. + """ + def upsert_product(%ProviderConnection{id: conn_id}, attrs) do + provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"] + new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"]) + attrs = Map.put(attrs, :checksum, new_checksum) + + case get_product_by_provider(conn_id, provider_product_id) do + nil -> + attrs = Map.put(attrs, :provider_connection_id, conn_id) + + case create_product(attrs) do + {:ok, product} -> {:ok, product, :created} + error -> error + end + + %Product{checksum: ^new_checksum} = product -> + {:ok, product, :unchanged} + + product -> + case update_product(product, attrs) do + {:ok, product} -> {:ok, product, :updated} + error -> error + end + end + end + + # ============================================================================= + # Product Images + # ============================================================================= + + @doc """ + Creates a product image. + """ + def create_product_image(attrs \\ %{}) do + %ProductImage{} + |> ProductImage.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes all images for a product. + """ + def delete_product_images(%Product{id: product_id}) do + from(i in ProductImage, where: i.product_id == ^product_id) + |> Repo.delete_all() + end + + @doc """ + Syncs product images from a list of image data. + + Deletes existing images and inserts new ones. + """ + def sync_product_images(%Product{id: product_id} = product, images) when is_list(images) do + delete_product_images(product) + + images + |> Enum.with_index() + |> Enum.map(fn {image_data, index} -> + attrs = + image_data + |> Map.put(:product_id, product_id) + |> Map.put_new(:position, index) + + create_product_image(attrs) + end) + end + + # ============================================================================= + # Product Variants + # ============================================================================= + + @doc """ + Creates a product variant. + """ + def create_product_variant(attrs \\ %{}) do + %ProductVariant{} + |> ProductVariant.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a product variant. + """ + def update_product_variant(%ProductVariant{} = variant, attrs) do + variant + |> ProductVariant.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes all variants for a product. + """ + def delete_product_variants(%Product{id: product_id}) do + from(v in ProductVariant, where: v.product_id == ^product_id) + |> Repo.delete_all() + end + + @doc """ + Gets a variant by product and provider variant ID. + """ + def get_variant_by_provider(product_id, provider_variant_id) do + Repo.get_by(ProductVariant, + product_id: product_id, + provider_variant_id: provider_variant_id + ) + end + + @doc """ + Syncs product variants from a list of variant data. + + Upserts variants based on provider_variant_id. + """ + def sync_product_variants(%Product{id: product_id}, variants) when is_list(variants) do + existing_ids = + from(v in ProductVariant, + where: v.product_id == ^product_id, + select: v.provider_variant_id + ) + |> Repo.all() + |> MapSet.new() + + incoming_ids = + variants + |> Enum.map(&(&1[:provider_variant_id] || &1["provider_variant_id"])) + |> MapSet.new() + + # Delete variants that are no longer in the incoming list + removed_ids = MapSet.difference(existing_ids, incoming_ids) + + if MapSet.size(removed_ids) > 0 do + from(v in ProductVariant, + where: v.product_id == ^product_id and v.provider_variant_id in ^MapSet.to_list(removed_ids) + ) + |> Repo.delete_all() + end + + # Upsert incoming variants + Enum.map(variants, fn variant_data -> + provider_variant_id = variant_data[:provider_variant_id] || variant_data["provider_variant_id"] + attrs = Map.put(variant_data, :product_id, product_id) + + case get_variant_by_provider(product_id, provider_variant_id) do + nil -> + create_product_variant(attrs) + + existing -> + update_product_variant(existing, attrs) + end + end) + end + + # ============================================================================= + # Private Helpers + # ============================================================================= + + defp apply_product_filters(query, opts) do + query + |> filter_by_visible(opts[:visible]) + |> filter_by_status(opts[:status]) + |> filter_by_category(opts[:category]) + |> filter_by_provider_connection(opts[:provider_connection_id]) + end + + defp filter_by_visible(query, nil), do: query + defp filter_by_visible(query, visible), do: where(query, [p], p.visible == ^visible) + + defp filter_by_status(query, nil), do: query + defp filter_by_status(query, status), do: where(query, [p], p.status == ^status) + + defp filter_by_category(query, nil), do: query + defp filter_by_category(query, category), do: where(query, [p], p.category == ^category) + + defp filter_by_provider_connection(query, nil), do: query + + defp filter_by_provider_connection(query, conn_id), + do: where(query, [p], p.provider_connection_id == ^conn_id) + + defp maybe_preload(query, nil), do: query + defp maybe_preload(query, preloads), do: preload(query, ^preloads) +end diff --git a/lib/simpleshop_theme/products/product.ex b/lib/simpleshop_theme/products/product.ex new file mode 100644 index 0000000..194d8aa --- /dev/null +++ b/lib/simpleshop_theme/products/product.ex @@ -0,0 +1,108 @@ +defmodule SimpleshopTheme.Products.Product do + @moduledoc """ + Schema for products synced from POD providers. + + Products are uniquely identified by the combination of + provider_connection_id and provider_product_id. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @statuses ~w(active draft archived) + + schema "products" do + field :provider_product_id, :string + field :title, :string + field :description, :string + field :slug, :string + field :status, :string, default: "active" + field :visible, :boolean, default: true + field :category, :string + field :provider_data, :map, default: %{} + field :checksum, :string + + belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection + has_many :images, SimpleshopTheme.Products.ProductImage + has_many :variants, SimpleshopTheme.Products.ProductVariant + + timestamps(type: :utc_datetime) + end + + @doc """ + Returns the list of valid product statuses. + """ + def statuses, do: @statuses + + @doc """ + Changeset for creating or updating a product. + """ + def changeset(product, attrs) do + product + |> cast(attrs, [ + :provider_connection_id, + :provider_product_id, + :title, + :description, + :slug, + :status, + :visible, + :category, + :provider_data, + :checksum + ]) + |> generate_slug_if_missing() + |> validate_required([:provider_connection_id, :provider_product_id, :title, :slug]) + |> validate_inclusion(:status, @statuses) + |> unique_constraint(:slug) + |> unique_constraint([:provider_connection_id, :provider_product_id]) + end + + @doc """ + Generates a checksum from provider data for detecting changes. + """ + def compute_checksum(provider_data) when is_map(provider_data) do + provider_data + |> Jason.encode!() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + |> binary_part(0, 16) + end + + def compute_checksum(_), do: nil + + defp generate_slug_if_missing(changeset) do + case get_field(changeset, :slug) do + nil -> + title = get_change(changeset, :title) || get_field(changeset, :title) + + if title do + slug = Slug.slugify(title) + put_change(changeset, :slug, slug) + else + changeset + end + + _ -> + changeset + end + end +end + +defmodule Slug do + @moduledoc false + + def slugify(nil), do: nil + + def slugify(string) when is_binary(string) do + string + |> String.downcase() + |> String.replace(~r/[^\w\s-]/, "") + |> String.replace(~r/\s+/, "-") + |> String.replace(~r/-+/, "-") + |> String.trim("-") + end +end diff --git a/lib/simpleshop_theme/products/product_image.ex b/lib/simpleshop_theme/products/product_image.ex new file mode 100644 index 0000000..81b5369 --- /dev/null +++ b/lib/simpleshop_theme/products/product_image.ex @@ -0,0 +1,33 @@ +defmodule SimpleshopTheme.Products.ProductImage do + @moduledoc """ + Schema for product images. + + Images are ordered by position and belong to a single product. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "product_images" do + field :src, :string + field :position, :integer, default: 0 + field :alt, :string + + belongs_to :product, SimpleshopTheme.Products.Product + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating or updating a product image. + """ + def changeset(product_image, attrs) do + product_image + |> cast(attrs, [:product_id, :src, :position, :alt]) + |> validate_required([:product_id, :src]) + |> foreign_key_constraint(:product_id) + end +end diff --git a/lib/simpleshop_theme/products/product_variant.ex b/lib/simpleshop_theme/products/product_variant.ex new file mode 100644 index 0000000..144044f --- /dev/null +++ b/lib/simpleshop_theme/products/product_variant.ex @@ -0,0 +1,98 @@ +defmodule SimpleshopTheme.Products.ProductVariant do + @moduledoc """ + Schema for product variants. + + Variants represent different options (size, color, etc.) for a product. + Each variant has its own pricing and availability. + + ## Options Field + + The `options` field stores variant options as a map with human-readable labels: + + %{ + "Size" => "Large", + "Color" => "Navy Blue" + } + + Labels are denormalized during sync for efficient display. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "product_variants" do + field :provider_variant_id, :string + field :title, :string + field :sku, :string + field :price, :integer + field :compare_at_price, :integer + field :cost, :integer + field :options, :map, default: %{} + field :is_enabled, :boolean, default: true + field :is_available, :boolean, default: true + + belongs_to :product, SimpleshopTheme.Products.Product + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating or updating a product variant. + """ + def changeset(product_variant, attrs) do + product_variant + |> cast(attrs, [ + :product_id, + :provider_variant_id, + :title, + :sku, + :price, + :compare_at_price, + :cost, + :options, + :is_enabled, + :is_available + ]) + |> validate_required([:product_id, :provider_variant_id, :title, :price]) + |> validate_number(:price, greater_than_or_equal_to: 0) + |> validate_number(:compare_at_price, greater_than_or_equal_to: 0) + |> validate_number(:cost, greater_than_or_equal_to: 0) + |> unique_constraint([:product_id, :provider_variant_id]) + |> foreign_key_constraint(:product_id) + end + + @doc """ + Returns the profit for this variant (price - cost). + Returns nil if cost is not set. + """ + def profit(%__MODULE__{price: price, cost: cost}) when is_integer(price) and is_integer(cost) do + price - cost + end + + def profit(_), do: nil + + @doc """ + Returns true if the variant is on sale (has a compare_at_price higher than price). + """ + def on_sale?(%__MODULE__{price: price, compare_at_price: compare_at}) + when is_integer(price) and is_integer(compare_at) and compare_at > price do + true + end + + def on_sale?(_), do: false + + @doc """ + Formats the options as a human-readable title. + E.g., %{"Size" => "Large", "Color" => "Blue"} -> "Large / Blue" + """ + def options_title(%__MODULE__{options: options}) when is_map(options) and map_size(options) > 0 do + options + |> Map.values() + |> Enum.join(" / ") + end + + def options_title(_), do: nil +end diff --git a/lib/simpleshop_theme/products/provider_connection.ex b/lib/simpleshop_theme/products/provider_connection.ex new file mode 100644 index 0000000..1951ff1 --- /dev/null +++ b/lib/simpleshop_theme/products/provider_connection.ex @@ -0,0 +1,97 @@ +defmodule SimpleshopTheme.Products.ProviderConnection do + @moduledoc """ + Schema for POD provider connections. + + Stores encrypted API credentials and configuration for each provider. + Only one connection per provider type is allowed. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias SimpleshopTheme.Vault + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @provider_types ~w(printify gelato prodigi printful) + @sync_statuses ~w(pending syncing completed failed) + + schema "provider_connections" do + field :provider_type, :string + field :name, :string + field :enabled, :boolean, default: true + field :api_key_encrypted, :binary + field :config, :map, default: %{} + field :last_synced_at, :utc_datetime + field :sync_status, :string, default: "pending" + + # Virtual field for setting API key + field :api_key, :string, virtual: true + + has_many :products, SimpleshopTheme.Products.Product + + timestamps(type: :utc_datetime) + end + + @doc """ + Returns the list of supported provider types. + """ + def provider_types, do: @provider_types + + @doc """ + Returns the list of valid sync statuses. + """ + def sync_statuses, do: @sync_statuses + + @doc """ + Changeset for creating a new provider connection. + """ + def changeset(provider_connection, attrs) do + provider_connection + |> cast(attrs, [:provider_type, :name, :enabled, :api_key, :config]) + |> validate_required([:provider_type, :name]) + |> validate_inclusion(:provider_type, @provider_types) + |> unique_constraint(:provider_type) + |> encrypt_api_key() + end + + @doc """ + Changeset for updating sync status. + """ + def sync_changeset(provider_connection, attrs) do + provider_connection + |> cast(attrs, [:last_synced_at, :sync_status]) + |> validate_inclusion(:sync_status, @sync_statuses) + end + + @doc """ + Decrypts and returns the API key for a provider connection. + """ + def get_api_key(%__MODULE__{api_key_encrypted: nil}), do: nil + + def get_api_key(%__MODULE__{api_key_encrypted: encrypted}) do + case Vault.decrypt(encrypted) do + {:ok, api_key} -> api_key + {:error, _} -> nil + end + end + + defp encrypt_api_key(changeset) do + case get_change(changeset, :api_key) do + nil -> + changeset + + api_key -> + case Vault.encrypt(api_key) do + {:ok, encrypted} -> + changeset + |> put_change(:api_key_encrypted, encrypted) + |> delete_change(:api_key) + + {:error, _} -> + add_error(changeset, :api_key, "could not be encrypted") + end + end + end +end diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex new file mode 100644 index 0000000..b398482 --- /dev/null +++ b/lib/simpleshop_theme/providers/printify.ex @@ -0,0 +1,251 @@ +defmodule SimpleshopTheme.Providers.Printify do + @moduledoc """ + Printify provider implementation. + + Handles product sync and order submission for Printify. + """ + + @behaviour SimpleshopTheme.Providers.Provider + + alias SimpleshopTheme.Clients.Printify, as: Client + alias SimpleshopTheme.Products.ProviderConnection + + @impl true + def provider_type, do: "printify" + + @impl true + def test_connection(%ProviderConnection{} = conn) do + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key), + {:ok, shops} <- Client.get_shops() do + shop = List.first(shops) + + {:ok, + %{ + shop_id: shop["id"], + shop_name: shop["title"], + shop_count: length(shops) + }} + else + nil -> {:error, :no_api_key} + {:error, _} = error -> error + end + end + + @impl true + def fetch_products(%ProviderConnection{config: config} = conn) do + shop_id = config["shop_id"] + + if is_nil(shop_id) do + {:error, :no_shop_id} + else + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key), + {:ok, response} <- Client.list_products(shop_id) do + products = + response["data"] + |> Enum.map(&normalize_product/1) + + {:ok, products} + else + nil -> {:error, :no_api_key} + {:error, _} = error -> error + end + end + end + + @impl true + def submit_order(%ProviderConnection{config: config} = conn, order) do + shop_id = config["shop_id"] + + if is_nil(shop_id) do + {:error, :no_shop_id} + else + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key), + order_data <- build_order_payload(order), + {:ok, response} <- Client.create_order(shop_id, order_data) do + {:ok, %{provider_order_id: response["id"]}} + else + nil -> {:error, :no_api_key} + {:error, _} = error -> error + end + end + end + + @impl true + def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do + shop_id = config["shop_id"] + + if is_nil(shop_id) do + {:error, :no_shop_id} + else + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key), + {:ok, response} <- Client.get_order(shop_id, provider_order_id) do + {:ok, normalize_order_status(response)} + else + nil -> {:error, :no_api_key} + {:error, _} = error -> error + end + end + end + + # ============================================================================= + # Data Normalization + # ============================================================================= + + defp normalize_product(raw) do + %{ + provider_product_id: to_string(raw["id"]), + title: raw["title"], + description: raw["description"], + category: extract_category(raw), + images: normalize_images(raw["images"] || []), + variants: normalize_variants(raw["variants"] || []), + provider_data: %{ + blueprint_id: raw["blueprint_id"], + print_provider_id: raw["print_provider_id"], + tags: raw["tags"] || [], + options: raw["options"] || [], + raw: raw + } + } + end + + defp normalize_images(images) do + images + |> Enum.with_index() + |> Enum.map(fn {img, index} -> + %{ + src: img["src"], + position: img["position"] || index, + alt: nil + } + end) + end + + defp normalize_variants(variants) do + Enum.map(variants, fn var -> + %{ + provider_variant_id: to_string(var["id"]), + title: var["title"], + sku: var["sku"], + price: var["price"], + cost: var["cost"], + options: normalize_variant_options(var), + is_enabled: var["is_enabled"] == true, + is_available: var["is_available"] == true + } + end) + end + + defp normalize_variant_options(variant) do + # Printify variants have options as a list of option value IDs + # We need to build the human-readable map from the variant title + # Format: "Size / Color" -> %{"Size" => "Large", "Color" => "Blue"} + + title = variant["title"] || "" + parts = String.split(title, " / ") + + # Common option names based on position + option_names = ["Size", "Color", "Style"] + + parts + |> Enum.with_index() + |> Enum.reduce(%{}, fn {value, index}, acc -> + key = Enum.at(option_names, index) || "Option #{index + 1}" + Map.put(acc, key, value) + end) + end + + defp extract_category(raw) do + # Try to extract category from tags + tags = raw["tags"] || [] + + cond do + "apparel" in tags or "clothing" in tags -> "Apparel" + "homeware" in tags or "home" in tags -> "Homewares" + "accessories" in tags -> "Accessories" + "art" in tags or "print" in tags -> "Art Prints" + true -> nil + end + end + + defp normalize_order_status(raw) do + %{ + status: map_order_status(raw["status"]), + provider_status: raw["status"], + tracking_number: extract_tracking(raw), + tracking_url: extract_tracking_url(raw), + shipments: raw["shipments"] || [] + } + end + + defp map_order_status("pending"), do: "pending" + defp map_order_status("on-hold"), do: "pending" + defp map_order_status("payment-not-received"), do: "pending" + defp map_order_status("in-production"), do: "processing" + defp map_order_status("partially-shipped"), do: "processing" + defp map_order_status("shipped"), do: "shipped" + defp map_order_status("delivered"), do: "delivered" + defp map_order_status("canceled"), do: "cancelled" + defp map_order_status(_), do: "pending" + + defp extract_tracking(raw) do + case raw["shipments"] do + [shipment | _] -> shipment["tracking_number"] + _ -> nil + end + end + + defp extract_tracking_url(raw) do + case raw["shipments"] do + [shipment | _] -> shipment["tracking_url"] + _ -> nil + end + end + + # ============================================================================= + # Order Building + # ============================================================================= + + defp build_order_payload(order) do + %{ + external_id: order.order_number, + label: order.order_number, + line_items: + Enum.map(order.line_items, fn item -> + %{ + product_id: item.product_variant.product.provider_product_id, + variant_id: String.to_integer(item.product_variant.provider_variant_id), + quantity: item.quantity + } + end), + shipping_method: 1, + address_to: %{ + first_name: order.shipping_address["first_name"], + last_name: order.shipping_address["last_name"], + email: order.customer_email, + phone: order.shipping_address["phone"], + country: order.shipping_address["country"], + region: order.shipping_address["state"] || order.shipping_address["region"], + address1: order.shipping_address["address1"], + address2: order.shipping_address["address2"], + city: order.shipping_address["city"], + zip: order.shipping_address["zip"] || order.shipping_address["postal_code"] + } + } + end + + # ============================================================================= + # API Key Management + # ============================================================================= + + # Temporarily sets the API key for the request + # In a production system, this would use a connection pool or request context + defp set_api_key(api_key) do + Process.put(:printify_api_key, api_key) + :ok + end +end diff --git a/lib/simpleshop_theme/providers/provider.ex b/lib/simpleshop_theme/providers/provider.ex new file mode 100644 index 0000000..c6e8b39 --- /dev/null +++ b/lib/simpleshop_theme/providers/provider.ex @@ -0,0 +1,75 @@ +defmodule SimpleshopTheme.Providers.Provider do + @moduledoc """ + Behaviour for POD provider integrations. + + Each provider (Printify, Gelato, Prodigi, etc.) implements this behaviour + to provide a consistent interface for: + + - Testing connections + - Fetching products + - Submitting orders + - Tracking order status + + ## Data Normalization + + Providers return normalized data structures: + + - Products are maps with keys: `title`, `description`, `provider_product_id`, + `images`, `variants`, `category`, `provider_data` + - Variants are maps with keys: `provider_variant_id`, `title`, `sku`, `price`, + `cost`, `options`, `is_enabled`, `is_available` + - Images are maps with keys: `src`, `position`, `alt` + """ + + alias SimpleshopTheme.Products.ProviderConnection + + @doc """ + Returns the provider type identifier (e.g., "printify", "gelato"). + """ + @callback provider_type() :: String.t() + + @doc """ + Tests the connection to the provider. + + Returns `{:ok, info}` with provider-specific info (e.g., shop name) + or `{:error, reason}` if the connection fails. + """ + @callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()} + + @doc """ + Fetches all products from the provider. + + Returns a list of normalized product maps. + """ + @callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()} + + @doc """ + Submits an order to the provider for fulfillment. + + Returns `{:ok, %{provider_order_id: String.t()}}` on success. + """ + @callback submit_order(ProviderConnection.t(), order :: map()) :: + {:ok, %{provider_order_id: String.t()}} | {:error, term()} + + @doc """ + Gets the current status of an order from the provider. + """ + @callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) :: + {:ok, map()} | {:error, term()} + + @doc """ + Returns the provider module for a given provider type. + """ + def for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify} + def for_type("gelato"), do: {:error, :not_implemented} + def for_type("prodigi"), do: {:error, :not_implemented} + def for_type("printful"), do: {:error, :not_implemented} + def for_type(type), do: {:error, {:unknown_provider, type}} + + @doc """ + Returns the provider module for a provider connection. + """ + def for_connection(%ProviderConnection{provider_type: type}) do + for_type(type) + end +end diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex new file mode 100644 index 0000000..68dd73f --- /dev/null +++ b/lib/simpleshop_theme/sync/product_sync_worker.ex @@ -0,0 +1,148 @@ +defmodule SimpleshopTheme.Sync.ProductSyncWorker do + @moduledoc """ + Oban worker for syncing products from POD providers. + + This worker fetches products from a provider, normalizes them, + and upserts them into the local database. + + ## Usage + + # Enqueue a sync for a provider connection + ProductSyncWorker.enqueue(provider_connection_id) + + ## Job Args + + * `provider_connection_id` - The ID of the provider connection to sync + """ + + use Oban.Worker, queue: :sync, max_attempts: 3 + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Products.ProviderConnection + alias SimpleshopTheme.Providers.Provider + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"provider_connection_id" => conn_id}}) do + case Products.get_provider_connection(conn_id) do + nil -> + {:cancel, :connection_not_found} + + %ProviderConnection{enabled: false} -> + {:cancel, :connection_disabled} + + conn -> + sync_products(conn) + end + end + + @doc """ + Enqueue a product sync for a provider connection. + """ + def enqueue(provider_connection_id) do + %{provider_connection_id: provider_connection_id} + |> new() + |> Oban.insert() + end + + @doc """ + Enqueue a product sync with a delay. + """ + def enqueue(provider_connection_id, delay_seconds) when is_integer(delay_seconds) do + %{provider_connection_id: provider_connection_id} + |> new(scheduled_at: DateTime.add(DateTime.utc_now(), delay_seconds, :second)) + |> Oban.insert() + end + + # ============================================================================= + # Private + # ============================================================================= + + defp sync_products(conn) do + Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})") + + Products.update_sync_status(conn, "syncing") + + with {:ok, provider} <- Provider.for_connection(conn), + {:ok, products} <- provider.fetch_products(conn) do + results = sync_all_products(conn, products) + + created = Enum.count(results, fn {_, _, status} -> status == :created end) + updated = Enum.count(results, fn {_, _, status} -> status == :updated end) + unchanged = Enum.count(results, fn {_, _, status} -> status == :unchanged end) + errors = Enum.count(results, fn result -> match?({:error, _}, result) end) + + Logger.info( + "Product sync complete for #{conn.provider_type}: " <> + "#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors" + ) + + Products.update_sync_status(conn, "completed", DateTime.utc_now()) + :ok + else + {:error, reason} = error -> + Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}") + Products.update_sync_status(conn, "failed") + error + end + end + + defp sync_all_products(conn, products) do + Enum.map(products, fn product_data -> + case sync_product(conn, product_data) do + {:ok, product, status} -> + sync_product_associations(product, product_data) + {:ok, product, status} + + error -> + error + end + end) + end + + defp sync_product(conn, product_data) do + attrs = %{ + provider_product_id: product_data[:provider_product_id], + title: product_data[:title], + description: product_data[:description], + category: product_data[:category], + provider_data: product_data[:provider_data] + } + + Products.upsert_product(conn, attrs) + end + + defp sync_product_associations(product, product_data) do + # Sync images + images = + (product_data[:images] || []) + |> Enum.map(fn img -> + %{ + src: img[:src], + position: img[:position], + alt: img[:alt] + } + end) + + Products.sync_product_images(product, images) + + # Sync variants + variants = + (product_data[:variants] || []) + |> Enum.map(fn var -> + %{ + provider_variant_id: var[:provider_variant_id], + title: var[:title], + sku: var[:sku], + price: var[:price], + cost: var[:cost], + options: var[:options], + is_enabled: var[:is_enabled], + is_available: var[:is_available] + } + end) + + Products.sync_product_variants(product, variants) + end +end diff --git a/lib/simpleshop_theme/vault.ex b/lib/simpleshop_theme/vault.ex new file mode 100644 index 0000000..2f7a595 --- /dev/null +++ b/lib/simpleshop_theme/vault.ex @@ -0,0 +1,101 @@ +defmodule SimpleshopTheme.Vault do + @moduledoc """ + Handles encryption and decryption of sensitive data. + + Uses AES-256-GCM for authenticated encryption. + Keys are derived from the application's secret_key_base. + """ + + @aad "SimpleshopTheme.Vault" + + @doc """ + Encrypts a string value. + + Returns `{:ok, encrypted_binary}` or `{:error, reason}`. + The encrypted binary includes the IV and auth tag. + """ + @spec encrypt(String.t()) :: {:ok, binary()} | {:error, term()} + def encrypt(plaintext) when is_binary(plaintext) do + key = derive_key() + iv = :crypto.strong_rand_bytes(12) + + {ciphertext, tag} = + :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, plaintext, @aad, true) + + # Format: iv (12 bytes) + tag (16 bytes) + ciphertext + {:ok, iv <> tag <> ciphertext} + rescue + e -> {:error, e} + end + + def encrypt(nil), do: {:ok, nil} + + @doc """ + Decrypts an encrypted binary. + + Returns `{:ok, plaintext}` or `{:error, reason}`. + """ + @spec decrypt(binary()) :: {:ok, String.t()} | {:error, term()} + def decrypt(<>) do + key = derive_key() + + case :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, ciphertext, @aad, tag, false) do + plaintext when is_binary(plaintext) -> + {:ok, plaintext} + + :error -> + {:error, :decryption_failed} + end + rescue + e -> {:error, e} + end + + def decrypt(nil), do: {:ok, nil} + def decrypt(""), do: {:ok, ""} + + def decrypt(_invalid) do + {:error, :invalid_ciphertext} + end + + @doc """ + Encrypts a string value, raising on error. + """ + @spec encrypt!(String.t()) :: binary() + def encrypt!(plaintext) do + case encrypt(plaintext) do + {:ok, ciphertext} -> ciphertext + {:error, reason} -> raise "Encryption failed: #{inspect(reason)}" + end + end + + @doc """ + Decrypts an encrypted binary, raising on error. + """ + @spec decrypt!(binary()) :: String.t() + def decrypt!(ciphertext) do + case decrypt(ciphertext) do + {:ok, plaintext} -> plaintext + {:error, reason} -> raise "Decryption failed: #{inspect(reason)}" + end + end + + # Derives a 32-byte key from the secret_key_base + defp derive_key do + secret_key_base = get_secret_key_base() + + :crypto.hash(:sha256, secret_key_base <> "vault_encryption_key") + end + + defp get_secret_key_base do + case Application.get_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint)[:secret_key_base] do + nil -> + raise """ + Secret key base is not configured. + Set it in config/runtime.exs or config/dev.exs. + """ + + key when is_binary(key) -> + key + end + end +end diff --git a/mix.exs b/mix.exs index 07a82ba..fa9be09 100644 --- a/mix.exs +++ b/mix.exs @@ -69,7 +69,9 @@ defmodule SimpleshopTheme.MixProject do {:bandit, "~> 1.5"}, {:tidewave, "~> 0.5", only: :dev}, {:image, "~> 0.54"}, - {:oban, "~> 2.18"} + {:oban, "~> 2.18"}, + {:ex_money, "~> 5.0"}, + {:ex_money_sql, "~> 1.0"} ] end diff --git a/mix.lock b/mix.lock index a197c33..851c35f 100644 --- a/mix.lock +++ b/mix.lock @@ -3,15 +3,22 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, + "cldr_utils": {:hex, :cldr_utils, "2.29.4", "11437b0bf9a0d57db4eccdf751c49f675a04fa4261c5dae1e23552a0347e25c9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e72a43e69a3f546979085cbdbeae7e9049998cd21cedfdd796cff9155998114e"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "ex_cldr": {:hex, :ex_cldr, "2.46.0", "29b5bb638932ca4fc4339595145e327b797f59963a398c12a6aee1efe5a35b1b", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "14157ac16694e99c1339ac25a4f10d3df0e0d15cc1a35073b37e195487c1b6cb"}, + "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.0", "c38d76339dbee413f7dd1aba4cdf05758bd4c0bbfe9c3b1c8602f96082c2890a", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9af59bd29407dcca59fa39ded8c1649ae1cf6ec29fd0611576dcad0279bce0db"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.0", "b5564b57d3769c85e16689472a9bb65804f71ccd3484144e31998398fda25ad1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "b29e4d723c69db5d0a3f3bcef7583a0bc87dda1cd642187c589fec4bfc59a703"}, + "ex_money": {:hex, :ex_money, "5.24.1", "f3a4c7e6321bc6e743dae9772ef0f0f289d00332fd8c79bf67de8b79884700a2", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.46", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.38", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.19", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "37a1551e86f74bc1c123790df37f69e6defa6c72e2a2a9c02164e1d1e8f0748e"}, + "ex_money_sql": {:hex, :ex_money_sql, "1.12.0", "900f6d03195e82bc9f84ba9df1ba179d633cb0eb2e6b422888f2f7aac70563ea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_money, "~> 5.7", [hex: :ex_money, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.15", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "f430eaf9d9fc17ff851aceefb6b0436faf7f35c90f12b1ffea08b4decc4a6b5c"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "exqlite": {:hex, :exqlite, "0.34.0", "ebca3570eb4c4eb4345d76c8e44ce31a62de7b24a54fd118164480f2954bd540", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "bcdc58879a0db5e08cd5f6fbe07a0692ceffaaaa617eab46b506137edf0a2742"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -27,6 +34,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, @@ -39,6 +47,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"}, diff --git a/priv/repo/migrations/20260128235845_create_provider_connections.exs b/priv/repo/migrations/20260128235845_create_provider_connections.exs new file mode 100644 index 0000000..14209f0 --- /dev/null +++ b/priv/repo/migrations/20260128235845_create_provider_connections.exs @@ -0,0 +1,20 @@ +defmodule SimpleshopTheme.Repo.Migrations.CreateProviderConnections do + use Ecto.Migration + + def change do + create table(:provider_connections, primary_key: false) do + add :id, :binary_id, primary_key: true + add :provider_type, :string, null: false + add :name, :string, null: false + add :enabled, :boolean, default: true, null: false + add :api_key_encrypted, :binary + add :config, :map, default: %{} + add :last_synced_at, :utc_datetime + add :sync_status, :string, default: "pending" + + timestamps(type: :utc_datetime) + end + + create unique_index(:provider_connections, [:provider_type]) + end +end diff --git a/priv/repo/migrations/20260128235846_create_products.exs b/priv/repo/migrations/20260128235846_create_products.exs new file mode 100644 index 0000000..e945427 --- /dev/null +++ b/priv/repo/migrations/20260128235846_create_products.exs @@ -0,0 +1,27 @@ +defmodule SimpleshopTheme.Repo.Migrations.CreateProducts do + use Ecto.Migration + + def change do + create table(:products, primary_key: false) do + add :id, :binary_id, primary_key: true + add :provider_connection_id, references(:provider_connections, type: :binary_id, on_delete: :delete_all), null: false + add :provider_product_id, :string, null: false + add :title, :string, null: false + add :description, :text + add :slug, :string, null: false + add :status, :string, default: "active", null: false + add :visible, :boolean, default: true, null: false + add :category, :string + add :provider_data, :map, default: %{} + add :checksum, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:products, [:slug]) + create unique_index(:products, [:provider_connection_id, :provider_product_id]) + create index(:products, [:status]) + create index(:products, [:visible]) + create index(:products, [:category]) + end +end diff --git a/priv/repo/migrations/20260128235847_create_product_images.exs b/priv/repo/migrations/20260128235847_create_product_images.exs new file mode 100644 index 0000000..f5b3031 --- /dev/null +++ b/priv/repo/migrations/20260128235847_create_product_images.exs @@ -0,0 +1,18 @@ +defmodule SimpleshopTheme.Repo.Migrations.CreateProductImages do + use Ecto.Migration + + def change do + create table(:product_images, primary_key: false) do + add :id, :binary_id, primary_key: true + add :product_id, references(:products, type: :binary_id, on_delete: :delete_all), null: false + add :src, :string, null: false + add :position, :integer, default: 0, null: false + add :alt, :string + + timestamps(type: :utc_datetime) + end + + # Composite index covers queries on product_id alone (leftmost prefix) + create index(:product_images, [:product_id, :position]) + end +end diff --git a/priv/repo/migrations/20260128235848_create_product_variants.exs b/priv/repo/migrations/20260128235848_create_product_variants.exs new file mode 100644 index 0000000..2a55974 --- /dev/null +++ b/priv/repo/migrations/20260128235848_create_product_variants.exs @@ -0,0 +1,27 @@ +defmodule SimpleshopTheme.Repo.Migrations.CreateProductVariants do + use Ecto.Migration + + def change do + create table(:product_variants, primary_key: false) do + add :id, :binary_id, primary_key: true + add :product_id, references(:products, type: :binary_id, on_delete: :delete_all), null: false + add :provider_variant_id, :string, null: false + add :title, :string, null: false + add :sku, :string + add :price, :integer, null: false + add :compare_at_price, :integer + add :cost, :integer + add :options, :map, default: %{} + add :is_enabled, :boolean, default: true, null: false + add :is_available, :boolean, default: true, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:product_variants, [:product_id, :provider_variant_id]) + create index(:product_variants, [:product_id]) + create index(:product_variants, [:sku]) + create index(:product_variants, [:is_enabled]) + create index(:product_variants, [:is_available]) + end +end diff --git a/test/simpleshop_theme/products/product_image_test.exs b/test/simpleshop_theme/products/product_image_test.exs new file mode 100644 index 0000000..ebcb436 --- /dev/null +++ b/test/simpleshop_theme/products/product_image_test.exs @@ -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 diff --git a/test/simpleshop_theme/products/product_test.exs b/test/simpleshop_theme/products/product_test.exs new file mode 100644 index 0000000..e09f646 --- /dev/null +++ b/test/simpleshop_theme/products/product_test.exs @@ -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 diff --git a/test/simpleshop_theme/products/product_variant_test.exs b/test/simpleshop_theme/products/product_variant_test.exs new file mode 100644 index 0000000..9d9c37a --- /dev/null +++ b/test/simpleshop_theme/products/product_variant_test.exs @@ -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 diff --git a/test/simpleshop_theme/products/provider_connection_test.exs b/test/simpleshop_theme/products/provider_connection_test.exs new file mode 100644 index 0000000..6809724 --- /dev/null +++ b/test/simpleshop_theme/products/provider_connection_test.exs @@ -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 diff --git a/test/simpleshop_theme/products_test.exs b/test/simpleshop_theme/products_test.exs new file mode 100644 index 0000000..9f73bed --- /dev/null +++ b/test/simpleshop_theme/products_test.exs @@ -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 diff --git a/test/simpleshop_theme/sync/product_sync_worker_test.exs b/test/simpleshop_theme/sync/product_sync_worker_test.exs new file mode 100644 index 0000000..f86019a --- /dev/null +++ b/test/simpleshop_theme/sync/product_sync_worker_test.exs @@ -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 diff --git a/test/simpleshop_theme/vault_test.exs b/test/simpleshop_theme/vault_test.exs new file mode 100644 index 0000000..c80f82b --- /dev/null +++ b/test/simpleshop_theme/vault_test.exs @@ -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 diff --git a/test/support/fixtures/products_fixtures.ex b/test/support/fixtures/products_fixtures.ex new file mode 100644 index 0000000..7f93847 --- /dev/null +++ b/test/support/fixtures/products_fixtures.ex @@ -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