datewise/app/services/__tests__/reservations.server.test.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

160 lines
7.8 KiB
TypeScript

/**
* Runs against a real Postgres. Set TEST_DATABASE_URL (schema from prisma/migrations applied).
* TEST_DATABASE_URL=postgresql://postgres@localhost:5433/datewise_test npx vitest run app/services
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createPrisma } from "../../db.server";
import { ReservationService } from "../reservations.server";
import type { PrismaClient } from "@prisma/client";
const url = process.env.TEST_DATABASE_URL;
const d = url ? describe : describe.skip;
d("ReservationService (Postgres)", () => {
let db: PrismaClient;
let svc: ReservationService;
let shopId: string;
const sel = { locationId: "kit", method: "delivery" as const, date: "2026-11-08", slot: "10:00-13:00" };
const now = new Date("2026-11-05T15:00:00Z");
const details = { timezone: "America/Toronto", configVersion: 1, snapshot: {} };
beforeAll(async () => {
db = createPrisma(url);
svc = new ReservationService(db);
});
afterAll(async () => db.$disconnect());
beforeEach(async () => {
await db.shop.deleteMany({ where: { domain: "test.myshopify.com" } });
const s = await db.shop.create({ data: { domain: "test.myshopify.com", settings: {} } });
shopId = s.id;
});
it("25 concurrent shoppers, 3 places → exactly 3 holds", async () => {
const caps = { dayCap: 30, slotCap: 3 };
const results = await Promise.all(
Array.from({ length: 25 }, (_, i) => svc.hold(shopId, `t${i}`, sel, caps, now, 10)),
);
expect(results.filter((r) => r.ok)).toHaveLength(3);
const c = await db.capacityCounter.findMany({ where: { shopId } });
expect(c.find((x) => x.slot === "*")!.count).toBe(3);
expect(c.find((x) => x.slot === sel.slot)!.count).toBe(3);
});
it("expired holds free capacity inside the next hold", async () => {
const caps = { dayCap: 30, slotCap: 1 };
expect((await svc.hold(shopId, "a", sel, caps, now, 10)).ok).toBe(true);
expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(false);
const later = new Date(now.getTime() + 11 * 60000);
expect((await svc.hold(shopId, "b", sel, caps, later, 10)).ok).toBe(true);
expect(await db.hold.count({ where: { shopId } })).toBe(1);
});
it("moving a hold releases the previous slot", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.hold(shopId, "a", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(true);
});
it("usage excludes stale holds and the caller's own hold", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.hold(shopId, "b", sel, caps, now, 1);
const u = await svc.usage(shopId, "2026-11-01", "2026-11-30", new Date(now.getTime() + 2 * 60000), "a");
expect(u["kit|delivery|2026-11-08"].slots[sel.slot]).toBe(0);
});
it("concurrent duplicate webhooks create one booking", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const rs = await Promise.all(
Array.from({ length: 6 }, () => svc.confirm(shopId, "order-1", "a", null, caps, now, details)),
);
expect(await db.booking.count({ where: { shopId } })).toBe(1);
expect(rs.filter((r) => r.ok && !r.duplicate)).toHaveLength(1);
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(1);
});
it("expired hold at order time: re-reserve when free, flag when full", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const late = new Date(now.getTime() + 15 * 60000);
const ok = await svc.confirm(shopId, "o1", "a", null, caps, late, details);
expect(ok.ok && ok.reReserved).toBe(true);
await svc.hold(shopId, "x", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
await svc.hold(shopId, "y", { ...sel, slot: "13:00-16:00" }, caps, new Date(now.getTime() + 11 * 60000), 10);
const flagged = await svc.confirm(shopId, "o2", "x", null, caps, new Date(now.getTime() + 12 * 60000), details);
expect(flagged.ok).toBe(false);
const b = await db.booking.findUnique({ where: { id: flagged.bookingId } });
expect(b!.status).toBe("needs_review");
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: "13:00-16:00" } });
expect(c!.count).toBe(1); // only y's hold — no overbooking
});
it("order without a selection lands in review", async () => {
const r = await svc.confirm(shopId, "o9", null, null, { dayCap: 1, slotCap: 1 }, now, details);
expect(r.ok).toBe(false);
});
it("reschedule: atomic swap, failure preserves, audit recorded", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const r = await svc.confirm(shopId, "o1", "a", null, caps, now, details);
await svc.hold(shopId, "other", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
const fail = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "13:00-16:00" }, caps, now, "customer");
expect(fail.ok).toBe(false);
expect((await db.booking.findUnique({ where: { id: r.bookingId } }))!.slotStart).toBe("10:00");
const ok = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "16:00-19:00" }, caps, now, "customer");
expect(ok.ok && ok.freed?.slot).toBe("10:00-13:00");
const counters = await db.capacityCounter.findMany({ where: { shopId } });
expect(counters.find((c) => c.slot === "10:00-13:00")!.count).toBe(0);
expect(counters.find((c) => c.slot === "16:00-19:00")!.count).toBe(1);
expect(counters.find((c) => c.slot === "*")!.count).toBe(2); // booking + other's hold
const b = await db.booking.findUnique({ where: { id: r.bookingId } });
expect(b!.rescheduleCount).toBe(1);
expect(await db.bookingEvent.count({ where: { bookingId: r.bookingId, action: "rescheduled" } })).toBe(1);
});
it("concurrent reschedules across two dates don't deadlock or overbook", async () => {
const caps = { dayCap: 30, slotCap: 1 };
const a = { ...sel, date: "2026-11-07" };
const b = { ...sel, date: "2026-11-08" };
await svc.hold(shopId, "h1", a, caps, now, 10);
const r1 = await svc.confirm(shopId, "o1", "h1", null, caps, now, details);
await svc.hold(shopId, "h2", b, caps, now, 10);
const r2 = await svc.confirm(shopId, "o2", "h2", null, caps, now, details);
// Swap attempts in opposite directions at once: both target full slots → both fail cleanly.
const [x, y] = await Promise.all([
svc.reschedule(shopId, r1.bookingId, b, caps, now, "merchant:1"),
svc.reschedule(shopId, r2.bookingId, a, caps, now, "merchant:2"),
]);
expect(x.ok || y.ok).toBe(false);
const total = await db.capacityCounter.findMany({ where: { shopId, slot: sel.slot } });
expect(total.every((t) => t.count <= 1)).toBe(true);
});
it("cancel frees capacity once and is idempotent", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.confirm(shopId, "o1", "a", null, caps, now, details);
const c1 = await svc.cancel(shopId, "o1");
const c2 = await svc.cancel(shopId, "o1");
expect(c1.freed?.slot).toBe(sel.slot);
expect(c2.freed).toBeNull();
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(0);
});
it("background sweep releases expired holds", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 1);
await svc.hold(shopId, "b", sel, caps, now, 30);
const n = await svc.sweepExpired(new Date(now.getTime() + 5 * 60000), 500, shopId);
expect(n).toBe(1);
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(1);
});
});