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:
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
|
||||
Reference in New Issue
Block a user