Skip to content
Merged
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");
});
267 changes: 267 additions & 0 deletions rest/nodejs/test/fulfillment.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildApp>
): Promise<Checkout> {
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<typeof buildApp>,
checkout: Checkout,
optionId: string
): Promise<Response> {
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");
});
Loading
Loading