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:
parent
62faf86abe
commit
c5c06d9979
@ -80,6 +80,9 @@ config :logger, :default_formatter,
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# ex_money configuration for currency handling
|
||||
config :ex_money, default_cldr_backend: SimpleshopTheme.Cldr
|
||||
|
||||
# Oban configuration for background jobs
|
||||
config :simpleshop_theme, Oban,
|
||||
engine: Oban.Engines.Lite,
|
||||
@ -87,7 +90,7 @@ config :simpleshop_theme, Oban,
|
||||
plugins: [
|
||||
{Oban.Plugins.Pruner, max_age: 60}
|
||||
],
|
||||
queues: [images: 2]
|
||||
queues: [images: 2, sync: 1]
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
|
||||
1183
docs/plans/products-context.md
Normal file
1183
docs/plans/products-context.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -32,7 +32,7 @@ defmodule Mix.Tasks.GenerateMockups do
|
||||
|
||||
use Mix.Task
|
||||
|
||||
alias SimpleshopTheme.Printify.MockupGenerator
|
||||
alias SimpleshopTheme.Mockups.Generator, as: MockupGenerator
|
||||
|
||||
@shortdoc "Generates product mockups using Printify API"
|
||||
|
||||
|
||||
12
lib/simpleshop_theme/cldr.ex
Normal file
12
lib/simpleshop_theme/cldr.ex
Normal 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
|
||||
@ -1,4 +1,4 @@
|
||||
defmodule SimpleshopTheme.Printify.Client do
|
||||
defmodule SimpleshopTheme.Clients.Printify do
|
||||
@moduledoc """
|
||||
HTTP client for the Printify API.
|
||||
|
||||
@ -9,9 +9,13 @@ defmodule SimpleshopTheme.Printify.Client do
|
||||
@base_url "https://api.printify.com/v1"
|
||||
|
||||
@doc """
|
||||
Get the API token from environment.
|
||||
Get the API token.
|
||||
|
||||
Checks process dictionary first (for provider connections with stored credentials),
|
||||
then falls back to environment variable (for development/mockup generation).
|
||||
"""
|
||||
def api_token do
|
||||
Process.get(:printify_api_key) ||
|
||||
System.get_env("PRINTIFY_API_TOKEN") ||
|
||||
raise "PRINTIFY_API_TOKEN environment variable is not set"
|
||||
end
|
||||
@ -148,6 +152,15 @@ defmodule SimpleshopTheme.Printify.Client do
|
||||
get("/shops/#{shop_id}/products/#{product_id}.json")
|
||||
end
|
||||
|
||||
@doc """
|
||||
List all products in a shop.
|
||||
"""
|
||||
def list_products(shop_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
page = Keyword.get(opts, :page, 1)
|
||||
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delete a product from a shop.
|
||||
"""
|
||||
@ -155,6 +168,20 @@ defmodule SimpleshopTheme.Printify.Client do
|
||||
delete("/shops/#{shop_id}/products/#{product_id}.json")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create an order in a shop.
|
||||
"""
|
||||
def create_order(shop_id, order_data) do
|
||||
post("/shops/#{shop_id}/orders.json", order_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get an order by ID.
|
||||
"""
|
||||
def get_order(shop_id, order_id) do
|
||||
get("/shops/#{shop_id}/orders/#{order_id}.json")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Download a file from a URL to a local path.
|
||||
"""
|
||||
@ -1,4 +1,4 @@
|
||||
defmodule SimpleshopTheme.Printify.MockupGenerator do
|
||||
defmodule SimpleshopTheme.Mockups.Generator do
|
||||
@moduledoc """
|
||||
Generates product mockups using the Printify API.
|
||||
|
||||
@ -11,7 +11,7 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do
|
||||
6. Optionally cleaning up created products
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Printify.Client
|
||||
alias SimpleshopTheme.Clients.Printify, as: Client
|
||||
|
||||
@output_dir "priv/static/mockups"
|
||||
|
||||
338
lib/simpleshop_theme/products.ex
Normal file
338
lib/simpleshop_theme/products.ex
Normal 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
|
||||
108
lib/simpleshop_theme/products/product.ex
Normal file
108
lib/simpleshop_theme/products/product.ex
Normal 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
|
||||
33
lib/simpleshop_theme/products/product_image.ex
Normal file
33
lib/simpleshop_theme/products/product_image.ex
Normal 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
|
||||
98
lib/simpleshop_theme/products/product_variant.ex
Normal file
98
lib/simpleshop_theme/products/product_variant.ex
Normal 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
|
||||
97
lib/simpleshop_theme/products/provider_connection.ex
Normal file
97
lib/simpleshop_theme/products/provider_connection.ex
Normal 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
|
||||
251
lib/simpleshop_theme/providers/printify.ex
Normal file
251
lib/simpleshop_theme/providers/printify.ex
Normal 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
|
||||
75
lib/simpleshop_theme/providers/provider.ex
Normal file
75
lib/simpleshop_theme/providers/provider.ex
Normal 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
|
||||
148
lib/simpleshop_theme/sync/product_sync_worker.ex
Normal file
148
lib/simpleshop_theme/sync/product_sync_worker.ex
Normal 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
|
||||
101
lib/simpleshop_theme/vault.ex
Normal file
101
lib/simpleshop_theme/vault.ex
Normal 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
|
||||
4
mix.exs
4
mix.exs
@ -69,7 +69,9 @@ defmodule SimpleshopTheme.MixProject do
|
||||
{:bandit, "~> 1.5"},
|
||||
{:tidewave, "~> 0.5", only: :dev},
|
||||
{:image, "~> 0.54"},
|
||||
{:oban, "~> 2.18"}
|
||||
{:oban, "~> 2.18"},
|
||||
{:ex_money, "~> 5.0"},
|
||||
{:ex_money_sql, "~> 1.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
9
mix.lock
9
mix.lock
@ -3,15 +3,22 @@
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"cldr_utils": {:hex, :cldr_utils, "2.29.4", "11437b0bf9a0d57db4eccdf751c49f675a04fa4261c5dae1e23552a0347e25c9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e72a43e69a3f546979085cbdbeae7e9049998cd21cedfdd796cff9155998114e"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_cldr": {:hex, :ex_cldr, "2.46.0", "29b5bb638932ca4fc4339595145e327b797f59963a398c12a6aee1efe5a35b1b", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "14157ac16694e99c1339ac25a4f10d3df0e0d15cc1a35073b37e195487c1b6cb"},
|
||||
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.0", "c38d76339dbee413f7dd1aba4cdf05758bd4c0bbfe9c3b1c8602f96082c2890a", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9af59bd29407dcca59fa39ded8c1649ae1cf6ec29fd0611576dcad0279bce0db"},
|
||||
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.0", "b5564b57d3769c85e16689472a9bb65804f71ccd3484144e31998398fda25ad1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "b29e4d723c69db5d0a3f3bcef7583a0bc87dda1cd642187c589fec4bfc59a703"},
|
||||
"ex_money": {:hex, :ex_money, "5.24.1", "f3a4c7e6321bc6e743dae9772ef0f0f289d00332fd8c79bf67de8b79884700a2", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.46", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.38", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.19", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "37a1551e86f74bc1c123790df37f69e6defa6c72e2a2a9c02164e1d1e8f0748e"},
|
||||
"ex_money_sql": {:hex, :ex_money_sql, "1.12.0", "900f6d03195e82bc9f84ba9df1ba179d633cb0eb2e6b422888f2f7aac70563ea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_money, "~> 5.7", [hex: :ex_money, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.15", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "f430eaf9d9fc17ff851aceefb6b0436faf7f35c90f12b1ffea08b4decc4a6b5c"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"exqlite": {:hex, :exqlite, "0.34.0", "ebca3570eb4c4eb4345d76c8e44ce31a62de7b24a54fd118164480f2954bd540", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "bcdc58879a0db5e08cd5f6fbe07a0692ceffaaaa617eab46b506137edf0a2742"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
@ -27,6 +34,7 @@
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
||||
@ -39,6 +47,7 @@
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
||||
|
||||
@ -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
|
||||
27
priv/repo/migrations/20260128235846_create_products.exs
Normal file
27
priv/repo/migrations/20260128235846_create_products.exs
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
65
test/simpleshop_theme/products/product_image_test.exs
Normal file
65
test/simpleshop_theme/products/product_image_test.exs
Normal 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
|
||||
248
test/simpleshop_theme/products/product_test.exs
Normal file
248
test/simpleshop_theme/products/product_test.exs
Normal 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
|
||||
201
test/simpleshop_theme/products/product_variant_test.exs
Normal file
201
test/simpleshop_theme/products/product_variant_test.exs
Normal 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
|
||||
169
test/simpleshop_theme/products/provider_connection_test.exs
Normal file
169
test/simpleshop_theme/products/provider_connection_test.exs
Normal 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
|
||||
469
test/simpleshop_theme/products_test.exs
Normal file
469
test/simpleshop_theme/products_test.exs
Normal 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
|
||||
69
test/simpleshop_theme/sync/product_sync_worker_test.exs
Normal file
69
test/simpleshop_theme/sync/product_sync_worker_test.exs
Normal 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
|
||||
106
test/simpleshop_theme/vault_test.exs
Normal file
106
test/simpleshop_theme/vault_test.exs
Normal 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
|
||||
250
test/support/fixtures/products_fixtures.ex
Normal file
250
test/support/fixtures/products_fixtures.ex
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user