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/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts new file mode 100644 index 00000000..7b05c7ae --- /dev/null +++ b/rest/nodejs/test/fulfillment.test.ts @@ -0,0 +1,267 @@ +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 { + CheckoutCompleteRequestSchema, + 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 fulfillment +// flow can be exercised end to end through app.request() — the same convention +// as lifecycle.test.ts. +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 + ); + 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", CheckoutCompleteRequestSchema, prettyValidation), + svc.completeCheckout + ); + 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 JSON_HEADERS = { "Content-Type": "application/json" }; + +// The mock merchant only surfaces shipping destinations (and therefore shipping +// options) for the known customer john.doe@example.com; a shipping method that +// selects one of those seeded destinations is what makes the merchant quote +// options. See constructFulfillmentResponse / recalculateTotals in checkout.ts. +const KNOWN_BUYER = { email: "john.doe@example.com" }; +const LINE_ITEMS = [{ item: { id: "bouquet_roses" }, quantity: 1 }]; + +const SUCCESS_PAYMENT = { + payment: { + instruments: [ + { + 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). + credential: { type: "network_token", token: "success_token" }, + }, + ], + }, +}; + +type Total = { type: string; amount: number }; +type Option = { id: string; totals: Total[] }; +type Group = { + id: string; + line_item_ids: string[]; + selected_option_id?: string; + options?: Option[]; +}; +type Method = { groups?: Group[] }; +type Checkout = { + id: string; + status: string; + totals: Total[]; + order?: { id: string }; + fulfillment?: { methods?: Method[] }; +}; + +// The "total" amount the option contributes to the receipt. +function optionTotal(option: Option): number { + return option.totals.find((t) => t.type === "total")?.amount ?? 0; +} +function totalOfType(totals: Total[], type: string): Total | undefined { + return totals.find((t) => t.type === type); +} + +// Create a checkout that has selected a shipping destination (which is what +// prompts the merchant to quote fulfillment options), returning the parsed body. +async function createWithDestination( + app: ReturnType +): Promise { + const res = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: LINE_ITEMS, + payment: {}, + buyer: KNOWN_BUYER, + fulfillment: { + methods: [{ type: "shipping", selected_destination_id: "addr_1" }], + }, + }), + }); + assert.equal(res.status, 201); + return (await res.json()) as Checkout; +} + +// Select a fulfillment option on an existing method group via an update, +// echoing the server-assigned group id and line_item_ids as the update schema +// requires, returning the parsed body. +async function selectOption( + app: ReturnType, + checkout: Checkout, + optionId: string +): Promise { + const group = checkout.fulfillment!.methods![0].groups![0]; + return app.request(`/checkout-sessions/${checkout.id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: [{ id: "line_1", ...LINE_ITEMS[0] }], + buyer: KNOWN_BUYER, + fulfillment: { + methods: [ + { + type: "shipping", + selected_destination_id: "addr_1", + groups: [ + { + id: group.id, + line_item_ids: group.line_item_ids, + selected_option_id: optionId, + }, + ], + }, + ], + }, + }), + }); +} + +test("a created checkout with a selected destination is quoted fulfillment options", async () => { + const app = buildApp(); + const created = await createWithDestination(app); + + const options = created.fulfillment?.methods?.[0]?.groups?.[0]?.options; + assert.ok( + Array.isArray(options) && options.length > 0, + "a destination-bearing checkout must expose at least one fulfillment option" + ); + assert.ok( + options.every((o) => typeof o.id === "string" && o.id.length > 0), + "every quoted fulfillment option must carry an id to select it by" + ); + assert.ok( + options.every((o) => totalOfType(o.totals, "total") !== undefined), + "every quoted fulfillment option must carry a receipt total" + ); +}); + +test("selecting a fulfillment option is reflected and adds its cost to the receipt", async () => { + const app = buildApp(); + const created = await createWithDestination(app); + + const options = created.fulfillment!.methods![0].groups![0].options!; + // Pick a priced option so the assertion that the selection moves the receipt + // is non-vacuous (the mock quotes a paid express option alongside a free one). + const priced = options.find((o) => optionTotal(o) > 0); + assert.ok(priced, "the merchant must quote at least one priced option"); + + const subtotal = totalOfType(created.totals, "subtotal")!.amount; + + const res = await selectOption(app, created, priced.id); + assert.equal(res.status, 200); + const updated = (await res.json()) as Checkout; + + assert.equal( + updated.fulfillment?.methods?.[0]?.groups?.[0]?.selected_option_id, + priced.id, + "the chosen option must be marked selected on the group" + ); + + const fulfillmentTotal = totalOfType(updated.totals, "fulfillment"); + assert.ok( + fulfillmentTotal, + "selecting an option must surface a fulfillment totals[] entry" + ); + assert.equal( + fulfillmentTotal.amount, + optionTotal(priced), + "the fulfillment totals[] entry must equal the selected option's cost" + ); + assert.equal( + subtotal + fulfillmentTotal.amount, + totalOfType(updated.totals, "total")!.amount, + "subtotal plus the selected fulfillment cost must equal the total" + ); +}); + +test("completing a checkout with no fulfillment selected is rejected", async () => { + const app = buildApp(); + // A plain checkout carries no fulfillment selection at all. + const created = (await ( + await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: LINE_ITEMS, + payment: {}, + }), + }) + ).json()) as Checkout; + + const res = await app.request(`/checkout-sessions/${created.id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(SUCCESS_PAYMENT), + }); + assert.equal(res.status, 400); + const body = (await res.json()) as { detail: string }; + assert.match(body.detail, /fulfillment/i); +}); + +test("a checkout with fulfillment fully selected can be completed", async () => { + const app = buildApp(); + const created = await createWithDestination(app); + const options = created.fulfillment!.methods![0].groups![0].options!; + + const selectRes = await selectOption(app, created, options[0].id); + assert.equal(selectRes.status, 200); + + const res = await app.request(`/checkout-sessions/${created.id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(SUCCESS_PAYMENT), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as Checkout; + assert.equal(body.status, "completed"); + assert.ok(body.order?.id, "completion must assign an order id"); +}); 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..7148b4c7 --- /dev/null +++ b/rest/nodejs/test/lifecycle.test.ts @@ -0,0 +1,249 @@ +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 { + CheckoutCompleteRequestSchema, + 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", CheckoutCompleteRequestSchema, 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: { + instruments: [ + { + 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), + }); +} + +// The merchant refuses to complete a checkout until a fulfillment destination +// and option are selected, so drive a checkout through that selection (create +// with a known-customer shipping destination, then choose the quoted option) +// and return its id ready to complete. See fulfillment.test.ts for the +// dedicated coverage of that selection flow. +async function createReadyToComplete( + app: ReturnType +): Promise { + const created = (await ( + await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, + buyer: { email: "john.doe@example.com" }, + fulfillment: { + methods: [{ type: "shipping", selected_destination_id: "addr_1" }], + }, + }), + }) + ).json()) as { + id: string; + fulfillment: { + methods: { + groups: { + id: string; + line_item_ids: string[]; + options: { id: string }[]; + }[]; + }[]; + }; + }; + + const group = created.fulfillment.methods[0].groups[0]; + await app.request(`/checkout-sessions/${created.id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: [ + { id: "line_1", item: { id: "bouquet_roses" }, quantity: 1 }, + ], + buyer: { email: "john.doe@example.com" }, + fulfillment: { + methods: [ + { + type: "shipping", + selected_destination_id: "addr_1", + groups: [ + { + id: group.id, + line_item_ids: group.line_item_ids, + selected_option_id: group.options[0].id, + }, + ], + }, + ], + }, + }), + }); + return created.id; +} + +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 id = await createReadyToComplete(app); + const res = await complete(app, 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 id = await createReadyToComplete(app); + await complete(app, id); + const again = await complete(app, id); + assert.equal(again.status, 409); +}); + +test("a failing payment token surfaces a 402", async () => { + const app = buildApp(); + const id = await createReadyToComplete(app); + const res = await complete(app, 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 id = await createReadyToComplete(app); + await app.request(`/checkout-sessions/${id}/cancel`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({}), + }); + const res = await complete(app, 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); +});