test/shared/booking-intent.test.ts
Rama main · réplica de lectura del repositorio original.
import { assertArrayIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import * as v from "valibot";
import {
BookingIntentSchema,
type BookingItem,
} from "#shared/booking-intent.ts";
/** A minimal booking line that satisfies every schema rule; spread and override
* one field per case to probe a single boundary in isolation. Every line is
* checked the way production checks one — as the single line of a whole
* booking — so these cases exercise the real parser, not a piece of it. */
const validItem: BookingItem = { e: 1, p: 0, q: 1 };
/** The smallest booking a payment can carry. */
const validIntent = {
address: "",
date: null,
email: "buyer@example.com",
items: [validItem],
modifiers: [],
name: "Buyer",
phone: "",
special_instructions: "",
};
const oneLineBooking = (item: Record<string, unknown>) => ({
...validIntent,
items: [item],
});
const accepts = (item: Record<string, unknown>) =>
expect(v.is(BookingIntentSchema, oneLineBooking(item))).toBe(true);
const rejects = (item: Record<string, unknown>) =>
expect(v.is(BookingIntentSchema, oneLineBooking(item))).toBe(false);
describe("booking line validation", () => {
test("accepts a minimal signed line", () => {
accepts(validItem);
});
test("accepts the optional edge tag with both kinds", () => {
accepts({ ...validItem, k: "p", r: 1 });
accepts({ ...validItem, k: "g", r: 2 });
});
test("the edge tag is a pair: k and r are both present or both absent", () => {
accepts(validItem); // neither
rejects({ ...validItem, k: "p" }); // k without r
rejects({ ...validItem, r: 1 }); // r without k
});
test("the paired-edge-tag rejection carries its explanatory message", () => {
const result = v.safeParse(
BookingIntentSchema,
oneLineBooking({ ...validItem, k: "p" }),
);
expect(result.success).toBe(false);
assertArrayIncludes(result.issues?.map((issue) => issue.message) ?? [], [
"edge tag k and r must both be present or both absent",
]);
});
test("the listing id (e) must be a positive integer", () => {
accepts({ ...validItem, e: 1 });
rejects({ ...validItem, e: 0 });
rejects({ ...validItem, e: -1 });
rejects({ ...validItem, e: 1.5 });
});
test("the quantity (q) must be a non-negative integer", () => {
// A signed line may deliberately carry quantity 0 (an admin sentinel or a
// refunded/deleted-listing placeholder), preserved rather than coerced to 1.
accepts({ ...validItem, q: 1 });
accepts({ ...validItem, q: 0 });
rejects({ ...validItem, q: -1 });
rejects({ ...validItem, q: 1.5 });
});
test("the line total (p) is a signed integer of minor units", () => {
accepts({ ...validItem, p: -250 });
accepts({ ...validItem, p: 0 });
// p is unitPrice * quantity, both integer minor units — a fraction is corrupt.
rejects({ ...validItem, p: 12.5 });
rejects({ ...validItem, p: Number.POSITIVE_INFINITY });
rejects({ ...validItem, p: Number.NaN });
});
test("the edge kind (k) only accepts the two literals", () => {
accepts({ ...validItem, k: "p", r: 1 });
accepts({ ...validItem, k: "g", r: 1 });
rejects({ ...validItem, k: "x", r: 1 });
});
test("the group id (r) must be a positive integer when present", () => {
accepts({ ...validItem, k: "p", r: 1 });
rejects({ ...validItem, k: "p", r: 0 });
rejects({ ...validItem, k: "p", r: 1.5 });
});
});
describe("how many lines a booking may carry", () => {
test("accepts several valid lines", () => {
expect(
v.is(BookingIntentSchema, {
...validIntent,
items: [validItem, { ...validItem, e: 2 }],
}),
).toBe(true);
});
test("refuses a booking with no lines at all", () => {
expect(v.is(BookingIntentSchema, { ...validIntent, items: [] })).toBe(
false,
);
});
test("refuses every line when one of them is bad", () => {
expect(
v.is(BookingIntentSchema, {
...validIntent,
items: [validItem, { ...validItem, e: 0 }],
}),
).toBe(false);
});
});
const acceptsIntent = (extra: Record<string, unknown>) =>
expect(v.is(BookingIntentSchema, { ...validIntent, ...extra })).toBe(true);
const rejectsIntent = (extra: Record<string, unknown>) =>
expect(v.is(BookingIntentSchema, { ...validIntent, ...extra })).toBe(false);
describe("what a booking a payment carries may be", () => {
test("accepts the smallest booking", () => {
acceptsIntent({});
});
// Answers are filed under a listing id. A key in any other shape matches no
// listing, so those answers would be dropped without a word after paying.
for (const [name, key, allowed] of [
["a listing id", "12", true],
["a listing id with a leading zero", "012", false],
["a name instead of an id", "listing-12", false],
["nothing at all", "", false],
["a listing id with spaces around it", " 12 ", false],
] as const) {
test(`${allowed ? "accepts" : "refuses"} answers filed under ${name}`, () => {
const check = allowed ? acceptsIntent : rejectsIntent;
check({ listingAnswerIds: { [key]: [1] } });
check({ listingTextAnswerIds: { [key]: [{ q: 1, s: 1 }] } });
});
}
// A free-text answer whose stored text cannot be found is the one thing a
// booking forgives: the buyer has already paid, so that single answer is
// dropped and the rest of the order goes through.
for (const [name, stringId] of [
["was lost on the way back", undefined],
["is not an id we could have stored", "not-a-string-id"],
["counts from nothing", 0],
["is part of a number", 1.5],
] as const) {
test(`forgets a free-text answer whose stored text ${name}`, () => {
const parsed = v.parse(BookingIntentSchema, {
...validIntent,
listingTextAnswerIds: { "12": [{ q: 1, s: stringId }] },
});
expect(parsed.listingTextAnswerIds).toEqual({ "12": [{ q: 1 }] });
});
}
test("keeps a free-text answer that still knows its stored text", () => {
const parsed = v.parse(BookingIntentSchema, {
...validIntent,
listingTextAnswerIds: { "12": [{ q: 1, s: 5 }] },
});
expect(parsed.listingTextAnswerIds).toEqual({ "12": [{ q: 1, s: 5 }] });
});
test("accepts paying off a balance with the one line it owes", () => {
acceptsIntent({ balanceAttendeeId: 1 });
});
test("refuses paying off a balance with something added on top", () => {
// Settling clears the line's own amount, so an add-on would be charged for
// and then left out of the money record entirely.
rejectsIntent({ balanceAttendeeId: 1, modifiers: [{ i: 3, q: 1 }] });
});
test("refuses paying off a balance that carries a second line", () => {
// Only the balance line is settled, so a second line would be charged for
// and then neither booked nor given back.
rejectsIntent({ balanceAttendeeId: 1, items: [validItem, validItem] });
});
test("refuses a deposit nobody can read", () => {
rejectsIntent({ reservationAmount: "half of it" });
});
test("accepts a deposit written as a share of the price", () => {
acceptsIntent({ reservationAmount: "10%" });
});
});