Printful HTTP client (v2 + v1 for sync products), Provider behaviour implementation with all callbacks (test_connection, fetch_products, submit_order, get_order_status, fetch_shipping_rates), and multi-provider order routing that looks up the provider connection from the order's product instead of hardcoding "printify". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
484 lines
15 KiB
Elixir
484 lines
15 KiB
Elixir
defmodule SimpleshopTheme.Providers.PrintfulTest do
|
|
use SimpleshopTheme.DataCase, async: true
|
|
|
|
alias SimpleshopTheme.Providers.Printful
|
|
|
|
describe "provider_type/0" do
|
|
test "returns printful" do
|
|
assert Printful.provider_type() == "printful"
|
|
end
|
|
end
|
|
|
|
describe "test_connection/1" do
|
|
test "returns error when no API key" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil
|
|
}
|
|
|
|
assert {:error, :no_api_key} = Printful.test_connection(conn)
|
|
end
|
|
end
|
|
|
|
describe "fetch_products/1" do
|
|
test "returns error when no store_id in config" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{}
|
|
}
|
|
|
|
assert {:error, :no_store_id} = Printful.fetch_products(conn)
|
|
end
|
|
|
|
test "returns error when no API key" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{"store_id" => "12345"}
|
|
}
|
|
|
|
assert {:error, :no_api_key} = Printful.fetch_products(conn)
|
|
end
|
|
end
|
|
|
|
describe "submit_order/2" do
|
|
test "returns error when no store_id in config" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{}
|
|
}
|
|
|
|
assert {:error, :no_store_id} = Printful.submit_order(conn, %{})
|
|
end
|
|
|
|
test "returns error when no API key" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{"store_id" => "12345"}
|
|
}
|
|
|
|
assert {:error, :no_api_key} = Printful.submit_order(conn, %{})
|
|
end
|
|
end
|
|
|
|
describe "get_order_status/2" do
|
|
test "returns error when no store_id in config" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{}
|
|
}
|
|
|
|
assert {:error, :no_store_id} = Printful.get_order_status(conn, "12345")
|
|
end
|
|
|
|
test "returns error when no API key" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{"store_id" => "12345"}
|
|
}
|
|
|
|
assert {:error, :no_api_key} = Printful.get_order_status(conn, "12345")
|
|
end
|
|
end
|
|
|
|
describe "fetch_shipping_rates/2" do
|
|
test "returns error when no API key" do
|
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
|
provider_type: "printful",
|
|
api_key_encrypted: nil,
|
|
config: %{"store_id" => "12345"}
|
|
}
|
|
|
|
assert {:error, :no_api_key} = Printful.fetch_shipping_rates(conn, [])
|
|
end
|
|
end
|
|
|
|
describe "extract_option_types/1" do
|
|
test "extracts color and size options" do
|
|
provider_data = %{
|
|
"options" => [
|
|
%{
|
|
"name" => "Color",
|
|
"type" => "color",
|
|
"values" => [
|
|
%{"title" => "Black"},
|
|
%{"title" => "Natural", "hex" => "#F5F5DC"}
|
|
]
|
|
},
|
|
%{
|
|
"name" => "Size",
|
|
"type" => "size",
|
|
"values" => [
|
|
%{"title" => "S"},
|
|
%{"title" => "M"},
|
|
%{"title" => "L"}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
result = Printful.extract_option_types(provider_data)
|
|
|
|
assert length(result) == 2
|
|
|
|
[color_opt, size_opt] = result
|
|
assert color_opt.name == "Color"
|
|
assert color_opt.type == :color
|
|
assert length(color_opt.values) == 2
|
|
assert hd(color_opt.values).title == "Black"
|
|
assert Enum.at(color_opt.values, 1).hex == "#F5F5DC"
|
|
|
|
assert size_opt.name == "Size"
|
|
assert size_opt.type == :size
|
|
assert length(size_opt.values) == 3
|
|
end
|
|
|
|
test "returns empty list for nil provider_data" do
|
|
assert Printful.extract_option_types(nil) == []
|
|
end
|
|
|
|
test "returns empty list for provider_data without options" do
|
|
assert Printful.extract_option_types(%{}) == []
|
|
end
|
|
end
|
|
|
|
describe "product normalization" do
|
|
test "normalizes sync product response correctly" do
|
|
{sync_product, sync_variants} = printful_sync_product_response()
|
|
normalized = normalize_product(sync_product, sync_variants)
|
|
|
|
assert normalized.provider_product_id == "456789"
|
|
assert normalized.title == "PC Man T-Shirt"
|
|
assert normalized.description == ""
|
|
assert normalized.category == "Apparel"
|
|
|
|
# Images — one per unique colour from preview files
|
|
assert length(normalized.images) == 2
|
|
[img1, img2] = normalized.images
|
|
assert img1.position == 0
|
|
assert img2.position == 1
|
|
assert img1.alt == "Black"
|
|
assert img2.alt == "Natural"
|
|
|
|
# Variants
|
|
assert length(normalized.variants) == 4
|
|
[v1 | _] = normalized.variants
|
|
assert v1.provider_variant_id == "5001"
|
|
assert v1.title == "Black / S"
|
|
assert v1.price == 1350
|
|
assert v1.sku == "PCM-BK-S"
|
|
assert v1.is_enabled == true
|
|
assert v1.is_available == true
|
|
assert v1.options == %{"Color" => "Black", "Size" => "S"}
|
|
|
|
# Provider data
|
|
assert normalized.provider_data.catalog_product_id == 71
|
|
assert is_list(normalized.provider_data.options)
|
|
assert length(normalized.provider_data.catalog_variant_ids) == 4
|
|
end
|
|
|
|
test "handles variant with missing colour or size" do
|
|
sync_product = %{"id" => 1, "name" => "Test", "thumbnail_url" => nil}
|
|
|
|
sync_variants = [
|
|
%{
|
|
"id" => 100,
|
|
"color" => nil,
|
|
"size" => "M",
|
|
"retail_price" => "10.00",
|
|
"sku" => "T-M",
|
|
"synced" => true,
|
|
"availability_status" => "active",
|
|
"files" => [],
|
|
"product" => %{"product_id" => 1, "name" => "Test"},
|
|
"variant_id" => 200
|
|
}
|
|
]
|
|
|
|
normalized = normalize_product(sync_product, sync_variants)
|
|
|
|
assert length(normalized.variants) == 1
|
|
[v] = normalized.variants
|
|
assert v.title == "M"
|
|
assert v.options == %{"Size" => "M"}
|
|
end
|
|
|
|
test "parses price strings correctly" do
|
|
assert parse_price("13.50") == 1350
|
|
assert parse_price("0.99") == 99
|
|
assert parse_price("100.00") == 10000
|
|
assert parse_price(nil) == 0
|
|
assert parse_price(13.5) == 1350
|
|
end
|
|
end
|
|
|
|
describe "order status mapping" do
|
|
test "maps Printful statuses to internal statuses" do
|
|
assert map_order_status("draft") == "submitted"
|
|
assert map_order_status("pending") == "submitted"
|
|
assert map_order_status("inprocess") == "processing"
|
|
assert map_order_status("fulfilled") == "shipped"
|
|
assert map_order_status("shipped") == "shipped"
|
|
assert map_order_status("delivered") == "delivered"
|
|
assert map_order_status("canceled") == "cancelled"
|
|
assert map_order_status("failed") == "submitted"
|
|
assert map_order_status("onhold") == "submitted"
|
|
assert map_order_status("unknown_status") == "submitted"
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Test fixtures — replicate Printful API responses
|
|
# =============================================================================
|
|
|
|
defp printful_sync_product_response do
|
|
sync_product = %{
|
|
"id" => 456_789,
|
|
"name" => "PC Man T-Shirt",
|
|
"thumbnail_url" => "https://files.cdn.printful.com/thumb.png",
|
|
"synced" => 4
|
|
}
|
|
|
|
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,
|
|
"variant_id" => 4011,
|
|
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
|
},
|
|
"files" => [
|
|
%{
|
|
"type" => "preview",
|
|
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
|
|
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.png"
|
|
}
|
|
]
|
|
},
|
|
%{
|
|
"id" => 5002,
|
|
"color" => "Black",
|
|
"size" => "M",
|
|
"retail_price" => "13.50",
|
|
"sku" => "PCM-BK-M",
|
|
"synced" => true,
|
|
"availability_status" => "active",
|
|
"variant_id" => 4012,
|
|
"product" => %{
|
|
"product_id" => 71,
|
|
"variant_id" => 4012,
|
|
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
|
},
|
|
"files" => [
|
|
%{
|
|
"type" => "preview",
|
|
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
|
|
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.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,
|
|
"variant_id" => 4013,
|
|
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
|
},
|
|
"files" => [
|
|
%{
|
|
"type" => "preview",
|
|
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
|
|
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
|
|
}
|
|
]
|
|
},
|
|
%{
|
|
"id" => 5004,
|
|
"color" => "Natural",
|
|
"size" => "M",
|
|
"retail_price" => "13.50",
|
|
"sku" => "PCM-NT-M",
|
|
"synced" => true,
|
|
"availability_status" => "active",
|
|
"variant_id" => 4014,
|
|
"product" => %{
|
|
"product_id" => 71,
|
|
"variant_id" => 4014,
|
|
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
|
},
|
|
"files" => [
|
|
%{
|
|
"type" => "preview",
|
|
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
|
|
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
{sync_product, sync_variants}
|
|
end
|
|
|
|
# =============================================================================
|
|
# Test helpers — replicate private normalization functions
|
|
# =============================================================================
|
|
|
|
defp normalize_product(sync_product, sync_variants) do
|
|
images = extract_preview_images(sync_variants)
|
|
catalog_product_id = extract_catalog_product_id(sync_variants)
|
|
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
|
|
|
|
%{
|
|
provider_product_id: to_string(sync_product["id"]),
|
|
title: sync_product["name"],
|
|
description: "",
|
|
category: extract_category(sync_variants),
|
|
images: images,
|
|
variants: Enum.map(sync_variants, &normalize_variant/1),
|
|
provider_data: %{
|
|
catalog_product_id: catalog_product_id,
|
|
catalog_variant_ids: catalog_variant_ids,
|
|
thumbnail_url: sync_product["thumbnail_url"],
|
|
options: build_option_types(sync_variants),
|
|
raw: %{sync_product: sync_product}
|
|
}
|
|
}
|
|
end
|
|
|
|
defp normalize_variant(sv) do
|
|
%{
|
|
provider_variant_id: to_string(sv["id"]),
|
|
title: build_variant_title(sv),
|
|
sku: sv["sku"],
|
|
price: parse_price(sv["retail_price"]),
|
|
cost: nil,
|
|
options: build_variant_options(sv),
|
|
is_enabled: sv["synced"] == true,
|
|
is_available: sv["availability_status"] == "active"
|
|
}
|
|
end
|
|
|
|
defp build_variant_title(sv) do
|
|
[sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.join(" / ")
|
|
end
|
|
|
|
defp build_variant_options(sv) do
|
|
opts = %{}
|
|
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
|
|
if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts
|
|
end
|
|
|
|
defp extract_preview_images(sync_variants) do
|
|
sync_variants
|
|
|> Enum.flat_map(fn sv ->
|
|
(sv["files"] || [])
|
|
|> Enum.filter(&(&1["type"] == "preview"))
|
|
|> Enum.map(fn file ->
|
|
%{src: file["preview_url"] || file["thumbnail_url"], color: sv["color"]}
|
|
end)
|
|
end)
|
|
|> Enum.uniq_by(& &1.color)
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {img, index} ->
|
|
%{src: img.src, position: index, alt: img.color}
|
|
end)
|
|
end
|
|
|
|
defp extract_catalog_product_id(sync_variants) do
|
|
Enum.find_value(sync_variants, 0, fn sv -> get_in(sv, ["product", "product_id"]) end)
|
|
end
|
|
|
|
defp build_option_types(sync_variants) do
|
|
colors =
|
|
sync_variants
|
|
|> Enum.map(& &1["color"])
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.uniq()
|
|
|> Enum.map(fn c -> %{"title" => c} end)
|
|
|
|
sizes =
|
|
sync_variants
|
|
|> Enum.map(& &1["size"])
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.uniq()
|
|
|> Enum.map(fn s -> %{"title" => s} end)
|
|
|
|
opts = []
|
|
|
|
opts =
|
|
if colors != [],
|
|
do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
|
|
else: opts
|
|
|
|
if sizes != [],
|
|
do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
|
|
else: opts
|
|
end
|
|
|
|
defp extract_category(sync_variants) do
|
|
case sync_variants do
|
|
[sv | _] ->
|
|
product_name = get_in(sv, ["product", "name"]) || ""
|
|
categorize_from_name(product_name)
|
|
|
|
[] ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp categorize_from_name(name) do
|
|
name_lower = String.downcase(name)
|
|
|
|
cond do
|
|
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
|
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
|
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
|
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
|
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
|
true -> "Apparel"
|
|
end
|
|
end
|
|
|
|
defp has_keyword?(text, keywords), do: Enum.any?(keywords, &String.contains?(text, &1))
|
|
|
|
defp parse_price(price) when is_binary(price) do
|
|
case Float.parse(price) do
|
|
{float, _} -> round(float * 100)
|
|
:error -> 0
|
|
end
|
|
end
|
|
|
|
defp parse_price(price) when is_number(price), do: round(price * 100)
|
|
defp parse_price(_), do: 0
|
|
|
|
defp map_order_status("draft"), do: "submitted"
|
|
defp map_order_status("pending"), do: "submitted"
|
|
defp map_order_status("inprocess"), do: "processing"
|
|
defp map_order_status("fulfilled"), do: "shipped"
|
|
defp map_order_status("shipped"), do: "shipped"
|
|
defp map_order_status("delivered"), do: "delivered"
|
|
defp map_order_status("canceled"), do: "cancelled"
|
|
defp map_order_status("failed"), do: "submitted"
|
|
defp map_order_status("onhold"), do: "submitted"
|
|
defp map_order_status(_), do: "submitted"
|
|
end
|