- 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>
172 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|