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

Same pattern as the Printful work: wire up base_options/0 so tests can
inject a Req.Test plug, fix unreachable 204 clause in delete, add
HTTP-level client tests and provider integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-22 10:35:24 +00:00
parent a45e85ef4c
commit b0aed4c1d6
4 changed files with 697 additions and 8 deletions

View File

@ -49,6 +49,9 @@ config :berrypod, Oban, testing: :inline
# Isolate image cache so test cleanup doesn't wipe the dev cache # Isolate image cache so test cleanup doesn't wipe the dev cache
config :berrypod, :image_cache_dir, Path.expand("../tmp/test_image_cache", __DIR__) 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, config :berrypod, Berrypod.Clients.Printful,
req_options: [plug: {Req.Test, Berrypod.Clients.Printful}, retry: false] 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]

View File

@ -26,7 +26,7 @@ defmodule Berrypod.Clients.Printify do
def get(path, _opts \\ []) do def get(path, _opts \\ []) do
url = @base_url <> path 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, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body} {:ok, body}
@ -44,7 +44,10 @@ defmodule Berrypod.Clients.Printify do
def post(path, body, _opts \\ []) do def post(path, body, _opts \\ []) do
url = @base_url <> path 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, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body} {:ok, body}
@ -62,7 +65,10 @@ defmodule Berrypod.Clients.Printify do
def put(path, body, _opts \\ []) do def put(path, body, _opts \\ []) do
url = @base_url <> path 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, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body} {:ok, body}
@ -80,13 +86,13 @@ defmodule Berrypod.Clients.Printify do
def delete(path, _opts \\ []) do def delete(path, _opts \\ []) do
url = @base_url <> path 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, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body} {:ok, body}
{:ok, %Req.Response{status: status}} when status == 204 ->
{:ok, nil}
{:ok, %Req.Response{status: status, body: body}} -> {:ok, %Req.Response{status: status, body: body}} ->
{:error, {status, body}} {:error, {status, body}}
@ -267,4 +273,8 @@ defmodule Berrypod.Clients.Printify do
{"Content-Type", "application/json"} {"Content-Type", "application/json"}
] ]
end end
defp base_options do
Application.get_env(:berrypod, __MODULE__, [])[:req_options] || []
end
end end

View File

@ -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

View File

@ -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