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
|
||||
Reference in New Issue
Block a user