|
| 1 | +import { |
| 2 | + CountingIdGen, |
| 3 | + FakeEmailSender, |
| 4 | + FixedClock, |
| 5 | + InMemoryAddressStore, |
| 6 | + InMemoryCartStore, |
| 7 | + InMemoryCouponStore, |
| 8 | + InMemoryCredentialVerifier, |
| 9 | + InMemoryCustomerStore, |
| 10 | + InMemoryEntitlementStore, |
| 11 | + InMemoryInventoryStore, |
| 12 | + InMemoryOrderNotesStore, |
| 13 | + InMemoryOrderStore, |
| 14 | + InMemoryPaymentEventStore, |
| 15 | + InMemoryProductCommerceStore, |
| 16 | + InMemoryReportingStore, |
| 17 | + InMemorySessionStore, |
| 18 | + InMemorySettingsStore, |
| 19 | + InMemoryShippingRulesStore, |
| 20 | + InMemoryTaxRulesStore, |
| 21 | +} from "@urumi/domain/testing"; |
| 22 | +import { StripePaymentGateway } from "@urumi/payments-stripe"; |
| 23 | +import type { Hono } from "hono"; |
| 24 | +import { describe, expect, test, vi } from "vitest"; |
| 25 | +import { createApp } from "../src/app.js"; |
| 26 | +import { CART_LINE_MAX_QTY, RESERVE_MAX_QTY } from "../src/schemas.js"; |
| 27 | + |
| 28 | +// PR C — wire-level qty caps (service-hardening plan §4). Zod-only bounds on |
| 29 | +// the three qty sites that previously accepted any positive safe integer |
| 30 | +// (including 1e9): `addLineBody`/`patchLineBody` get a shopper-facing |
| 31 | +// CART_LINE_MAX_QTY (10,000); `reserveBody` gets the same 1,000,000,000 cap as |
| 32 | +// the admin `stockMovementBody` precedent (the raw inventory primitive, a |
| 33 | +// machine caller). The cap is a wire bound only — an over-cap request never |
| 34 | +// reaches the store, so no reservation row (successful or failed) is minted. |
| 35 | +// This does NOT fix junk-row/request-count amplification (see the linked |
| 36 | +// follow-up issue); it only closes the "qty: 1e9 is a valid wire request" gap. |
| 37 | +interface TestApp { |
| 38 | + app: Hono; |
| 39 | + inventory: InMemoryInventoryStore; |
| 40 | +} |
| 41 | + |
| 42 | +function makeApp(): TestApp { |
| 43 | + const clock = new FixedClock(new Date("2026-07-26T00:00:00.000Z")); |
| 44 | + const inventory = new InMemoryInventoryStore({ |
| 45 | + idGen: new CountingIdGen("res"), |
| 46 | + clock, |
| 47 | + seed: [{ sku: "SKU-1", onHand: 20_000 }], |
| 48 | + }); |
| 49 | + const cartStore = new InMemoryCartStore({ |
| 50 | + idGen: new CountingIdGen("cart"), |
| 51 | + reservationState: (id) => { |
| 52 | + try { |
| 53 | + return inventory.reservationState(id); |
| 54 | + } catch { |
| 55 | + return undefined; |
| 56 | + } |
| 57 | + }, |
| 58 | + releaseHold: (id) => { |
| 59 | + void inventory.release(id); |
| 60 | + }, |
| 61 | + }); |
| 62 | + const productCommerce = new InMemoryProductCommerceStore({ |
| 63 | + clock, |
| 64 | + inventoryOnHand: (s) => inventory.onHand(s), |
| 65 | + }); |
| 66 | + const idGen = new CountingIdGen("id"); |
| 67 | + const customerStore = new InMemoryCustomerStore({ idGen, clock }); |
| 68 | + const app = createApp({ |
| 69 | + store: inventory, |
| 70 | + productCommerce, |
| 71 | + cartStore, |
| 72 | + orderStore: new InMemoryOrderStore({ idGen, clock }), |
| 73 | + orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), |
| 74 | + entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), |
| 75 | + paymentEventStore: new InMemoryPaymentEventStore(), |
| 76 | + shippingRules: new InMemoryShippingRulesStore(), |
| 77 | + taxRules: new InMemoryTaxRulesStore(), |
| 78 | + couponStore: new InMemoryCouponStore({ idGen, clock }), |
| 79 | + reportingStore: new InMemoryReportingStore(), |
| 80 | + settingsStore: new InMemorySettingsStore(), |
| 81 | + customerStore, |
| 82 | + addressStore: new InMemoryAddressStore({ idGen, clock }), |
| 83 | + sessionStore: new InMemorySessionStore({ idGen, clock }), |
| 84 | + credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), |
| 85 | + emailSender: new FakeEmailSender(), |
| 86 | + idGen, |
| 87 | + gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, |
| 88 | + clock, |
| 89 | + }); |
| 90 | + return { app, inventory }; |
| 91 | +} |
| 92 | + |
| 93 | +const json = { "content-type": "application/json" }; |
| 94 | + |
| 95 | +async function newCart(app: Hono): Promise<string> { |
| 96 | + const res = await app.request("/carts", { method: "POST", headers: json, body: "{}" }); |
| 97 | + expect(res.status).toBe(201); |
| 98 | + const body = (await res.json()) as { cartId: string }; |
| 99 | + return body.cartId; |
| 100 | +} |
| 101 | + |
| 102 | +describe("PR C — cart line qty cap (CART_LINE_MAX_QTY)", () => { |
| 103 | + test("POST /carts/:id/lines over cap is 400 with a structured error body", async () => { |
| 104 | + const { app } = makeApp(); |
| 105 | + const cartId = await newCart(app); |
| 106 | + |
| 107 | + const res = await app.request(`/carts/${cartId}/lines`, { |
| 108 | + method: "POST", |
| 109 | + headers: { ...json, "Idempotency-Key": "k1" }, |
| 110 | + body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }), |
| 111 | + }); |
| 112 | + |
| 113 | + expect(res.status).toBe(400); |
| 114 | + const body = (await res.json()) as { error: string; issues: unknown }; |
| 115 | + expect(body.error).toBe("invalid request body"); |
| 116 | + expect(body.issues).toBeDefined(); |
| 117 | + }); |
| 118 | + |
| 119 | + test("...and the store is never touched: reserve() not called, onHand unchanged", async () => { |
| 120 | + const { app, inventory } = makeApp(); |
| 121 | + const cartId = await newCart(app); |
| 122 | + const reserveSpy = vi.spyOn(inventory, "reserve"); |
| 123 | + const before = inventory.onHand("SKU-1"); |
| 124 | + |
| 125 | + const res = await app.request(`/carts/${cartId}/lines`, { |
| 126 | + method: "POST", |
| 127 | + headers: { ...json, "Idempotency-Key": "k1b" }, |
| 128 | + body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }), |
| 129 | + }); |
| 130 | + |
| 131 | + expect(res.status).toBe(400); |
| 132 | + expect(reserveSpy).not.toHaveBeenCalled(); |
| 133 | + expect(inventory.onHand("SKU-1")).toBe(before); |
| 134 | + }); |
| 135 | + |
| 136 | + test("POST /carts/:id/lines at the CART_LINE_MAX_QTY boundary succeeds (200)", async () => { |
| 137 | + const { app } = makeApp(); |
| 138 | + const cartId = await newCart(app); |
| 139 | + |
| 140 | + const res = await app.request(`/carts/${cartId}/lines`, { |
| 141 | + method: "POST", |
| 142 | + headers: { ...json, "Idempotency-Key": "k2" }, |
| 143 | + body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY }), |
| 144 | + }); |
| 145 | + |
| 146 | + expect(res.status).toBe(200); |
| 147 | + const body = (await res.json()) as { ok: boolean }; |
| 148 | + expect(body.ok).toBe(true); |
| 149 | + }); |
| 150 | + |
| 151 | + async function existingLineId(app: Hono, cartId: string, key: string): Promise<string> { |
| 152 | + const addRes = await app.request(`/carts/${cartId}/lines`, { |
| 153 | + method: "POST", |
| 154 | + headers: { ...json, "Idempotency-Key": key }, |
| 155 | + body: JSON.stringify({ sku: "SKU-1", qty: 1 }), |
| 156 | + }); |
| 157 | + const addBody = (await addRes.json()) as { line: { lineId: string } }; |
| 158 | + return addBody.line.lineId; |
| 159 | + } |
| 160 | + |
| 161 | + test("PATCH /carts/:id/lines/:lineId over cap is 400", async () => { |
| 162 | + const { app } = makeApp(); |
| 163 | + const cartId = await newCart(app); |
| 164 | + const lineId = await existingLineId(app, cartId, "k3"); |
| 165 | + |
| 166 | + const overCap = await app.request(`/carts/${cartId}/lines/${lineId}`, { |
| 167 | + method: "PATCH", |
| 168 | + headers: { ...json, "Idempotency-Key": "k4" }, |
| 169 | + body: JSON.stringify({ qty: CART_LINE_MAX_QTY + 1 }), |
| 170 | + }); |
| 171 | + expect(overCap.status).toBe(400); |
| 172 | + }); |
| 173 | + |
| 174 | + test("PATCH /carts/:id/lines/:lineId at the cap is 200", async () => { |
| 175 | + const { app } = makeApp(); |
| 176 | + const cartId = await newCart(app); |
| 177 | + const lineId = await existingLineId(app, cartId, "k3b"); |
| 178 | + |
| 179 | + const atCap = await app.request(`/carts/${cartId}/lines/${lineId}`, { |
| 180 | + method: "PATCH", |
| 181 | + headers: { ...json, "Idempotency-Key": "k5" }, |
| 182 | + body: JSON.stringify({ qty: CART_LINE_MAX_QTY }), |
| 183 | + }); |
| 184 | + expect(atCap.status).toBe(200); |
| 185 | + }); |
| 186 | + |
| 187 | + test("the exact QA repro — qty: 1e9 on a cart line — is now 400", async () => { |
| 188 | + const { app } = makeApp(); |
| 189 | + const cartId = await newCart(app); |
| 190 | + |
| 191 | + const res = await app.request(`/carts/${cartId}/lines`, { |
| 192 | + method: "POST", |
| 193 | + headers: { ...json, "Idempotency-Key": "k6" }, |
| 194 | + body: JSON.stringify({ sku: "SKU-1", qty: 1e9 }), |
| 195 | + }); |
| 196 | + |
| 197 | + expect(res.status).toBe(400); |
| 198 | + }); |
| 199 | +}); |
| 200 | + |
| 201 | +describe("PR C — POST /inventory/reserve qty cap (RESERVE_MAX_QTY, aligned with stockMovementBody)", () => { |
| 202 | + test("qty: 1_000_000_001 is 400, reserve never called", async () => { |
| 203 | + const { app, inventory } = makeApp(); |
| 204 | + const reserveSpy = vi.spyOn(inventory, "reserve"); |
| 205 | + |
| 206 | + const res = await app.request("/inventory/reserve", { |
| 207 | + method: "POST", |
| 208 | + headers: { ...json, "Idempotency-Key": "k7" }, |
| 209 | + body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY + 1 }), |
| 210 | + }); |
| 211 | + |
| 212 | + expect(res.status).toBe(400); |
| 213 | + expect(reserveSpy).not.toHaveBeenCalled(); |
| 214 | + }); |
| 215 | + |
| 216 | + test("qty: 1_000_000_000 reaches the store (200, OUT_OF_STOCK since it exceeds seeded on-hand)", async () => { |
| 217 | + const { app } = makeApp(); |
| 218 | + |
| 219 | + const res = await app.request("/inventory/reserve", { |
| 220 | + method: "POST", |
| 221 | + headers: { ...json, "Idempotency-Key": "k8" }, |
| 222 | + body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY }), |
| 223 | + }); |
| 224 | + |
| 225 | + expect(res.status).toBe(200); |
| 226 | + const body = (await res.json()) as { ok: boolean; reason?: string }; |
| 227 | + expect(body.ok).toBe(false); |
| 228 | + expect(body.reason).toBe("OUT_OF_STOCK"); |
| 229 | + }); |
| 230 | +}); |
0 commit comments