add Printful client test coverage with Req.Test stubs
All checks were successful
deploy / deploy (push) Successful in 1m10s

Wire up Req.Test plug for the Printful HTTP client so tests can stub
responses. Adds HTTP-level tests for the client, provider integration
tests, and mockup enricher tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-22 10:20:49 +00:00
parent f1b4e55cc7
commit a45e85ef4c
5 changed files with 1023 additions and 6 deletions

View File

@ -48,3 +48,7 @@ config :berrypod, Oban, testing: :inline
# Isolate image cache so test cleanup doesn't wipe the dev cache
config :berrypod, :image_cache_dir, Path.expand("../tmp/test_image_cache", __DIR__)
# Route Printful HTTP client through Req.Test stubs
config :berrypod, Berrypod.Clients.Printful,
req_options: [plug: {Req.Test, Berrypod.Clients.Printful}, retry: false]

View File

@ -41,7 +41,7 @@ defmodule Berrypod.Clients.Printful do
def get(path, _opts \\ []) do
url = @base_url <> path
case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do
case Req.get(url, [headers: auth_headers(), receive_timeout: 30_000] ++ base_options()) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
@ -59,7 +59,10 @@ defmodule Berrypod.Clients.Printful do
def post(path, body, _opts \\ []) do
url = @base_url <> path
case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
case Req.post(
url,
[json: body, headers: auth_headers(), receive_timeout: 60_000] ++ base_options()
) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
@ -77,13 +80,13 @@ defmodule Berrypod.Clients.Printful do
def delete(path, _opts \\ []) do
url = @base_url <> path
case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
case Req.delete(url, [headers: auth_headers(), receive_timeout: 30_000] ++ base_options()) do
{:ok, %Req.Response{status: 204}} ->
{:ok, nil}
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {status, body}}
@ -362,4 +365,8 @@ defmodule Berrypod.Clients.Printful do
id -> [{"X-PF-Store-Id", to_string(id)} | headers]
end
end
defp base_options do
Application.get_env(:berrypod, __MODULE__, [])[:req_options] || []
end
end

View File

@ -0,0 +1,336 @@
defmodule Berrypod.Clients.PrintfulHttpTest do
@moduledoc """
Tests the Printful HTTP client against Req.Test stubs.
Exercises URL construction, auth headers, response unwrapping, and error handling.
"""
use ExUnit.Case, async: true
alias Berrypod.Clients.Printful
setup do
Process.put(:printful_api_key, "test_token_abc")
Req.Test.stub(Printful, &route/1)
:ok
end
# =============================================================================
# Response unwrapping
# =============================================================================
describe "v1 response unwrapping" do
test "unwraps result key from v1 responses" do
assert {:ok, [%{"id" => 456}]} = Printful.list_sync_products()
end
test "get_sync_product returns nested data" do
assert {:ok, data} = Printful.get_sync_product(456)
assert data["sync_product"]["name"] == "Test T-Shirt"
end
end
describe "v2 response unwrapping" do
test "unwraps data key from v2 responses" do
assert {:ok, stores} = Printful.get_stores()
assert [%{"id" => 123, "name" => "Test Store"}] = stores
end
test "get_order returns v2 data" do
assert {:ok, order} = Printful.get_order(12345)
assert order["status"] == "draft"
end
end
# =============================================================================
# Error handling
# =============================================================================
describe "error handling" do
test "returns error tuple on 4xx" do
Req.Test.stub(Printful, fn conn ->
Req.Test.json(conn |> Plug.Conn.put_status(404), %{"error" => "Not found"})
end)
assert {:error, {404, %{"error" => "Not found"}}} = Printful.get("/v2/orders/999")
end
test "returns error tuple on 5xx" do
Req.Test.stub(Printful, fn conn ->
Req.Test.json(conn |> Plug.Conn.put_status(500), %{"error" => "Internal error"})
end)
assert {:error, {500, _}} = Printful.get("/v2/stores")
end
test "post returns error on 422" do
Req.Test.stub(Printful, fn conn ->
Req.Test.json(
conn |> Plug.Conn.put_status(422),
%{"error" => "Invalid address"}
)
end)
assert {:error, {422, %{"error" => "Invalid address"}}} =
Printful.create_order(%{test: true})
end
end
# =============================================================================
# Auth headers
# =============================================================================
describe "auth headers" do
test "includes Bearer token" do
Req.Test.stub(Printful, fn conn ->
[auth] = Plug.Conn.get_req_header(conn, "authorization")
assert auth == "Bearer test_token_abc"
Req.Test.json(conn, %{"data" => []})
end)
assert {:ok, _} = Printful.get_stores()
end
test "includes X-PF-Store-Id when set" do
Process.put(:printful_store_id, 99999)
Req.Test.stub(Printful, fn conn ->
[store_id] = Plug.Conn.get_req_header(conn, "x-pf-store-id")
assert store_id == "99999"
Req.Test.json(conn, %{"code" => 200, "result" => []})
end)
assert {:ok, _} = Printful.list_sync_products()
after
Process.delete(:printful_store_id)
end
test "omits X-PF-Store-Id when not set" do
Process.delete(:printful_store_id)
Req.Test.stub(Printful, fn conn ->
assert Plug.Conn.get_req_header(conn, "x-pf-store-id") == []
Req.Test.json(conn, %{"data" => []})
end)
assert {:ok, _} = Printful.get_stores()
end
end
# =============================================================================
# Specific endpoints
# =============================================================================
describe "get_stores/0" do
test "calls GET /v2/stores" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "GET"
assert conn.request_path == "/v2/stores"
Req.Test.json(conn, %{"data" => [%{"id" => 1}]})
end)
assert {:ok, [%{"id" => 1}]} = Printful.get_stores()
end
end
describe "get_store_id/0" do
test "returns first store id" do
assert {:ok, 123} = Printful.get_store_id()
end
test "returns error when no stores" do
Req.Test.stub(Printful, fn conn ->
Req.Test.json(conn, %{"data" => []})
end)
assert {:error, :no_stores} = Printful.get_store_id()
end
end
describe "list_sync_products/1" do
test "passes offset and limit as query params" do
Req.Test.stub(Printful, fn conn ->
assert conn.query_string == "offset=40&limit=20"
Req.Test.json(conn, %{"code" => 200, "result" => []})
end)
assert {:ok, []} = Printful.list_sync_products(offset: 40)
end
test "defaults to offset 0 and limit 20" do
Req.Test.stub(Printful, fn conn ->
assert conn.query_string == "offset=0&limit=20"
Req.Test.json(conn, %{"code" => 200, "result" => []})
end)
assert {:ok, []} = Printful.list_sync_products()
end
end
describe "calculate_shipping/2" do
test "sends recipient and items in POST body" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/v2/shipping-rates"
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
assert decoded["recipient"]["country_code"] == "GB"
assert length(decoded["order_items"]) == 1
Req.Test.json(conn, %{
"data" => [%{"shipping" => "STANDARD", "rate" => "4.99", "currency" => "USD"}]
})
end)
recipient = %{country_code: "GB"}
items = [%{source: "catalog", catalog_variant_id: 474, quantity: 1}]
assert {:ok, [rate]} = Printful.calculate_shipping(recipient, items)
assert rate["rate"] == "4.99"
end
end
describe "create_order/1 and confirm_order/1" do
test "create_order sends POST to /v2/orders" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/v2/orders"
Req.Test.json(conn, %{"data" => %{"id" => 12345, "status" => "draft"}})
end)
assert {:ok, %{"id" => 12345}} = Printful.create_order(%{external_id: "SS-001"})
end
test "confirm_order sends POST to /v2/orders/:id/confirmation" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/v2/orders/12345/confirmation"
Req.Test.json(conn, %{"data" => %{"id" => 12345, "status" => "pending"}})
end)
assert {:ok, %{"status" => "pending"}} = Printful.confirm_order(12345)
end
end
describe "get_order_shipments/1" do
test "calls GET /v2/orders/:id/shipments" do
Req.Test.stub(Printful, fn conn ->
assert conn.request_path == "/v2/orders/12345/shipments"
Req.Test.json(conn, %{
"data" => [%{"tracking_number" => "1Z999", "tracking_url" => "https://ups.com/1Z999"}]
})
end)
assert {:ok, [shipment]} = Printful.get_order_shipments(12345)
assert shipment["tracking_number"] == "1Z999"
end
end
describe "setup_webhooks/2" do
test "sends POST to /v2/webhooks with url and events" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/v2/webhooks"
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
assert decoded["url"] =~ "https://example.com"
assert is_list(decoded["events"])
Req.Test.json(conn, %{"data" => %{"url" => decoded["url"]}})
end)
assert {:ok, _} =
Printful.setup_webhooks("https://example.com/webhooks", ["package_shipped"])
end
end
describe "mockup generator" do
test "create_mockup_generator_task sends POST to /mockup-generator/create-task/:id" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/mockup-generator/create-task/71"
Req.Test.json(conn, %{
"code" => 200,
"result" => %{"task_key" => "gt-abc123", "status" => "pending"}
})
end)
assert {:ok, %{"task_key" => "gt-abc123"}} =
Printful.create_mockup_generator_task(71, %{variant_ids: [4011]})
end
test "get_mockup_generator_task polls by task key" do
Req.Test.stub(Printful, fn conn ->
assert conn.query_string =~ "task_key=gt-abc123"
Req.Test.json(conn, %{
"code" => 200,
"result" => %{"status" => "completed", "mockups" => []}
})
end)
assert {:ok, %{"status" => "completed"}} =
Printful.get_mockup_generator_task("gt-abc123")
end
end
describe "delete/1" do
test "delete_webhooks calls DELETE /v2/webhooks" do
Req.Test.stub(Printful, fn conn ->
assert conn.method == "DELETE"
assert conn.request_path == "/v2/webhooks"
Plug.Conn.send_resp(conn, 204, "")
end)
assert {:ok, nil} = Printful.delete_webhooks()
end
end
# =============================================================================
# Default stub router — handles all standard routes for basic tests
# =============================================================================
defp route(%Plug.Conn{method: "GET", request_path: "/v2/stores"} = conn) do
Req.Test.json(conn, %{"data" => [%{"id" => 123, "name" => "Test Store"}]})
end
defp route(%Plug.Conn{method: "GET", request_path: "/v2/orders/" <> rest} = conn) do
cond do
String.contains?(rest, "/shipments") ->
Req.Test.json(conn, %{"data" => []})
true ->
Req.Test.json(conn, %{
"data" => %{"id" => 12345, "status" => "draft", "external_id" => "SS-001"}
})
end
end
defp route(%Plug.Conn{method: "GET", request_path: "/store/products/" <> _id} = conn) do
Req.Test.json(conn, %{
"code" => 200,
"result" => %{
"sync_product" => %{"id" => 456, "name" => "Test T-Shirt", "thumbnail_url" => nil},
"sync_variants" => []
}
})
end
defp route(%Plug.Conn{method: "GET", request_path: "/store/products"} = conn) do
Req.Test.json(conn, %{"code" => 200, "result" => [%{"id" => 456}]})
end
defp route(%Plug.Conn{method: "GET"} = conn) do
Req.Test.json(conn, %{"data" => %{}})
end
defp route(%Plug.Conn{method: "POST"} = conn) do
Req.Test.json(conn, %{"data" => %{}})
end
defp route(%Plug.Conn{method: "DELETE"} = conn) do
Plug.Conn.send_resp(conn, 204, "")
end
end

View File

@ -0,0 +1,429 @@
defmodule Berrypod.Providers.PrintfulIntegrationTest do
@moduledoc """
Happy-path integration tests for the Printful provider.
Uses Req.Test to stub HTTP responses and a real DB connection for fixtures.
"""
use Berrypod.DataCase, async: false
alias Berrypod.Clients.Printful, as: Client
alias Berrypod.Providers.Printful
import Berrypod.ProductsFixtures
setup do
conn =
provider_connection_fixture(%{
provider_type: "printful",
config: %{"store_id" => "99999", "webhook_secret" => "pf_secret_123"}
})
%{provider_conn: conn}
end
# =============================================================================
# test_connection/1
# =============================================================================
describe "test_connection/1 happy path" do
test "returns store_id and store_name", %{provider_conn: conn} do
Req.Test.stub(Client, fn %{request_path: "/v2/stores"} = plug_conn ->
Req.Test.json(plug_conn, %{
"data" => [%{"id" => 77777, "name" => "My Print Store"}]
})
end)
assert {:ok, result} = Printful.test_connection(conn)
assert result.store_id == 77777
assert result.store_name == "My Print Store"
end
end
# =============================================================================
# fetch_products/1
# =============================================================================
describe "fetch_products/1 happy path" do
test "fetches and normalizes a single page of products", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
route_fetch_products(plug_conn)
end)
assert {:ok, products} = Printful.fetch_products(conn)
assert length(products) == 1
[product] = products
assert product.provider_product_id == "456789"
assert product.title == "PC Man T-Shirt"
assert product.category == "Apparel"
# Variants normalised
assert length(product.variants) == 2
[v1, v2] = product.variants
assert v1.title == "Black / S"
assert v1.price == 1350
assert v2.title == "Natural / S"
# Images extracted from preview files (one per unique src URL)
assert length(product.images) == 2
# Provider data populated
assert product.provider_data.catalog_product_id == 71
assert product.provider_data.blueprint_id == 71
assert is_list(product.provider_data.options)
end
test "paginates when first page returns 20 products", %{provider_conn: conn} do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(Client, fn plug_conn ->
case plug_conn.request_path do
"/store/products" ->
:counters.add(call_count, 1, 1)
page = :counters.get(call_count, 1)
if page == 1 do
# First page: exactly 20 products to trigger pagination
products = for i <- 1..20, do: %{"id" => i, "name" => "Product #{i}"}
Req.Test.json(plug_conn, %{"code" => 200, "result" => products})
else
# Second page: fewer than 20 to stop
Req.Test.json(plug_conn, %{"code" => 200, "result" => [%{"id" => 21}]})
end
"/store/products/" <> _id ->
Req.Test.json(plug_conn, %{
"code" => 200,
"result" => sync_product_detail_response()
})
"/v2/catalog-products/" <> _id ->
Req.Test.json(plug_conn, %{
"data" => %{"colors" => [%{"name" => "Black", "value" => "#0b0b0b"}]}
})
_ ->
Req.Test.json(plug_conn, %{"data" => %{}})
end
end)
assert {:ok, products} = Printful.fetch_products(conn)
# 20 from page 1 + 1 from page 2 = 21 products
assert length(products) == 21
end
end
# =============================================================================
# submit_order/2
# =============================================================================
describe "submit_order/2 happy path" do
test "creates and confirms an order", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
case {plug_conn.method, plug_conn.request_path} do
{"POST", "/v2/orders"} ->
{:ok, body, _} = Plug.Conn.read_body(plug_conn)
decoded = Jason.decode!(body)
# Verify the order payload structure
assert decoded["external_id"] == "SS-001234"
assert decoded["shipping"] == "STANDARD"
assert decoded["recipient"]["email"] == "test@example.com"
assert length(decoded["items"]) == 1
Req.Test.json(plug_conn, %{
"data" => %{"id" => 55555, "status" => "draft"}
})
{"POST", "/v2/orders/55555/confirmation"} ->
Req.Test.json(plug_conn, %{
"data" => %{"id" => 55555, "status" => "pending"}
})
end
end)
order_data = %{
order_number: "SS-001234",
customer_email: "test@example.com",
shipping_address: %{
"name" => "Jane Doe",
"line1" => "1 High Street",
"city" => "London",
"country" => "GB",
"postal_code" => "SW1A 1AA"
},
line_items: [
%{
provider_variant_id: "5001",
quantity: 2
}
]
}
assert {:ok, %{provider_order_id: "55555"}} = Printful.submit_order(conn, order_data)
end
end
# =============================================================================
# get_order_status/2
# =============================================================================
describe "get_order_status/2 happy path" do
test "returns normalised status with tracking", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
case plug_conn.request_path do
"/v2/orders/55555/shipments" ->
Req.Test.json(plug_conn, %{
"data" => [
%{
"tracking_number" => "PF-TRACK-001",
"tracking_url" => "https://tracking.printful.com/PF-TRACK-001"
}
]
})
"/v2/orders/55555" ->
Req.Test.json(plug_conn, %{
"data" => %{"id" => 55555, "status" => "fulfilled"}
})
end
end)
assert {:ok, status} = Printful.get_order_status(conn, "55555")
assert status.status == "shipped"
assert status.provider_status == "fulfilled"
assert status.tracking_number == "PF-TRACK-001"
assert status.tracking_url == "https://tracking.printful.com/PF-TRACK-001"
end
test "returns status with nil tracking when no shipments", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
case plug_conn.request_path do
"/v2/orders/55555/shipments" ->
Req.Test.json(plug_conn, %{"data" => []})
"/v2/orders/55555" ->
Req.Test.json(plug_conn, %{
"data" => %{"id" => 55555, "status" => "pending"}
})
end
end)
assert {:ok, status} = Printful.get_order_status(conn, "55555")
assert status.status == "submitted"
assert is_nil(status.tracking_number)
end
end
# =============================================================================
# fetch_shipping_rates/2
# =============================================================================
describe "fetch_shipping_rates/2 happy path" do
test "returns rates for target countries", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
assert plug_conn.request_path == "/v2/shipping-rates"
Req.Test.json(plug_conn, %{
"data" => [
%{
"shipping" => "STANDARD",
"rate" => "4.99",
"currency" => "USD",
"max_delivery_days" => 7
}
]
})
end)
products = [
%{
provider_data: %{
catalog_product_id: 71,
catalog_variant_ids: [4011, 4012]
}
}
]
assert {:ok, rates} = Printful.fetch_shipping_rates(conn, products)
# Should get one rate per target country (10 countries)
assert length(rates) == 10
[rate | _] = rates
assert rate.blueprint_id == 71
assert rate.first_item_cost == 499
assert rate.currency == "USD"
assert rate.handling_time_days == 7
end
test "handles partial failures gracefully", %{provider_conn: conn} do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(Client, fn plug_conn ->
:counters.add(call_count, 1, 1)
count = :counters.get(call_count, 1)
# Fail every 3rd country
if rem(count, 3) == 0 do
Req.Test.json(plug_conn |> Plug.Conn.put_status(500), %{"error" => "rate limit"})
else
Req.Test.json(plug_conn, %{
"data" => [
%{
"shipping" => "STANDARD",
"rate" => "3.99",
"currency" => "USD",
"max_delivery_days" => 5
}
]
})
end
end)
products = [
%{provider_data: %{catalog_product_id: 71, catalog_variant_ids: [4011]}}
]
assert {:ok, rates} = Printful.fetch_shipping_rates(conn, products)
# Some countries fail, but we still get rates for the rest
assert length(rates) > 0
assert length(rates) < 10
end
end
# =============================================================================
# register_webhooks/2
# =============================================================================
describe "register_webhooks/2" do
test "appends token to webhook URL", %{provider_conn: conn} do
Req.Test.stub(Client, fn plug_conn ->
{:ok, body, _} = Plug.Conn.read_body(plug_conn)
decoded = Jason.decode!(body)
# Verify the token is appended as a query param
assert decoded["url"] =~ "token=pf_secret_123"
assert decoded["url"] =~ "https://example.com/webhooks/printful"
assert is_list(decoded["events"])
assert "package_shipped" in decoded["events"]
Req.Test.json(plug_conn, %{"data" => %{"url" => decoded["url"]}})
end)
assert {:ok, _} =
Printful.register_webhooks(conn, "https://example.com/webhooks/printful")
end
test "returns error when no webhook_secret" do
conn =
provider_connection_fixture(%{
provider_type: "printful",
config: %{"store_id" => "99999"}
})
assert {:error, :no_webhook_secret} =
Printful.register_webhooks(conn, "https://example.com/webhooks/printful")
end
test "returns error when no store_id" do
conn =
provider_connection_fixture(%{
provider_type: "printful",
config: %{"webhook_secret" => "pf_secret_123"}
})
assert {:error, :no_store_id} =
Printful.register_webhooks(conn, "https://example.com/webhooks/printful")
end
end
# =============================================================================
# Stub responses
# =============================================================================
defp route_fetch_products(%{request_path: "/store/products"} = conn) do
# Single page with one product (fewer than 20 — no pagination)
Req.Test.json(conn, %{
"code" => 200,
"result" => [%{"id" => 456_789, "name" => "PC Man T-Shirt"}]
})
end
defp route_fetch_products(%{request_path: "/store/products/" <> _id} = conn) do
Req.Test.json(conn, %{
"code" => 200,
"result" => sync_product_detail_response()
})
end
defp route_fetch_products(%{request_path: "/v2/catalog-products/" <> _id} = conn) do
Req.Test.json(conn, %{
"data" => %{
"colors" => [
%{"name" => "Black", "value" => "#0b0b0b"},
%{"name" => "Natural", "value" => "#F5F5DC"}
]
}
})
end
defp route_fetch_products(conn) do
Req.Test.json(conn, %{"data" => %{}})
end
defp sync_product_detail_response do
%{
"sync_product" => %{
"id" => 456_789,
"name" => "PC Man T-Shirt",
"thumbnail_url" => "https://files.cdn.printful.com/thumb.png"
},
"sync_variants" => [
%{
"id" => 5001,
"color" => "Black",
"size" => "S",
"retail_price" => "13.50",
"sku" => "PCM-BK-S",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4011,
"product" => %{
"product_id" => 71,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-black.png"
},
%{
"type" => "default",
"url" => "https://files.cdn.printful.com/artwork.png"
}
]
},
%{
"id" => 5003,
"color" => "Natural",
"size" => "S",
"retail_price" => "13.50",
"sku" => "PCM-NT-S",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4013,
"product" => %{
"product_id" => 71,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-natural.png"
}
]
}
]
}
end
end

View File

@ -0,0 +1,241 @@
defmodule Berrypod.Sync.MockupEnricherTest do
use Berrypod.DataCase, async: false
alias Berrypod.Clients.Printful, as: Client
alias Berrypod.Products
alias Berrypod.Sync.MockupEnricher
import Berrypod.ProductsFixtures
@moduletag capture_log: true
setup do
conn =
provider_connection_fixture(%{
provider_type: "printful",
config: %{"store_id" => "99999"}
})
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{
"catalog_product_id" => 71,
"artwork_url" => "https://files.cdn.printful.com/artwork.png",
"color_variant_map" => %{"Black" => 4011, "Natural" => 4013}
}
})
%{provider_conn: conn, product: product}
end
describe "perform/1 happy path" do
test "creates product images from mockup results", %{
provider_conn: conn,
product: product
} do
stub_mockup_generator_success()
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert :ok = MockupEnricher.perform(job)
# Hero colour gets extra angles, other colours get front only
images = Products.list_product_images(product.id)
assert length(images) > 0
# Check images have color tags
assert Enum.any?(images, fn img -> img.color == "Black" end)
end
end
describe "perform/1 skip conditions" do
test "skips when no artwork_url", %{provider_conn: conn} do
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{
"catalog_product_id" => 71,
"color_variant_map" => %{"Black" => 4011}
}
})
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert :ok = MockupEnricher.perform(job)
assert Products.list_product_images(product.id) == []
end
test "skips when no catalog_product_id", %{provider_conn: conn} do
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{
"artwork_url" => "https://example.com/art.png",
"color_variant_map" => %{"Black" => 4011}
}
})
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert :ok = MockupEnricher.perform(job)
assert Products.list_product_images(product.id) == []
end
test "skips when already enriched", %{provider_conn: conn, product: product} do
# Create an existing color-tagged image to simulate prior enrichment
Products.create_product_image(%{
product_id: product.id,
src: "https://example.com/existing.jpg",
alt: "Front",
color: "Black",
position: 0
})
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert :ok = MockupEnricher.perform(job)
# Should still only have the one existing image
images = Products.list_product_images(product.id)
assert length(images) == 1
end
end
describe "perform/1 error handling" do
test "returns {:cancel, :not_found} when connection missing", %{product: product} do
job = %Oban.Job{
args: %{
"provider_connection_id" => Ecto.UUID.generate(),
"product_id" => product.id
}
}
assert {:cancel, :not_found} = MockupEnricher.perform(job)
end
test "returns {:cancel, :not_found} when product missing", %{provider_conn: conn} do
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => Ecto.UUID.generate()
}
}
assert {:cancel, :not_found} = MockupEnricher.perform(job)
end
test "returns {:snooze, 60} on 429 rate limit", %{
provider_conn: conn,
product: product
} do
Req.Test.stub(Client, fn plug_conn ->
Req.Test.json(plug_conn |> Plug.Conn.put_status(429), %{"error" => "rate limited"})
end)
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert {:snooze, 60} = MockupEnricher.perform(job)
end
test "returns :ok on 400 unsupported product", %{
provider_conn: conn,
product: product
} do
Req.Test.stub(Client, fn plug_conn ->
Req.Test.json(
plug_conn |> Plug.Conn.put_status(400),
%{"error" => "Product not supported"}
)
end)
job = %Oban.Job{
args: %{
"provider_connection_id" => conn.id,
"product_id" => product.id
}
}
assert :ok = MockupEnricher.perform(job)
end
end
describe "enqueue/3" do
test "staggers jobs by delay_index", %{provider_conn: conn, product: product} do
stub_mockup_generator_success()
{:ok, job0} = MockupEnricher.enqueue(conn.id, product.id, 0)
{:ok, job2} = MockupEnricher.enqueue(conn.id, product.id, 2)
# Job 2 should be scheduled later than job 0
assert DateTime.compare(job2.scheduled_at, job0.scheduled_at) == :gt
end
end
# =============================================================================
# Stub helpers
# =============================================================================
defp stub_mockup_generator_success do
Req.Test.stub(Client, fn plug_conn ->
case {plug_conn.method, plug_conn.request_path} do
{"POST", "/mockup-generator/create-task/" <> _id} ->
Req.Test.json(plug_conn, %{
"code" => 200,
"result" => %{"task_key" => "gt-test-123", "status" => "pending"}
})
{"GET", "/mockup-generator/task"} ->
Req.Test.json(plug_conn, %{
"code" => 200,
"result" => %{
"status" => "completed",
"mockups" => [
%{
"mockup_url" => "https://mockup.printful.com/front.jpg",
"extra" => [
%{
"url" => "https://mockup.printful.com/back.jpg",
"title" => "Back"
},
%{
"url" => "https://mockup.printful.com/left.jpg",
"title" => "Left"
}
]
}
]
}
})
_ ->
Req.Test.json(plug_conn, %{"data" => %{}})
end
end)
end
end