test/shared/booking.test.ts
Rama main · réplica de lectura del repositorio original.
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { getAttendeeBalanceState } from "#db/attendees/balance.ts";
import {
type BookingResult,
listingHasSpots,
processBooking,
} from "#shared/booking.ts";
import { flushAsync, stubWebhookFetch } from "#test/shared/webhook/helpers.ts";
import { withCheckoutStub } from "#test-utils/api/helpers.ts";
import { STUB_CHECKOUT_URL, stubCheckout } from "#test-utils/checkout.ts";
import { describeWithEnv } from "#test-utils/db.ts";
import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts";
import { createTestListing } from "#test-utils/db-helpers/listings.ts";
import { withRequestContext } from "#test-utils/request-context.ts";
import { setupStripe } from "#test-utils/settings.ts";
import type { Attendee, ContactInfo } from "#types";
/** Direct, function-level tests of {@link processBooking} and
* {@link listingHasSpots} — the single-listing booking core. The route layer
* (`handleBook`) maps a {@link BookingResult} to a JSON response, so these tests
* pin the discriminated-union contract the route depends on at the function
* boundary rather than going through the HTTP/router stack. */
const BASE_URL = "http://localhost";
const contact: ContactInfo = {
address: "",
email: "alice@test.com",
name: "Alice",
phone: "",
special_instructions: "",
};
/** Narrow a {@link BookingResult} to its success attendee, or null. The caller
* first asserts `result.type === "success"` (which throws on a wrong branch),
* so a null here means the branch drifted — the value assertion then fails
* loudly rather than papering over the mismatch. */
const attendeeOf = (result: BookingResult) =>
result.type === "success" ? result.attendee : null;
/** Create a listing at `unitPrice` (capacity 10) and book `quantity` units
* directly, asserting success and returning the attendee. Shared by the free,
* owed, scaled, and one-penny success-path tests. */
const bookDirect = async (
unitPrice: number,
quantity = 1,
): Promise<Attendee> => {
const listing = await createTestListing({ maxAttendees: 10, unitPrice });
const result = await processBooking(
listing,
contact,
quantity,
null,
BASE_URL,
);
expect(result.type).toBe("success");
return attendeeOf(result)!;
};
describeWithEnv("processBooking", { db: true, triggers: true }, () => {
describe("free and provider-less direct bookings", () => {
test("creates a free attendee that owes nothing", async () => {
const attendee = await bookDirect(0);
expect(attendee.remaining_balance).toBe(0);
expect(typeof attendee.ticket_token).toBe("string");
expect(attendee.ticket_token.length).toBeGreaterThan(0);
});
test("records the full listing value as owed without a provider", async () => {
const attendee = await bookDirect(1500);
expect(attendee.remaining_balance).toBe(1500);
// The owed ledger poster writes a gross `sale` leg with nothing paid, so
// the ledger-projected balance (not just the denormalized column) equals
// the gross — the invariant the poster exists to maintain.
expect(
(await getAttendeeBalanceState(attendee.id))?.remainingBalance,
).toBe(1500);
});
test("records even a one-penny owed balance in the ledger", async () => {
// The `remainingBalance > 0` gate must fire for the smallest non-zero
// balance (a £0.01 listing): any positive value posts the owed leg.
const attendee = await bookDirect(1);
expect(
(await getAttendeeBalanceState(attendee.id))?.remainingBalance,
).toBe(1);
});
test("scales the owed balance by the booked quantity", async () => {
const attendee = await bookDirect(1000, 3);
expect(attendee.remaining_balance).toBe(3000);
});
test("returns sold_out when capacity is exhausted without a provider", async () => {
// No provider configured: the provider-less path still preflights capacity
// via createAttendeeAtomic, which returns capacity_exceeded when sold out.
const listing = await createTestListing({
maxAttendees: 1,
unitPrice: 1000,
});
await createTestAttendeeDirect(listing.id, "First", "f@test.com");
const result = await processBooking(listing, contact, 1, null, BASE_URL);
expect(result).toEqual({ type: "sold_out" });
});
});
describe("paid checkout through a provider", () => {
test("returns the provider's checkout URL with a single priced item", async () => {
await setupStripe();
const listing = await createTestListing({
maxAttendees: 10,
unitPrice: 1500,
});
const { checkout, getCaptured } = stubCheckout("sess_test");
try {
const result = await processBooking(
listing,
contact,
2,
null,
BASE_URL,
);
expect(result).toEqual({
checkoutUrl: STUB_CHECKOUT_URL,
type: "checkout",
});
} finally {
checkout.restore();
}
const intent = getCaptured();
expect(intent?.date).toBe(null);
expect(intent?.items).toEqual([
{
listingId: listing.id,
name: listing.name,
quantity: 2,
slug: listing.slug,
unitPrice: 1500,
},
]);
});
test("returns sold_out and skips the provider when capacity is exhausted", async () => {
await setupStripe();
const listing = await createTestListing({
maxAttendees: 1,
unitPrice: 1500,
});
await createTestAttendeeDirect(listing.id, "First", "f@test.com");
const { calls, checkout } = stubCheckout("sess_test");
try {
const result = await processBooking(
listing,
contact,
1,
null,
BASE_URL,
);
expect(result).toEqual({ type: "sold_out" });
} finally {
checkout.restore();
}
expect(calls()).toBe(0);
});
test("returns checkout_failed with no error when the provider yields null", async () => {
await setupStripe();
const listing = await createTestListing({
maxAttendees: 10,
unitPrice: 1500,
});
await withCheckoutStub(null, async () => {
const result = await processBooking(
listing,
contact,
1,
null,
BASE_URL,
);
expect(result).toEqual({ type: "checkout_failed" });
});
});
test("returns checkout_failed carrying the provider error message", async () => {
await setupStripe();
const listing = await createTestListing({
maxAttendees: 10,
unitPrice: 1500,
});
await withCheckoutStub({ error: "Provider rejected" }, async () => {
const result = await processBooking(
listing,
contact,
1,
null,
BASE_URL,
);
expect(result).toEqual({
error: "Provider rejected",
type: "checkout_failed",
});
});
});
test("threads a pay-more custom price into the checkout item unit price", async () => {
await setupStripe();
const listing = await createTestListing({
canPayMore: true,
maxAttendees: 10,
maxPrice: 10000,
unitPrice: 500,
});
const { checkout, getCaptured } = stubCheckout("sess_test");
try {
const result = await processBooking(
listing,
contact,
1,
null,
BASE_URL,
2000,
);
expect(result.type).toBe("checkout");
} finally {
checkout.restore();
}
expect(getCaptured()?.items[0]?.unitPrice).toBe(2000);
});
});
describe("listingHasSpots", () => {
test("is true while capacity remains and false once sold out", async () => {
const listing = await createTestListing({
maxAttendees: 1,
unitPrice: 0,
});
expect(await listingHasSpots(listing, 1, null)).toBe(true);
const booked = await processBooking(listing, contact, 1, null, BASE_URL);
expect(booked.type).toBe("success");
expect(await listingHasSpots(listing, 1, null)).toBe(false);
});
});
describe("registration webhook", () => {
const fetchSpy = stubWebhookFetch();
test("says the person booked online", async () => {
const listing = await createTestListing({
maxAttendees: 10,
unitPrice: 0,
webhookUrl: "https://example.com/registration",
});
await withRequestContext(() =>
processBooking(listing, contact, 1, null, BASE_URL),
);
await flushAsync();
expect(fetchSpy.firstBody().source).toBe("public");
});
});
});