diff --git a/config/test.exs b/config/test.exs index a29d971..bd4437d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,6 +49,9 @@ 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 +# Route HTTP clients through Req.Test stubs config :berrypod, Berrypod.Clients.Printful, req_options: [plug: {Req.Test, Berrypod.Clients.Printful}, retry: false] + +config :berrypod, Berrypod.Clients.Printify, + req_options: [plug: {Req.Test, Berrypod.Clients.Printify}, retry: false] diff --git a/lib/berrypod/clients/printify.ex b/lib/berrypod/clients/printify.ex index 72799a5..a89ffd9 100644 --- a/lib/berrypod/clients/printify.ex +++ b/lib/berrypod/clients/printify.ex @@ -26,7 +26,7 @@ defmodule Berrypod.Clients.Printify 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, body} @@ -44,7 +44,10 @@ defmodule Berrypod.Clients.Printify 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, body} @@ -62,7 +65,10 @@ defmodule Berrypod.Clients.Printify do def put(path, body, _opts \\ []) do url = @base_url <> path - case Req.put(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do + case Req.put( + 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, body} @@ -80,13 +86,13 @@ defmodule Berrypod.Clients.Printify do def delete(path, _opts \\ []) do url = @base_url <> path - case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do + 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, body} - {:ok, %Req.Response{status: status}} when status == 204 -> - {:ok, nil} - {:ok, %Req.Response{status: status, body: body}} -> {:error, {status, body}} @@ -267,4 +273,8 @@ defmodule Berrypod.Clients.Printify do {"Content-Type", "application/json"} ] end + + defp base_options do + Application.get_env(:berrypod, __MODULE__, [])[:req_options] || [] + end end diff --git a/test/berrypod/clients/printify_http_test.exs b/test/berrypod/clients/printify_http_test.exs new file mode 100644 index 0000000..b6ae2b9 --- /dev/null +++ b/test/berrypod/clients/printify_http_test.exs @@ -0,0 +1,325 @@ +defmodule Berrypod.Clients.PrintifyHttpTest do + @moduledoc """ + Tests the Printify HTTP client against Req.Test stubs. + Exercises URL construction, auth headers, response handling, and error cases. + """ + + use ExUnit.Case, async: true + + alias Berrypod.Clients.Printify + + setup do + Process.put(:printify_api_key, "test_token_printify") + Req.Test.stub(Printify, &route/1) + :ok + end + + # ============================================================================= + # Auth headers + # ============================================================================= + + describe "auth headers" do + test "includes Bearer token" do + Req.Test.stub(Printify, fn conn -> + [auth] = Plug.Conn.get_req_header(conn, "authorization") + assert auth == "Bearer test_token_printify" + Req.Test.json(conn, [%{"id" => 1, "title" => "Shop"}]) + end) + + assert {:ok, _} = Printify.get_shops() + end + + test "includes Content-Type json header" do + Req.Test.stub(Printify, fn conn -> + [ct] = Plug.Conn.get_req_header(conn, "content-type") + assert ct == "application/json" + Req.Test.json(conn, []) + end) + + assert {:ok, _} = Printify.get_shops() + end + end + + # ============================================================================= + # Error handling + # ============================================================================= + + describe "error handling" do + test "returns error tuple on 404" do + Req.Test.stub(Printify, fn conn -> + Req.Test.json(conn |> Plug.Conn.put_status(404), %{"error" => "Not found"}) + end) + + assert {:error, {404, %{"error" => "Not found"}}} = Printify.get("/shops.json") + end + + test "returns error tuple on 500" do + Req.Test.stub(Printify, fn conn -> + Req.Test.json(conn |> Plug.Conn.put_status(500), %{"error" => "Internal error"}) + end) + + assert {:error, {500, _}} = Printify.get_shops() + end + + test "post returns error on 422" do + Req.Test.stub(Printify, fn conn -> + Req.Test.json( + conn |> Plug.Conn.put_status(422), + %{"error" => "Invalid data"} + ) + end) + + assert {:error, {422, %{"error" => "Invalid data"}}} = + Printify.create_order("shop1", %{test: true}) + end + end + + # ============================================================================= + # get_shops/0 and get_shop_id/0 + # ============================================================================= + + describe "get_shops/0" do + test "calls GET /shops.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/v1/shops.json" + Req.Test.json(conn, [%{"id" => 42, "title" => "My Shop"}]) + end) + + assert {:ok, [%{"id" => 42}]} = Printify.get_shops() + end + end + + describe "get_shop_id/0" do + test "returns first shop id" do + assert {:ok, 42} = Printify.get_shop_id() + end + + test "returns error when no shops" do + Req.Test.stub(Printify, fn conn -> + Req.Test.json(conn, []) + end) + + assert {:error, :no_shops} = Printify.get_shop_id() + end + end + + # ============================================================================= + # list_products/2 + # ============================================================================= + + describe "list_products/2" do + test "passes limit and page as query params" do + Req.Test.stub(Printify, fn conn -> + assert conn.request_path == "/v1/shops/shop1/products.json" + assert conn.query_string == "limit=10&page=3" + Req.Test.json(conn, %{"data" => [], "current_page" => 3, "last_page" => 3}) + end) + + assert {:ok, _} = Printify.list_products("shop1", limit: 10, page: 3) + end + + test "defaults to limit 50 and page 1" do + Req.Test.stub(Printify, fn conn -> + assert conn.query_string == "limit=50&page=1" + Req.Test.json(conn, %{"data" => []}) + end) + + assert {:ok, _} = Printify.list_products("shop1") + end + end + + # ============================================================================= + # get_product/2 + # ============================================================================= + + describe "get_product/2" do + test "calls GET /shops/:shop_id/products/:id.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/v1/shops/shop1/products/prod1.json" + Req.Test.json(conn, %{"id" => "prod1", "title" => "T-Shirt"}) + end) + + assert {:ok, %{"id" => "prod1"}} = Printify.get_product("shop1", "prod1") + end + end + + # ============================================================================= + # create_order/2 + # ============================================================================= + + describe "create_order/2" do + test "sends POST to /shops/:shop_id/orders.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/v1/shops/shop1/orders.json" + + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["external_id"] == "SS-001" + + Req.Test.json(conn, %{"id" => "order_123"}) + end) + + assert {:ok, %{"id" => "order_123"}} = + Printify.create_order("shop1", %{external_id: "SS-001"}) + end + end + + # ============================================================================= + # get_order/2 + # ============================================================================= + + describe "get_order/2" do + test "calls GET /shops/:shop_id/orders/:id.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/v1/shops/shop1/orders/order_123.json" + + Req.Test.json(conn, %{ + "id" => "order_123", + "status" => "in-production", + "shipments" => [] + }) + end) + + assert {:ok, %{"status" => "in-production"}} = Printify.get_order("shop1", "order_123") + end + end + + # ============================================================================= + # get_shipping/2 + # ============================================================================= + + describe "get_shipping/2" do + test "calls GET /catalog/blueprints/:id/print_providers/:id/shipping.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.request_path == + "/v1/catalog/blueprints/6/print_providers/99/shipping.json" + + Req.Test.json(conn, %{ + "handling_time" => %{"value" => 3, "unit" => "day"}, + "profiles" => [] + }) + end) + + assert {:ok, %{"handling_time" => _}} = Printify.get_shipping(6, 99) + end + end + + # ============================================================================= + # Webhooks + # ============================================================================= + + describe "create_webhook/4" do + test "sends POST to /shops/:shop_id/webhooks.json with topic, url, secret" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/v1/shops/shop1/webhooks.json" + + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["topic"] == "product:updated" + assert decoded["url"] == "https://example.com/webhooks" + assert decoded["secret"] == "wh_secret" + + Req.Test.json(conn, %{"id" => "wh_1", "topic" => "product:updated"}) + end) + + assert {:ok, %{"id" => "wh_1"}} = + Printify.create_webhook( + "shop1", + "https://example.com/webhooks", + "product:updated", + "wh_secret" + ) + end + end + + describe "list_webhooks/1" do + test "calls GET /shops/:shop_id/webhooks.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/v1/shops/shop1/webhooks.json" + Req.Test.json(conn, [%{"id" => "wh_1"}]) + end) + + assert {:ok, [%{"id" => "wh_1"}]} = Printify.list_webhooks("shop1") + end + end + + describe "delete_webhook/2" do + test "calls DELETE /shops/:shop_id/webhooks/:id.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "DELETE" + assert conn.request_path == "/v1/shops/shop1/webhooks/wh_1.json" + Plug.Conn.send_resp(conn, 204, "") + end) + + assert {:ok, nil} = Printify.delete_webhook("shop1", "wh_1") + end + end + + # ============================================================================= + # update_product/3 (PUT method) + # ============================================================================= + + describe "update_product/3" do + test "sends PUT to /shops/:shop_id/products/:id.json" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "PUT" + assert conn.request_path == "/v1/shops/shop1/products/prod1.json" + + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["title"] == "Updated Title" + + Req.Test.json(conn, %{"id" => "prod1", "title" => "Updated Title"}) + end) + + assert {:ok, %{"title" => "Updated Title"}} = + Printify.update_product("shop1", "prod1", %{title: "Updated Title"}) + end + end + + # ============================================================================= + # delete_product/2 + # ============================================================================= + + describe "delete_product/2" do + test "returns {:ok, nil} on 204" do + Req.Test.stub(Printify, fn conn -> + assert conn.method == "DELETE" + assert conn.request_path == "/v1/shops/shop1/products/prod1.json" + Plug.Conn.send_resp(conn, 204, "") + end) + + assert {:ok, nil} = Printify.delete_product("shop1", "prod1") + end + end + + # ============================================================================= + # Default stub router + # ============================================================================= + + defp route(%Plug.Conn{method: "GET", request_path: "/v1/shops.json"} = conn) do + Req.Test.json(conn, [%{"id" => 42, "title" => "Test Shop"}]) + end + + defp route(%Plug.Conn{method: "GET"} = conn) do + Req.Test.json(conn, %{}) + end + + defp route(%Plug.Conn{method: "POST"} = conn) do + Req.Test.json(conn, %{}) + end + + defp route(%Plug.Conn{method: "PUT"} = conn) do + Req.Test.json(conn, %{}) + end + + defp route(%Plug.Conn{method: "DELETE"} = conn) do + Plug.Conn.send_resp(conn, 204, "") + end +end diff --git a/test/berrypod/providers/printify_integration_test.exs b/test/berrypod/providers/printify_integration_test.exs new file mode 100644 index 0000000..1d59afe --- /dev/null +++ b/test/berrypod/providers/printify_integration_test.exs @@ -0,0 +1,351 @@ +defmodule Berrypod.Providers.PrintifyIntegrationTest do + @moduledoc """ + Happy-path integration tests for the Printify provider. + Uses Req.Test to stub HTTP responses and a real DB connection for fixtures. + """ + + use Berrypod.DataCase, async: false + + alias Berrypod.Clients.Printify, as: Client + alias Berrypod.Providers.Printify + + import Berrypod.ProductsFixtures + + setup do + conn = + provider_connection_fixture(%{ + provider_type: "printify", + config: %{"shop_id" => "12345", "webhook_secret" => "py_secret_abc"} + }) + + %{provider_conn: conn} + end + + # ============================================================================= + # test_connection/1 + # ============================================================================= + + describe "test_connection/1 happy path" do + test "returns shop_id and shop_name", %{provider_conn: conn} do + Req.Test.stub(Client, fn %{request_path: "/v1/shops.json"} = plug_conn -> + Req.Test.json(plug_conn, [ + %{"id" => 55555, "title" => "Berry Pod Shop"} + ]) + end) + + assert {:ok, result} = Printify.test_connection(conn) + assert result.shop_id == 55555 + assert result.shop_name == "Berry Pod Shop" + 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} = Printify.fetch_products(conn) + assert length(products) == 1 + + [product] = products + assert product.provider_product_id == "12345" + assert product.title == "Classic T-Shirt" + assert product.category == "Apparel" + + # Variants normalised + assert length(product.variants) == 2 + [v1, v2] = product.variants + assert v1.provider_variant_id == "100" + assert v1.title == "Solid White / S" + assert v1.price == 2500 + assert v2.provider_variant_id == "101" + + # Images present + assert length(product.images) >= 1 + + # Provider data populated + assert product.provider_data.blueprint_id == 145 + assert product.provider_data.print_provider_id == 29 + assert is_list(product.provider_data.options) + end + + test "paginates when current_page < last_page", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + case plug_conn.request_path do + "/v1/shops/12345/products.json" -> + # Parse page from query string + params = URI.decode_query(plug_conn.query_string || "") + page = String.to_integer(params["page"] || "1") + + if page == 1 do + Req.Test.json(plug_conn, %{ + "current_page" => 1, + "last_page" => 2, + "data" => [printify_product_response()] + }) + else + Req.Test.json(plug_conn, %{ + "current_page" => 2, + "last_page" => 2, + "data" => [ + printify_product_response() + |> Map.put("id", "67890") + |> Map.put("title", "Second Product") + ] + }) + end + + _ -> + Req.Test.json(plug_conn, %{}) + end + end) + + assert {:ok, products} = Printify.fetch_products(conn) + assert length(products) == 2 + end + end + + # ============================================================================= + # submit_order/2 + # ============================================================================= + + describe "submit_order/2 happy path" do + test "creates an order and returns provider_order_id", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + assert plug_conn.method == "POST" + assert plug_conn.request_path == "/v1/shops/12345/orders.json" + + {:ok, body, _} = Plug.Conn.read_body(plug_conn) + decoded = Jason.decode!(body) + + assert decoded["external_id"] == "SS-001234" + assert decoded["address_to"]["first_name"] == "Jane" + assert decoded["address_to"]["country"] == "GB" + assert length(decoded["line_items"]) == 1 + + Req.Test.json(plug_conn, %{"id" => "printify_order_555"}) + 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_product_id: "prod_1", + provider_variant_id: "100", + quantity: 2 + } + ] + } + + assert {:ok, %{provider_order_id: "printify_order_555"}} = + Printify.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 -> + assert plug_conn.request_path == "/v1/shops/12345/orders/order_555.json" + + Req.Test.json(plug_conn, %{ + "id" => "order_555", + "status" => "shipped", + "shipments" => [ + %{ + "tracking_number" => "PY-TRACK-001", + "tracking_url" => "https://tracking.printify.com/PY-TRACK-001" + } + ] + }) + end) + + assert {:ok, status} = Printify.get_order_status(conn, "order_555") + assert status.status == "shipped" + assert status.provider_status == "shipped" + assert status.tracking_number == "PY-TRACK-001" + assert status.tracking_url == "https://tracking.printify.com/PY-TRACK-001" + end + + test "returns status with nil tracking when no shipments", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + Req.Test.json(plug_conn, %{ + "id" => "order_555", + "status" => "pending", + "shipments" => [] + }) + end) + + assert {:ok, status} = Printify.get_order_status(conn, "order_555") + 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 "normalises profiles into per-country rates", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + assert plug_conn.request_path =~ + "/v1/catalog/blueprints/145/print_providers/29/shipping.json" + + Req.Test.json(plug_conn, %{ + "handling_time" => %{"value" => 5, "unit" => "day"}, + "profiles" => [ + %{ + "variant_ids" => [100, 101], + "first_item" => %{"cost" => 399, "currency" => "USD"}, + "additional_items" => %{"cost" => 199, "currency" => "USD"}, + "countries" => ["US", "GB", "CA"] + }, + %{ + "variant_ids" => [100, 101], + "first_item" => %{"cost" => 599, "currency" => "USD"}, + "additional_items" => %{"cost" => 299, "currency" => "USD"}, + "countries" => ["AU", "NZ"] + } + ] + }) + end) + + products = [ + %{ + provider_data: %{ + blueprint_id: 145, + print_provider_id: 29 + } + } + ] + + assert {:ok, rates} = Printify.fetch_shipping_rates(conn, products) + # 3 countries from profile 1 + 2 from profile 2 = 5 + assert length(rates) == 5 + + us_rate = Enum.find(rates, &(&1.country_code == "US")) + assert us_rate.blueprint_id == 145 + assert us_rate.first_item_cost == 399 + assert us_rate.additional_item_cost == 199 + assert us_rate.currency == "USD" + assert us_rate.handling_time_days == 5 + + au_rate = Enum.find(rates, &(&1.country_code == "AU")) + assert au_rate.first_item_cost == 599 + end + + test "handles API errors gracefully", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + Req.Test.json(plug_conn |> Plug.Conn.put_status(500), %{"error" => "rate limit"}) + end) + + products = [ + %{provider_data: %{blueprint_id: 145, print_provider_id: 29}} + ] + + # Should still return ok with empty rates (logged warning) + assert {:ok, []} = Printify.fetch_shipping_rates(conn, products) + end + end + + # ============================================================================= + # register_webhooks/2 + # ============================================================================= + + describe "register_webhooks/2" do + test "creates a webhook for each event", %{provider_conn: conn} do + events_received = :counters.new(1, [:atomics]) + + Req.Test.stub(Client, fn plug_conn -> + assert plug_conn.method == "POST" + assert plug_conn.request_path == "/v1/shops/12345/webhooks.json" + + {:ok, body, _} = Plug.Conn.read_body(plug_conn) + decoded = Jason.decode!(body) + assert is_binary(decoded["topic"]) + assert decoded["url"] =~ "https://example.com/webhooks" + assert decoded["secret"] == "py_secret_abc" + + :counters.add(events_received, 1, 1) + Req.Test.json(plug_conn, %{"id" => "wh_#{:counters.get(events_received, 1)}"}) + end) + + assert {:ok, results} = Printify.register_webhooks(conn, "https://example.com/webhooks") + assert length(results) == 6 + + Enum.each(results, fn result -> + assert {:ok, _event, _response} = result + end) + end + + test "returns error when no webhook_secret" do + conn = + provider_connection_fixture(%{ + provider_type: "printify", + config: %{"shop_id" => "12345"} + }) + + assert {:error, :no_webhook_secret} = + Printify.register_webhooks(conn, "https://example.com/webhooks") + end + + test "returns error when no shop_id" do + conn = + provider_connection_fixture(%{ + provider_type: "printify", + config: %{"webhook_secret" => "py_secret_abc"} + }) + + assert {:error, :no_shop_id} = + Printify.register_webhooks(conn, "https://example.com/webhooks") + end + end + + # ============================================================================= + # list_webhooks/1 + # ============================================================================= + + describe "list_webhooks/1" do + test "returns webhooks for the shop", %{provider_conn: conn} do + Req.Test.stub(Client, fn plug_conn -> + assert plug_conn.request_path == "/v1/shops/12345/webhooks.json" + Req.Test.json(plug_conn, [%{"id" => "wh_1", "topic" => "product:updated"}]) + end) + + assert {:ok, [%{"id" => "wh_1"}]} = Printify.list_webhooks(conn) + end + end + + # ============================================================================= + # Stub responses + # ============================================================================= + + defp route_fetch_products(%{request_path: "/v1/shops/12345/products.json"} = conn) do + Req.Test.json(conn, %{ + "current_page" => 1, + "last_page" => 1, + "data" => [printify_product_response()] + }) + end + + defp route_fetch_products(conn) do + Req.Test.json(conn, %{}) + end +end