160 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|