feat: add Printify order submission and fulfilment tracking

Submit paid orders to Printify via provider API with idempotent
guards, Stripe address mapping, and error handling. Track fulfilment
status through submitted → processing → shipped → delivered via
webhook-driven updates (primary) and Oban Cron polling fallback.

- 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps)
- OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment
- FulfilmentStatusWorker polls every 30 mins for missed webhook events
- Printify order webhook handlers (sent-to-production, shipment, delivered)
- Admin UI: fulfilment column in table, fulfilment card with tracking info,
  submit/retry and refresh buttons on order detail
- Mox provider mocking for test isolation (Provider.for_type configurable)
- 33 new tests (555 total), verified against real Printify API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-08 09:51:51 +00:00
parent 02cdc810f2
commit 3e19887499
22 changed files with 1318 additions and 54 deletions

View File

@@ -0,0 +1,84 @@
defmodule SimpleshopTheme.Orders.FulfilmentStatusWorkerTest do
use SimpleshopTheme.DataCase, async: false
import Mox
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.FulfilmentStatusWorker
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
describe "perform/1" do
test "no-op when no submitted orders" do
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
test "updates status from submitted to processing" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, provider_order_id ->
assert provider_order_id == order.provider_order_id
{:ok,
%{
status: "processing",
provider_status: "in-production",
tracking_number: nil,
tracking_url: nil,
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "sets tracking info when shipped" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "1Z999AA10123456784",
tracking_url: "https://tracking.example.com/1Z999AA10123456784",
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA10123456784"
assert updated.tracking_url == "https://tracking.example.com/1Z999AA10123456784"
assert updated.shipped_at != nil
end
test "handles provider error gracefully" do
{_order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:error, {500, %{"message" => "Internal error"}}}
end)
# Should not raise, just log the error
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
end
end

View File

@@ -0,0 +1,112 @@
defmodule SimpleshopTheme.Orders.OrderSubmissionWorkerTest do
use SimpleshopTheme.DataCase, async: false
import Mox
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderSubmissionWorker
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
describe "perform/1" do
test "cancels if order not found" do
fake_id = Ecto.UUID.generate()
assert {:cancel, :order_not_found} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => fake_id}})
end
test "cancels if order is not paid" do
order = order_fixture()
assert order.payment_status == "pending"
assert {:cancel, :not_paid} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "returns :ok if already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "errors if no shipping address (will retry)" do
order = order_fixture(payment_status: "paid")
assert order.shipping_address == %{}
assert {:error, :no_shipping_address} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "submits to provider successfully" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, order_data ->
assert order_data.order_number == order.order_number
assert length(order_data.line_items) == 1
{:ok, %{provider_order_id: "printify_order_123"}}
end)
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "printify_order_123"
assert updated.submitted_at != nil
end
test "sets failed status when provider returns error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _order_data ->
{:error, {422, %{"message" => "Invalid address"}}}
end)
assert {:error, {422, _}} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (422)"
end
test "sets failed status when variant not found in local DB" do
# Create order with a variant_id that doesn't exist in product variants
order =
order_fixture(%{
variant_id: Ecto.UUID.generate(),
payment_status: "paid",
shipping_address: %{
"name" => "Test",
"line1" => "1 Street",
"city" => "London",
"postal_code" => "SW1",
"country" => "GB"
}
})
# Need a printify connection for the lookup to proceed
SimpleshopTheme.ProductsFixtures.provider_connection_fixture(%{provider_type: "printify"})
assert {:error, {:variant_not_found, _, _}} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "no longer exists"
end
end
end

View File

@@ -1,10 +1,14 @@
defmodule SimpleshopTheme.OrdersTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Orders
import Mox
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
describe "list_orders/1" do
test "returns all orders" do
order1 = order_fixture()
@@ -62,4 +66,154 @@ defmodule SimpleshopTheme.OrdersTest do
assert counts["failed"] == 1
end
end
describe "submit_to_provider/1" do
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
test "submits order and sets fulfilment status" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, order_data ->
assert order_data.order_number == order.order_number
assert is_list(order_data.line_items)
assert hd(order_data.line_items).quantity == 1
{:ok, %{provider_order_id: "pfy_123"}}
end)
assert {:ok, updated} = Orders.submit_to_provider(order)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "pfy_123"
assert updated.submitted_at != nil
assert updated.fulfilment_error == nil
end
test "is idempotent when already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
# No mock expectations — provider should not be called
assert {:ok, ^order} = Orders.submit_to_provider(order)
end
test "sets failed status on provider error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _data ->
{:error, {500, %{"message" => "Server error"}}}
end)
assert {:error, {500, _}} = Orders.submit_to_provider(order)
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (500)"
end
end
describe "refresh_fulfilment_status/1" do
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
test "updates tracking info from provider" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, pid ->
assert pid == order.provider_order_id
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "TRACK123",
tracking_url: "https://track.example.com/TRACK123"
}}
end)
assert {:ok, updated} = Orders.refresh_fulfilment_status(order)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "TRACK123"
assert updated.tracking_url == "https://track.example.com/TRACK123"
assert updated.shipped_at != nil
end
test "no-op when no provider_order_id" do
order = order_fixture(payment_status: "paid")
assert is_nil(order.provider_order_id)
assert {:ok, ^order} = Orders.refresh_fulfilment_status(order)
end
end
describe "update_fulfilment/2" do
test "updates fulfilment fields" do
order = order_fixture()
assert {:ok, updated} =
Orders.update_fulfilment(order, %{
fulfilment_status: "submitted",
provider_order_id: "test_123"
})
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "test_123"
end
test "validates fulfilment status inclusion" do
order = order_fixture()
assert {:error, changeset} =
Orders.update_fulfilment(order, %{fulfilment_status: "bogus"})
assert errors_on(changeset).fulfilment_status != []
end
end
describe "list_submitted_orders/0" do
test "returns orders with submitted or processing status" do
{submitted, _v, _p, _c} = submitted_order_fixture()
{processing, _v2, _p2, _c2} = submitted_order_fixture()
{:ok, processing} =
Orders.update_fulfilment(processing, %{fulfilment_status: "processing"})
_unfulfilled = order_fixture(payment_status: "paid")
orders = Orders.list_submitted_orders()
ids = Enum.map(orders, & &1.id)
assert submitted.id in ids
assert processing.id in ids
assert length(orders) == 2
end
test "returns empty list when no submitted orders" do
order_fixture()
assert Orders.list_submitted_orders() == []
end
end
describe "get_order_by_number/1" do
test "finds order by order number" do
order = order_fixture()
found = Orders.get_order_by_number(order.order_number)
assert found.id == order.id
end
test "returns nil for unknown order number" do
assert is_nil(Orders.get_order_by_number("SS-000000-XXXX"))
end
end
end

View File

@@ -1,19 +1,19 @@
defmodule SimpleshopTheme.WebhooksTest do
use SimpleshopTheme.DataCase
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Webhooks
import SimpleshopTheme.ProductsFixtures
import SimpleshopTheme.OrdersFixtures
setup do
conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, provider_connection: conn}
end
describe "handle_printify_event/2" do
describe "handle_printify_event/2 — product events" do
test "product:updated triggers sync", %{provider_connection: _conn} do
# With inline Oban, the job executes immediately (and fails due to no real API key)
# But the handler should still return {:ok, _} after inserting the job
result =
Webhooks.handle_printify_event(
"product:updated",
@@ -52,7 +52,6 @@ defmodule SimpleshopTheme.WebhooksTest do
end
test "returns error when no provider connection" do
# Delete all connections first
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
assert {:error, :no_connection} =
@@ -62,4 +61,75 @@ defmodule SimpleshopTheme.WebhooksTest do
)
end
end
describe "handle_printify_event/2 — order events" do
test "order:sent-to-production updates fulfilment status" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "order:shipment:created sets tracking info and shipped_at" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:created", %{
"id" => "printify_abc",
"external_id" => order.order_number,
"shipments" => [
%{
"tracking_number" => "1Z999AA1",
"tracking_url" => "https://ups.com/track/1Z999AA1"
}
]
})
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA1"
assert updated.tracking_url == "https://ups.com/track/1Z999AA1"
assert updated.shipped_at != nil
end
test "order:shipment:delivered sets delivered_at" do
{order, _v, _p, _c} = submitted_order_fixture()
# First mark as shipped
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:delivered", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "delivered"
assert updated.delivered_at != nil
end
test "order event with unknown external_id returns error" do
assert {:error, :order_not_found} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => "SS-000000-NOPE"
})
end
test "order event with missing external_id returns error" do
assert {:error, :missing_external_id} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc"
})
end
end
end

View File

@@ -115,5 +115,76 @@ defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do
{:error, {:live_redirect, %{to: "/admin/orders"}}} =
live(conn, ~p"/admin/orders/#{fake_id}")
end
test "shows fulfilment card", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Fulfilment"
assert html =~ "unfulfilled"
end
test "shows submit button for paid unfulfilled orders", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Submit to provider"
end
test "shows retry button for failed fulfilment", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, order} =
SimpleshopTheme.Orders.update_fulfilment(order, %{fulfilment_status: "failed"})
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Retry submission"
end
test "shows refresh button for submitted orders", %{conn: conn} do
{order, _v, _p, _c} =
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Refresh status"
end
test "shows tracking info when available", %{conn: conn} do
{order, _v, _p, _c} =
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
{:ok, order} =
SimpleshopTheme.Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "TRACK123",
tracking_url: "https://track.example.com/TRACK123",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "TRACK123"
assert html =~ "https://track.example.com/TRACK123"
end
end
describe "order list fulfilment column" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows fulfilment badge in table", %{conn: conn} do
order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ "Fulfilment"
assert html =~ "unfulfilled"
end
end
end

View File

@@ -5,12 +5,14 @@ defmodule SimpleshopTheme.OrdersFixtures do
alias SimpleshopTheme.Orders
import SimpleshopTheme.ProductsFixtures
def order_fixture(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
items = [
%{
variant_id: "var_#{System.unique_integer([:positive])}",
variant_id: Map.get(attrs, :variant_id, "var_#{System.unique_integer([:positive])}"),
name: Map.get(attrs, :product_name, "Test product"),
variant: Map.get(attrs, :variant_title, "Red / Large"),
price: Map.get(attrs, :unit_price, 1999),
@@ -26,6 +28,17 @@ defmodule SimpleshopTheme.OrdersFixtures do
{:ok, order} = Orders.create_order(order_attrs)
# Apply shipping address if provided
order =
case attrs[:shipping_address] do
nil ->
order
addr ->
{:ok, order} = Orders.update_order(order, %{shipping_address: addr})
order
end
case attrs[:payment_status] do
"paid" ->
{:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}")
@@ -39,4 +52,59 @@ defmodule SimpleshopTheme.OrdersFixtures do
order
end
end
@doc """
Creates a paid order with real product variants in the DB,
so enrich_items/1 can look up provider IDs. Also sets a shipping
address matching Stripe's format.
Returns `{order, variant, product, conn}`.
"""
def paid_order_with_products_fixture(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
conn = provider_connection_fixture(%{provider_type: "printify"})
product = product_fixture(%{provider_connection: conn})
variant = product_variant_fixture(%{product: product})
shipping =
Map.get(attrs, :shipping_address, %{
"name" => "Jane Doe",
"line1" => "42 Test Street",
"line2" => nil,
"city" => "London",
"postal_code" => "SW1A 1AA",
"state" => nil,
"country" => "GB"
})
order =
order_fixture(%{
variant_id: variant.id,
payment_status: "paid",
shipping_address: shipping,
customer_email: Map.get(attrs, :customer_email, "buyer@example.com")
})
{order, variant, product, conn}
end
@doc """
Creates a submitted order (has provider_order_id set).
Returns `{order, variant, product, conn}`.
"""
def submitted_order_fixture(attrs \\ %{}) do
{order, variant, product, conn} = paid_order_with_products_fixture(attrs)
attrs = Enum.into(attrs, %{})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "submitted",
provider_order_id:
Map.get(attrs, :provider_order_id, "printify_#{System.unique_integer([:positive])}"),
submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
{order, variant, product, conn}
end
end

3
test/support/mocks.ex Normal file
View File

@@ -0,0 +1,3 @@
Mox.defmock(SimpleshopTheme.Providers.MockProvider,
for: SimpleshopTheme.Providers.Provider
)