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:
@@ -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
|
||||
112
test/simpleshop_theme/orders/order_submission_worker_test.exs
Normal file
112
test/simpleshop_theme/orders/order_submission_worker_test.exs
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user