feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds

This commit is contained in:
Chengdong Zhang
2026-09-17 12:43:22 +08:00
commit dc9fd31c5e
96 changed files with 17550 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
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 (_, _, _, sku_a) = setup_sellable(&app, &admin, "split-a", 1000, 5).await;
let (_, _, _, 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;
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);
}