Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and invoices flip together, so one purchase runs end to end against the backend. - cart: CartItemView carries the line's shop and the SKU's stock, so the cart keeps grouping per shop and the quantity stepper caps at real stock instead of a hard-coded 999 - contract: Shipment.items is optional and Invoice.invoice_no nullable, both matching what the API actually returns. Invoice was declared twice in types.ts and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect at all - an anonymous add-to-cart redirects to /login?redirect=..., and sign-in honours only same-origin paths - the fixed-data adapter learns the new cart fields, and its persisted state key moves to v2 because a cart saved by an older build is no longer valid - order surfaces drop their storeById lookups and keep the generic store label until the public store read arrives Verified end to end: two-shop cart grouping with live shop names, stock caps read from the API, checkout, payment, shipment, delivery confirmation and an issued invoice. Rollback re-verified with every domain on fixed data and the backend stopped. Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at the mock content that remains. OpenSpec change: openspec/changes/replace-mock-api-wave-3
345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
mod common;
|
||
|
||
use common::{
|
||
add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app,
|
||
};
|
||
use serial_test::serial;
|
||
|
||
async fn stock_of(app: &common::TestApp, sku_id: &str) -> i32 {
|
||
sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
|
||
.bind(sku_id)
|
||
.fetch_one(&app.db)
|
||
.await
|
||
.unwrap()
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn checkout_splits_orders_per_shop_and_clears_cart() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (_, shop_a, _, sku_a) = setup_sellable(&app, &admin, "split-a", 1000, 5).await;
|
||
let (_, shop_b, _, sku_b) = setup_sellable(&app, &admin, "split-b", 2000, 5).await;
|
||
let (buyer, _) = register_customer(&app, "buyer").await;
|
||
|
||
add_to_cart(&app, &buyer, &sku_a, 2).await;
|
||
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
||
|
||
// The cart view must carry the owning shop and current stock, so the
|
||
// storefront can group lines and cap quantity without the mock catalog.
|
||
let res = client()
|
||
.get(app.url("/api/cart"))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 200);
|
||
let view: serde_json::Value = res.json().await.unwrap();
|
||
let items = view["items"].as_array().unwrap();
|
||
assert_eq!(items.len(), 2);
|
||
let shop_ids: std::collections::HashSet<String> = items
|
||
.iter()
|
||
.map(|i| i["shop_id"].as_str().unwrap().to_string())
|
||
.collect();
|
||
assert_eq!(shop_ids.len(), 2, "each line reports its own shop");
|
||
assert!(shop_ids.contains(&shop_a) && shop_ids.contains(&shop_b));
|
||
for item in items {
|
||
assert!(
|
||
item["shop_name"]["en"].as_str().is_some_and(|s| !s.is_empty()),
|
||
"line must carry a bilingual shop name"
|
||
);
|
||
assert_eq!(item["stock"], 5, "stock is the SKU's, before checkout decrements it");
|
||
}
|
||
|
||
let orders = checkout(&app, &buyer).await;
|
||
assert_eq!(orders.len(), 2, "one order per shop");
|
||
let totals: Vec<i64> = orders
|
||
.iter()
|
||
.map(|o| o["total_minor"].as_i64().unwrap())
|
||
.collect();
|
||
assert!(totals.contains(&2000) && totals.contains(&2000)); // 2×1000 and 1×2000
|
||
assert!(orders.iter().all(|o| o["status"] == "pending_payment"));
|
||
|
||
// stock decremented
|
||
assert_eq!(stock_of(&app, &sku_a).await, 3);
|
||
assert_eq!(stock_of(&app, &sku_b).await, 4);
|
||
|
||
// cart cleared
|
||
let res = client()
|
||
.get(app.url("/api/cart"))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["items"]
|
||
.as_array()
|
||
.unwrap()
|
||
.len(), 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn checkout_insufficient_stock_rolls_back() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (_, _, _, sku) = setup_sellable(&app, &admin, "lowstock", 500, 1).await;
|
||
let (buyer, _) = register_customer(&app, "buyer2").await;
|
||
add_to_cart(&app, &buyer, &sku, 2).await;
|
||
|
||
let res = client()
|
||
.post(app.url("/api/orders/checkout"))
|
||
.bearer_auth(&buyer)
|
||
.json(&serde_json::json!({
|
||
"shipping_address": {
|
||
"recipient": "R", "phone": "1", "country": "US", "region": "CA",
|
||
"city": "SJ", "line1": "1 Way", "postal_code": "95131"
|
||
},
|
||
"currency": "USD"
|
||
}))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 409);
|
||
assert_eq!(stock_of(&app, &sku).await, 1, "stock unchanged");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn cancel_rules_and_stock_restore() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (_, _, _, sku) = setup_sellable(&app, &admin, "cancelme", 800, 4).await;
|
||
let (buyer, _) = register_customer(&app, "buyer3").await;
|
||
add_to_cart(&app, &buyer, &sku, 2).await;
|
||
let orders = checkout(&app, &buyer).await;
|
||
let order_id = orders[0]["id"].as_str().unwrap();
|
||
assert_eq!(stock_of(&app, &sku).await, 2);
|
||
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 200);
|
||
assert_eq!(stock_of(&app, &sku).await, 4, "stock restored");
|
||
|
||
// paid orders cannot be cancelled
|
||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||
let orders = checkout(&app, &buyer).await;
|
||
let order_id = orders[0]["id"].as_str().unwrap();
|
||
pay(&app, &buyer, order_id).await;
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 409);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn fulfillment_flow_partial_then_complete() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (owner, _, _, sku) = setup_sellable(&app, &admin, "fulfil", 1500, 10).await;
|
||
let (buyer, _) = register_customer(&app, "buyer4").await;
|
||
add_to_cart(&app, &buyer, &sku, 3).await;
|
||
let orders = checkout(&app, &buyer).await;
|
||
let order = &orders[0];
|
||
let order_id = order["id"].as_str().unwrap();
|
||
let item_id = order["items"][0]["id"].as_str().unwrap();
|
||
pay(&app, &buyer, order_id).await;
|
||
|
||
// over-shipping the remainder is rejected
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||
.bearer_auth(&owner)
|
||
.json(&serde_json::json!({
|
||
"carrier": "UPS", "tracking_no": "T1",
|
||
"items": [{ "order_item_id": item_id, "qty": 4 }]
|
||
}))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 400);
|
||
|
||
// partial shipment of 2 → order fulfilling
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||
.bearer_auth(&owner)
|
||
.json(&serde_json::json!({
|
||
"carrier": "UPS", "tracking_no": "T1",
|
||
"items": [{ "order_item_id": item_id, "qty": 2 }]
|
||
}))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 201);
|
||
let shipment1: serde_json::Value = res.json().await.unwrap();
|
||
let shipment1_id = shipment1["id"].as_str().unwrap().to_string();
|
||
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shop/shipments/{shipment1_id}/ship")))
|
||
.bearer_auth(&owner)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 200);
|
||
let res = client()
|
||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "fulfilling");
|
||
|
||
// second shipment covers remainder → shipped after mark
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||
.bearer_auth(&owner)
|
||
.json(&serde_json::json!({
|
||
"carrier": "UPS", "tracking_no": "T2",
|
||
"items": [{ "order_item_id": item_id, "qty": 1 }]
|
||
}))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
let shipment2_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string();
|
||
client()
|
||
.post(app.url(&format!("/api/shop/shipments/{shipment2_id}/ship")))
|
||
.bearer_auth(&owner)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
let res = client()
|
||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "shipped");
|
||
|
||
// confirm both deliveries → completed
|
||
for sid in [&shipment1_id, &shipment2_id] {
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shipments/{sid}/confirm-delivered")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 200);
|
||
}
|
||
let res = client()
|
||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "completed");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn invoice_lifecycle() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (owner, _, _, sku) = setup_sellable(&app, &admin, "invc", 3000, 2).await;
|
||
let (buyer, _) = register_customer(&app, "buyer5").await;
|
||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||
let orders = checkout(&app, &buyer).await;
|
||
let order_id = orders[0]["id"].as_str().unwrap();
|
||
|
||
// pending_payment orders cannot be invoiced
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||
.bearer_auth(&buyer)
|
||
.json(&serde_json::json!({ "title": "Me", "kind": "personal" }))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 400);
|
||
|
||
pay(&app, &buyer, order_id).await;
|
||
|
||
// company invoice requires tax_no
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||
.bearer_auth(&buyer)
|
||
.json(&serde_json::json!({ "title": "ACME", "kind": "company" }))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 400);
|
||
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||
.bearer_auth(&buyer)
|
||
.json(&serde_json::json!({ "title": "ACME", "tax_no": "US-123", "kind": "company" }))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 201);
|
||
let invoice: serde_json::Value = res.json().await.unwrap();
|
||
assert_eq!(invoice["status"], "requested");
|
||
assert_eq!(invoice["amount_minor"], 3000);
|
||
|
||
// duplicate rejected
|
||
let res = client()
|
||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||
.bearer_auth(&buyer)
|
||
.json(&serde_json::json!({ "title": "Again", "kind": "personal" }))
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 409);
|
||
|
||
// shop issues it
|
||
let invoice_id = invoice["id"].as_str().unwrap();
|
||
let res = client()
|
||
.post(app.url(&format!("/api/shop/invoices/{invoice_id}/issue")))
|
||
.bearer_auth(&owner)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 200);
|
||
let issued: serde_json::Value = res.json().await.unwrap();
|
||
assert_eq!(issued["status"], "issued");
|
||
assert!(issued["invoice_no"].as_str().unwrap().starts_with("INV"));
|
||
|
||
// customer sees issued invoice
|
||
let res = client()
|
||
.get(app.url("/api/invoices"))
|
||
.bearer_auth(&buyer)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
let invoices: serde_json::Value = res.json().await.unwrap();
|
||
assert_eq!(invoices[0]["status"], "issued");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn order_ownership_enforced() {
|
||
let app = spawn_app().await;
|
||
let admin = login_admin(&app).await;
|
||
let (_, _, _, sku) = setup_sellable(&app, &admin, "ownerchk", 100, 1).await;
|
||
let (buyer, _) = register_customer(&app, "buyer6").await;
|
||
let (other, _) = register_customer(&app, "buyer7").await;
|
||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||
let orders = checkout(&app, &buyer).await;
|
||
let order_id = orders[0]["id"].as_str().unwrap();
|
||
|
||
let res = client()
|
||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||
.bearer_auth(&other)
|
||
.send()
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), 404);
|
||
}
|