CampgroundTickets/backend/src/test/redeem.test.ts
Hank d293e53ee6 Add Check-in / Ice / Banquet scan modes
- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:07:51 +00:00

172 lines
6.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { FakeNocoDB, fakeContext, seedTicket } from "./fakeNocodb.js";
import { redeem, lookupByCode, createTicket } from "../ticketService.js";
import { COL } from "../fields.js";
describe("redeem", () => {
it("checks in a single walk-up (default count 1)", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.checkedIn).toBe(1);
expect(r.ticket.redeemed).toBe(1);
expect(r.ticket.remaining).toBe(3);
}
});
it("supports group check-in and QR reuse across visits", async () => {
const db = new FakeNocoDB();
// Party of 7 (2 free under-4 not counted): total 5.
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
const ctx = fakeContext(db);
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
expect(first.ok && first.ticket.remaining).toBe(3);
const second = await redeem(ctx, "BC26-FAM-0001", 3); // mother + 3 daughters
expect(second.ok && second.ticket.remaining).toBe(0);
const third = await redeem(ctx, "BC26-FAM-0001", 1); // nobody left
expect(third.ok).toBe(false);
if (!third.ok) expect(third.reason).toBe("exhausted");
});
it("rejects over-redemption without mutating", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe("insufficient");
expect(db.rows[0][COL.redeemed]).toBe(0);
});
it("allows negative count to undo, clamped at zero", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
expect(r.ok).toBe(true);
if (r.ok) expect(r.ticket.redeemed).toBe(0);
});
it("writes an audit entry on each successful check-in and undo", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0001", 2);
await redeem(ctx, "BC26-AUDT-0001", -1);
const log = (ctx.audit as any).entries;
expect(log).toHaveLength(2);
expect(log[0]).toMatchObject({ code: "BC26-AUDT-0001", people: 2, action: "check-in", remainingAfter: 2 });
expect(log[1]).toMatchObject({ people: -1, action: "undo", remainingAfter: 3 });
});
it("does not audit a no-op (undo when nothing redeemed)", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0);
});
it("redeems ice bags independently of ticket check-ins", async () => {
const db = new FakeNocoDB();
await seedTicket(db, {
code: "BC26-ICE-0003",
ages: { "Ages 26-45": 2 },
});
// Give the ticket 3 prepaid ice bags.
db.rows[0]["Ice Total"] = 3;
db.rows[0]["Ice Redeemed"] = 0;
const ctx = fakeContext(db);
// Grab all 3 bags at once.
const ice = await redeem(ctx, "BC26-ICE-0003", 3, "ice");
expect(ice.ok).toBe(true);
if (ice.ok) expect(ice.ticket.ice.remaining).toBe(0);
expect(db.rows[0]["Ice Redeemed"]).toBe(3);
// Ticket check-ins are untouched by ice redemption.
expect(db.rows[0].Redeemed ?? 0).toBe(0);
// No ice left.
const again = await redeem(ctx, "BC26-ICE-0003", 1, "ice");
expect(again.ok).toBe(false);
if (!again.ok) expect(again.reason).toBe("exhausted");
// Ticket check-in still works after ice is gone.
const checkin = await redeem(ctx, "BC26-ICE-0003", 2, "tickets");
expect(checkin.ok).toBe(true);
if (checkin.ok) expect(checkin.ticket.remaining).toBe(0);
});
it("returns not_found for unknown codes", async () => {
const ctx = fakeContext(new FakeNocoDB());
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe("not_found");
});
it("surfaces db_error when the update fails", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
db.failNext = true;
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe("db_error");
});
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
const db = new FakeNocoDB(8);
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
const ctx = fakeContext(db);
const results = await Promise.all(
Array.from({ length: 20 }, () => redeem(ctx, "BC26-RACE-0005", 1)),
);
const successes = results.filter((r) => r.ok).length;
expect(successes).toBe(5);
expect(db.rows[0][COL.redeemed]).toBe(5);
});
});
describe("lookupByCode", () => {
it("returns the ticket view without mutating", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
const ctx = fakeContext(db);
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
expect(db.rows[0][COL.redeemed]).toBe(0);
});
it("reports not found", async () => {
const ctx = fakeContext(new FakeNocoDB());
const r = await lookupByCode(ctx, "BC26-NONE-0000");
expect(r.ok && !("found" in r ? false : true));
if (r.ok) expect(r.found).toBe(false);
});
});
describe("createTicket idempotency", () => {
it("does not create a second row for the same submission key", async () => {
const db = new FakeNocoDB();
const ctx = fakeContext(db);
const input = {
name: "Jane Bear",
email: "jane@example.com",
ages: { "Ages 26-45": 2 },
submissionKey: "sub:412",
};
const a = await createTicket(ctx, input);
const b = await createTicket(ctx, input);
expect(a.status).toBe("created");
expect(b.status).toBe("duplicate");
expect(b.code).toBe(a.code);
expect(db.rows.length).toBe(1);
});
});