diff --git a/rest/nodejs/test/discount.test.ts b/rest/nodejs/test/discount.test.ts index 2b8c7969..b9bc8ca0 100644 --- a/rest/nodejs/test/discount.test.ts +++ b/rest/nodejs/test/discount.test.ts @@ -59,3 +59,76 @@ test("applied[].amount stays the positive magnitude", () => { "applied[].amount is the positive magnitude, not the signed receipt effect" ); }); + +function checkoutWithCodes(codes: string[]) { + return { + id: "chk_test", + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + discounts: { codes }, + totals: [], + } as never; +} + +// Per discount.md a code is matched case-insensitively by the business. +test("a discount code matches case-insensitively", () => { + const checkout = checkoutWithCodes(["10off"]); + new CheckoutService()["recalculateTotals"](checkout); + + const applied = (checkout as unknown as { discounts: { applied: unknown[] } }) + .discounts.applied; + assert.ok( + applied.length > 0, + "a lowercase '10off' must still match the '10OFF' code" + ); +}); + +// An empty codes[] clears all discounts (discount.md). +test("an empty codes[] removes all discounts", () => { + const checkout = checkoutWithCodes([]); + new CheckoutService()["recalculateTotals"](checkout); + + const c = checkout as unknown as { + discounts: { applied: unknown[] }; + totals: Array<{ type: string }>; + }; + assert.equal( + c.discounts.applied.length, + 0, + "an empty code set must produce no applied discounts" + ); + assert.ok( + !c.totals.some((t) => t.type === "discount"), + "an empty code set must leave no discount entry in totals[]" + ); +}); + +// An applied discount's allocations must reconcile to its amount — a +// cross-field invariant the schema cannot express. +test("an applied discount's allocations sum to its amount", () => { + const checkout = checkoutWithCodes(["10OFF"]); + new CheckoutService()["recalculateTotals"](checkout); + + const applied = ( + checkout as unknown as { + discounts: { + applied: Array<{ + amount: number; + allocations?: Array<{ amount: number }>; + }>; + }; + } + ).discounts.applied; + let checked = 0; + for (const a of applied) { + const allocs = a.allocations ?? []; + if (allocs.length === 0) continue; + checked += 1; + assert.equal( + allocs.reduce((sum, x) => sum + x.amount, 0), + a.amount, + "allocations must sum to the applied discount amount" + ); + } + assert.ok(checked > 0, "expected at least one discount carrying allocations"); +}); diff --git a/rest/nodejs/test/idempotency.test.ts b/rest/nodejs/test/idempotency.test.ts new file mode 100644 index 00000000..1afb75c6 --- /dev/null +++ b/rest/nodejs/test/idempotency.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; +import { prettyValidation } from "../src/utils/validation"; + +function buildApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 100); +}); + +const BODY = { + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, +}; + +async function post( + app: ReturnType, + body: unknown, + key?: string +) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (key) headers["Idempotency-Key"] = key; + return app.request("/checkout-sessions", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +test("same idempotency key + same body replays the first response", async () => { + const app = buildApp(); + const first = (await post(app, BODY, "key-a").then((r) => r.json())) as { + id: string; + }; + const second = await post(app, BODY, "key-a"); + assert.equal(second.status, 201); + const replayed = (await second.json()) as { id: string }; + assert.equal( + replayed.id, + first.id, + "a replayed idempotency key must return the original checkout, not a new one" + ); +}); + +test("same idempotency key + different body is a 409 conflict", async () => { + const app = buildApp(); + await post(app, BODY, "key-b"); + const conflicting = await post( + app, + { ...BODY, line_items: [{ item: { id: "bouquet_roses" }, quantity: 2 }] }, + "key-b" + ); + assert.equal(conflicting.status, 409); +}); + +test("no idempotency key creates independent checkouts", async () => { + const app = buildApp(); + const a = (await post(app, BODY).then((r) => r.json())) as { id: string }; + const b = (await post(app, BODY).then((r) => r.json())) as { id: string }; + assert.notEqual(a.id, b.id, "distinct requests must get distinct ids"); +}); diff --git a/rest/nodejs/test/lifecycle.test.ts b/rest/nodejs/test/lifecycle.test.ts new file mode 100644 index 00000000..3f8e6371 --- /dev/null +++ b/rest/nodejs/test/lifecycle.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService, zCompleteCheckoutRequest } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +// A minimal app wired with just the checkout routes (no request logging +// middleware, which needs the node-server request context), so the lifecycle +// can be exercised end to end through app.request() — the same convention as +// discovery.test.ts. +function buildApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + // The validation hook logs via c.var.logger; provide one so the routes can + // run without the production pino middleware (which needs a node-server ctx). + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + app.get( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + svc.getCheckout + ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + svc.updateCheckout + ); + app.post( + "/checkout-sessions/:id/complete", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", zCompleteCheckoutRequest, prettyValidation), + svc.completeCheckout + ); + app.post( + "/checkout-sessions/:id/cancel", + zValidator("param", IdParamSchema, prettyValidation), + svc.cancelCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + const db = getProductsDb(); + db.prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ).run("bouquet_roses", "Red Rose", 3500, ""); + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 100); +}); + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const CREATE_BODY = { + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, +}; +function paymentWith(token: string) { + return { + payment_data: { + id: "pi_1", + handler_id: "mock_payment_handler", + type: "card", + brand: "visa", + last_digits: "4242", + // A non-"card" credential type routes to the mock token handler, so the + // token drives the outcome (success_token / fail_token / fraud_token). + credential: { type: "network_token", token }, + }, + }; +} +const SUCCESS_PAYMENT = paymentWith("success_token"); + +async function create(app: ReturnType) { + const res = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(CREATE_BODY), + }); + return res; +} + +async function complete( + app: ReturnType, + id: string, + body: unknown = SUCCESS_PAYMENT +) { + return app.request(`/checkout-sessions/${id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); +} + +test("create returns 201 with an id and an incomplete status", async () => { + const app = buildApp(); + const res = await create(app); + assert.equal(res.status, 201); + const body = (await res.json()) as { id: string; status: string }; + assert.ok(body.id, "checkout must carry a server-assigned id"); + assert.equal(body.status, "incomplete"); +}); + +test("a created checkout is retrievable by id", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await app.request(`/checkout-sessions/${created.id}`); + assert.equal(res.status, 200); + const got = (await res.json()) as { id: string }; + assert.equal(got.id, created.id); +}); + +test("complete moves the checkout to an order", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await complete(app, created.id); + assert.equal(res.status, 200); + const body = (await res.json()) as { status: string; order_id?: string }; + assert.equal(body.status, "completed"); + assert.ok(body.order_id, "completion must assign an order_id"); +}); + +test("completing an already-completed checkout is rejected (409)", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + await complete(app, created.id); + const again = await complete(app, created.id); + assert.equal(again.status, 409); +}); + +test("a failing payment token surfaces a 402", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await complete(app, created.id, paymentWith("fail_token")); + assert.equal(res.status, 402); +}); + +test("cancel moves the checkout to canceled", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await app.request(`/checkout-sessions/${created.id}/cancel`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({}), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { status: string }; + assert.equal(body.status, "canceled"); +}); + +test("a canceled checkout cannot be completed (409)", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + await app.request(`/checkout-sessions/${created.id}/cancel`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({}), + }); + const res = await complete(app, created.id); + assert.equal(res.status, 409); +}); diff --git a/rest/nodejs/test/validation_flow.test.ts b/rest/nodejs/test/validation_flow.test.ts new file mode 100644 index 00000000..b07fd9f3 --- /dev/null +++ b/rest/nodejs/test/validation_flow.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; +import { prettyValidation } from "../src/utils/validation"; + +function buildApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + // Two units in stock — enough to test the in-stock path and to exceed. + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 2); +}); + +async function create(app: ReturnType, lineItems: unknown) { + return app.request("/checkout-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currency: "USD", + line_items: lineItems, + payment: {}, + }), + }); +} + +test("an unknown product id is rejected", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "no_such_product" }, quantity: 1 }, + ]); + assert.equal(res.status, 400); + const body = (await res.json()) as { detail: string }; + assert.match(body.detail, /not found/i); +}); + +test("ordering more than the available stock is rejected", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 5 }, + ]); + assert.equal(res.status, 400); + const body = (await res.json()) as { detail: string }; + assert.match(body.detail, /stock/i); +}); + +test("ordering within the available stock succeeds", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 2 }, + ]); + assert.equal(res.status, 201); +});