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 normalized.provider_data.blueprint_id == 71 assert normalized.provider_data.print_provider_id == 0 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, blueprint_id: catalog_product_id, print_provider_id: 0, 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