Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions rest/nodejs/test/discount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
92 changes: 92 additions & 0 deletions rest/nodejs/test/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildApp>,
body: unknown,
key?: string
) {
const headers: Record<string, string> = {
"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");
});
178 changes: 178 additions & 0 deletions rest/nodejs/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildApp>) {
const res = await app.request("/checkout-sessions", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(CREATE_BODY),
});
return res;
}

async function complete(
app: ReturnType<typeof buildApp>,
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);
});
Loading
Loading