From e1a0a5dbdb58b079af61e3a251c02253852a27d3 Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Thu, 17 Sep 2026 16:33:22 +0000 Subject: [PATCH] feat(mall): run the transaction chain against the live API 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 --- apps/api/src/cart.rs | 14 ++++- apps/api/tests/orders.rs | 30 ++++++++- apps/mall/mock/api.ts | 10 ++- apps/mall/nuxt.config.ts | 4 +- apps/mall/pages/cart.vue | 33 +++++----- apps/mall/pages/checkout/index.vue | 31 +++++----- apps/mall/pages/checkout/pay.vue | 9 +-- apps/mall/pages/goods/[id].vue | 12 +++- apps/mall/pages/login.vue | 13 +++- apps/mall/pages/user/invoices.vue | 2 +- apps/mall/pages/user/orders/index.vue | 9 +-- docs/TBD-migrate-wave.md | 40 ++++++------ .../replace-mock-api-wave-3/.openspec.yaml | 2 + .../changes/replace-mock-api-wave-3/design.md | 61 +++++++++++++++++++ .../replace-mock-api-wave-3/proposal.md | 33 ++++++++++ .../specs/cart/spec.md | 18 ++++++ .../specs/frontend-mall/spec.md | 25 ++++++++ .../changes/replace-mock-api-wave-3/tasks.md | 34 +++++++++++ packages/shared/src/types.ts | 26 +++----- 19 files changed, 314 insertions(+), 92 deletions(-) create mode 100644 openspec/changes/replace-mock-api-wave-3/.openspec.yaml create mode 100644 openspec/changes/replace-mock-api-wave-3/design.md create mode 100644 openspec/changes/replace-mock-api-wave-3/proposal.md create mode 100644 openspec/changes/replace-mock-api-wave-3/specs/cart/spec.md create mode 100644 openspec/changes/replace-mock-api-wave-3/specs/frontend-mall/spec.md create mode 100644 openspec/changes/replace-mock-api-wave-3/tasks.md diff --git a/apps/api/src/cart.rs b/apps/api/src/cart.rs index 7e9c89a..a52f37f 100644 --- a/apps/api/src/cart.rs +++ b/apps/api/src/cart.rs @@ -50,6 +50,11 @@ pub struct CartItemView { pub unit_price_minor: i64, pub currency: String, pub qty: i32, + /// Owning shop, so the storefront can group lines without the mock catalog. + pub shop_id: Uuid, + pub shop_name: serde_json::Value, + /// Current SKU stock; advisory for the quantity stepper. Checkout is authoritative. + pub stock: i32, } #[derive(Debug, Serialize)] @@ -67,7 +72,8 @@ pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult { let sku_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); let rows = sqlx::query_as::<_, CartRow>( "SELECT s.id AS sku_id, p.id AS product_id, p.name AS product_name, s.sku_code, - (p.images ->> 0) AS image, s.price_minor, s.currency + (p.images ->> 0) AS image, s.price_minor, s.currency, s.stock, + p.shop_id, sh.name AS shop_name FROM skus s JOIN products p ON p.id = s.product_id JOIN shops sh ON sh.id = p.shop_id @@ -92,6 +98,9 @@ pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult { unit_price_minor: r.price_minor, currency: r.currency, qty: *qty, + shop_id: r.shop_id, + shop_name: r.shop_name, + stock: r.stock, }) }) .collect(); @@ -107,4 +116,7 @@ struct CartRow { image: Option, price_minor: i64, currency: String, + stock: i32, + shop_id: Uuid, + shop_name: serde_json::Value, } diff --git a/apps/api/tests/orders.rs b/apps/api/tests/orders.rs index 4414bea..42911db 100644 --- a/apps/api/tests/orders.rs +++ b/apps/api/tests/orders.rs @@ -18,13 +18,39 @@ async fn stock_of(app: &common::TestApp, sku_id: &str) -> i32 { 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 (_, 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 = 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 = orders diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index a619055..df50a7b 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -20,12 +20,14 @@ import { BASE_CURRENCY, MOCK_CATEGORIES, MOCK_CURRENCIES, + MOCK_STORES, MOCK_USER, defaultAddress, mockConvertMinor, productById, searchMockProducts, seedOrders, + storeById, } from "./data"; interface MockState { @@ -38,7 +40,9 @@ interface MockState { invoiceSeq: number; } -const STORAGE_KEY = "vmall.mock.state.v1"; +// v2: cart lines gained shop_id/shop_name/stock, so state saved by an older +// build is no longer a valid CartItem[]. +const STORAGE_KEY = "vmall.mock.state.v2"; type PersistedState = Pick; @@ -169,6 +173,10 @@ export function createMockApi(): ApiClient { unit_price_minor: sku.price_minor, currency: sku.currency, qty, + // The live cart view carries these too; see replace-mock-api-wave-3. + shop_id: entry.product.shop_id, + shop_name: storeById(entry.product.shop_id)?.name ?? MOCK_STORES[0].name, + stock: sku.stock, }); } persist(); diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 24a598c..f13e5d8 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -8,8 +8,8 @@ export default defineNuxtConfig({ apiBase: "http://localhost:8080/api", // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. - // See openspec/changes/replace-mock-api-wave-1/design.md and wave 2. - liveDomains: ["catalog", "currency", "auth"], + // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. + liveDomains: ["catalog", "currency", "auth", "cart", "orders", "shipments", "invoices"], appName: "mall", }, }, diff --git a/apps/mall/pages/cart.vue b/apps/mall/pages/cart.vue index e08f433..a2a0d4d 100644 --- a/apps/mall/pages/cart.vue +++ b/apps/mall/pages/cart.vue @@ -1,11 +1,10 @@