diff --git a/config/test.exs b/config/test.exs index 58c7e6f..a29d971 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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] diff --git a/lib/berrypod/clients/printful.ex b/lib/berrypod/clients/printful.ex index 5216e3d..932b708 100644 --- a/lib/berrypod/clients/printful.ex +++ b/lib/berrypod/clients/printful.ex @@ -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 diff --git a/test/berrypod/clients/printful_http_test.exs b/test/berrypod/clients/printful_http_test.exs new file mode 100644 index 0000000..2a8c569 --- /dev/null +++ b/test/berrypod/clients/printful_http_test.exs @@ -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 diff --git a/test/berrypod/providers/printful_integration_test.exs b/test/berrypod/providers/printful_integration_test.exs new file mode 100644 index 0000000..da11638 --- /dev/null +++ b/test/berrypod/providers/printful_integration_test.exs @@ -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 diff --git a/test/berrypod/sync/mockup_enricher_test.exs b/test/berrypod/sync/mockup_enricher_test.exs new file mode 100644 index 0000000..f1a9726 --- /dev/null +++ b/test/berrypod/sync/mockup_enricher_test.exs @@ -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