/** * 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); }); });