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

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

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

Schemas: ProviderConnection, Product, ProductImage, ProductVariant

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

134 tests added covering all new functionality.

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

View File

@ -80,6 +80,9 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
# ex_money configuration for currency handling
config :ex_money, default_cldr_backend: SimpleshopTheme.Cldr
# Oban configuration for background jobs # Oban configuration for background jobs
config :simpleshop_theme, Oban, config :simpleshop_theme, Oban,
engine: Oban.Engines.Lite, engine: Oban.Engines.Lite,
@ -87,7 +90,7 @@ config :simpleshop_theme, Oban,
plugins: [ plugins: [
{Oban.Plugins.Pruner, max_age: 60} {Oban.Plugins.Pruner, max_age: 60}
], ],
queues: [images: 2] queues: [images: 2, sync: 1]
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,7 @@ defmodule Mix.Tasks.GenerateMockups do
use Mix.Task use Mix.Task
alias SimpleshopTheme.Printify.MockupGenerator alias SimpleshopTheme.Mockups.Generator, as: MockupGenerator
@shortdoc "Generates product mockups using Printify API" @shortdoc "Generates product mockups using Printify API"

View File

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

View File

@ -1,4 +1,4 @@
defmodule SimpleshopTheme.Printify.Client do defmodule SimpleshopTheme.Clients.Printify do
@moduledoc """ @moduledoc """
HTTP client for the Printify API. HTTP client for the Printify API.
@ -9,10 +9,14 @@ defmodule SimpleshopTheme.Printify.Client do
@base_url "https://api.printify.com/v1" @base_url "https://api.printify.com/v1"
@doc """ @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 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" raise "PRINTIFY_API_TOKEN environment variable is not set"
end end
@ -148,6 +152,15 @@ defmodule SimpleshopTheme.Printify.Client do
get("/shops/#{shop_id}/products/#{product_id}.json") get("/shops/#{shop_id}/products/#{product_id}.json")
end 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 """ @doc """
Delete a product from a shop. Delete a product from a shop.
""" """
@ -155,6 +168,20 @@ defmodule SimpleshopTheme.Printify.Client do
delete("/shops/#{shop_id}/products/#{product_id}.json") delete("/shops/#{shop_id}/products/#{product_id}.json")
end 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 """ @doc """
Download a file from a URL to a local path. Download a file from a URL to a local path.
""" """

View File

@ -1,4 +1,4 @@
defmodule SimpleshopTheme.Printify.MockupGenerator do defmodule SimpleshopTheme.Mockups.Generator do
@moduledoc """ @moduledoc """
Generates product mockups using the Printify API. Generates product mockups using the Printify API.
@ -11,7 +11,7 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do
6. Optionally cleaning up created products 6. Optionally cleaning up created products
""" """
alias SimpleshopTheme.Printify.Client alias SimpleshopTheme.Clients.Printify, as: Client
@output_dir "priv/static/mockups" @output_dir "priv/static/mockups"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(<<iv::binary-12, tag::binary-16, ciphertext::binary>>) 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

View File

@ -69,7 +69,9 @@ defmodule SimpleshopTheme.MixProject do
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:tidewave, "~> 0.5", only: :dev}, {:tidewave, "~> 0.5", only: :dev},
{:image, "~> 0.54"}, {:image, "~> 0.54"},
{:oban, "~> 2.18"} {:oban, "~> 2.18"},
{:ex_money, "~> 5.0"},
{:ex_money_sql, "~> 1.0"}
] ]
end end

View File

@ -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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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": {: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_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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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_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"}, "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"}, "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"}, "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"}, "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": {: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"}, "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"}, "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"}, "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"}, "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"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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