Ctrl K
ComunidadGuías…

test/shared/booking.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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");
    });
  });
});