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 @@