SPB Git

Repository Public

booking.test.ts 22.7 KB 357 lines typescript

1import { afterAll, beforeEach, describe, expect, it } from "vitest";2import { sql } from "@/lib/db";3import { computeSlots } from "@/lib/engine/slots";4import { engineInput, getEventType } from "@/lib/services/state";5import { approveRequest, cancelBooking, createBookingByAdmin, declineRequest, expireStale, proposeTime, rescheduleBooking, resolveToken, visitorAcceptProposal, visitorCancel, visitorReschedule } from "@/lib/services/booking";6import { processOutbox, requeue } from "@/lib/services/outbox";7import { testControl, testOutbox } from "@/lib/mail/transport";8import { addRules, addType, code, ms, resetDb, setNow, submit, TZ, visitor } from "./helpers";9import { submitRequest } from "@/lib/services/booking";1011let userId: string;12const NOW = "2026-11-02T12:00:00Z";13const MON9 = ms("2026-11-16T14:00:00Z"), MON930 = ms("2026-11-16T14:30:00Z"), MON10 = ms("2026-11-16T15:00:00Z"), MON11 = ms("2026-11-16T16:00:00Z");1415async function publicSlots(slug = "meet") {16  const et = (await getEventType({ slug }))!;17  const st = await engineInput(et, TZ, ms("2026-11-16T05:00:00Z"), ms("2026-11-17T05:00:00Z"), { now: ms(process.env.SPCAL_FAKE_NOW!) });18  return computeSlots({ ...st, fromDate: "2026-11-16", toDate: "2026-11-16" }).map((s) => new Date(s.start).toISOString().slice(11, 16));19}20const statusOf = async (id: string) => (await sql<{ status: string }[]>`SELECT status FROM booking_requests WHERE id = ${id}`)[0].status;21const jobs = (kind?: string) => kind ? sql<{ kind: string; status: string; to_email: string; last_error: string | null; attempts: number }[]>`SELECT kind, status, to_email::text, last_error, attempts FROM notification_jobs WHERE kind = ${kind} ORDER BY created_at` : sql<{ kind: string; status: string; to_email: string; last_error: string | null; attempts: number }[]>`SELECT kind, status, to_email::text, last_error, attempts FROM notification_jobs ORDER BY created_at`;2223beforeEach(async () => {24  setNow(NOW);25  ({ userId } = await resetDb());26  await addRules(userId, [1], 540, 720); // Monday 9–1227  testOutbox.length = 0; testControl.failNext = 0;28});29afterAll(async () => { await sql.end(); });3031describe("acceptance 1–2: public slots follow blocks and confirmed bookings", () => {32  it("Monday 9–12 with a 10–11 private event", async () => {33    await addType(userId);34    await sql`INSERT INTO busy_blocks (user_id, starts_at, ends_at, title) VALUES (${userId}, ${new Date(MON10)}, ${new Date(MON11)}, 'Privé')`;35    expect(await publicSlots()).toEqual(["14:00", "14:30", "16:00", "16:30"]);36    expect(await code(submit({ startMs: MON10 }))).toBe("slot_taken");37    expect(await code(submit({ startMs: ms("2026-11-16T17:00:00Z") }))).toBe("slot_unavailable"); // outside hours38    expect(await code(submit({ startMs: ms("2026-11-01T14:00:00Z") }))).toBe("slot_past");39  });40  it("a confirmed booking removes its slot immediately", async () => {41    const typeId = await addType(userId);42    await createBookingByAdmin(userId, { eventTypeId: typeId, startMs: MON930, name: "A", email: "a@example.test", notify: false });43    expect(await publicSlots()).not.toContain("14:30");44    expect(await code(submit({ startMs: MON930 }))).toBe("slot_taken");45  });46});4748describe("acceptance 3–5: pending requests, hold policy, atomic approval", () => {49  it("hold policy: the first request holds the slot, the second visitor is refused; a request is not a booking", async () => {50    await addType(userId, { hold_policy: "hold", hold_ttl_hours: 24 });51    const a = await submit({ startMs: MON9 });52    expect(a.status).toBe("pending");53    expect(await publicSlots()).not.toContain("14:00");54    expect(await code(submit({ startMs: MON9 }))).toBe("slot_taken");55    expect((await sql`SELECT 1 FROM bookings`).length).toBe(0);56    // the hold lapses: the slot is offered again, the first request is still pending57    setNow("2026-11-03T13:00:00Z");58    expect(await publicSlots()).toContain("14:00");59    expect(await statusOf(a.requestId)).toBe("pending");60  });61  it("no-hold policy: both requests wait; only one can be approved for that time", async () => {62    await addType(userId, { hold_policy: "none" });63    const a = await submit({ startMs: MON9 }), b = await submit({ startMs: MON9 });64    expect(await publicSlots()).toContain("14:00");65    const booking = await approveRequest(userId, a.requestId);66    expect(booking.status).toBe("confirmed");67    expect(await code(approveRequest(userId, b.requestId))).toBe("slot_taken");68    expect(await statusOf(b.requestId)).toBe("pending"); // never auto-confirmed, the owner must propose or decline69    expect(await publicSlots()).not.toContain("14:00");70  });71  it("concurrent approvals of two requests for the same time: exactly one booking", async () => {72    await addType(userId, { hold_policy: "none" });73    const reqs = await Promise.all(Array.from({ length: 6 }, () => submit({ startMs: MON9 })));74    const results = await Promise.all(reqs.map((r) => code(approveRequest(userId, r.requestId))));75    expect(results.filter((x) => x === "ok")).toHaveLength(1);76    expect(results.filter((x) => x === "slot_taken")).toHaveLength(5);77    expect((await sql`SELECT 1 FROM bookings WHERE status = 'confirmed'`).length).toBe(1);78  });79  it("concurrent submissions with automatic confirmation: exactly one booking", async () => {80    await addType(userId, { approval: "auto" });81    const results = await Promise.all(Array.from({ length: 8 }, () => code(submit({ startMs: MON9 }))));82    expect(results.filter((x) => x === "ok")).toHaveLength(1);83    expect((await sql`SELECT 1 FROM bookings WHERE status = 'confirmed'`).length).toBe(1);84  });85  it("the database itself refuses two overlapping confirmed bookings", async () => {86    const typeId = await addType(userId);87    const ins = (s: number) => sql`INSERT INTO bookings (user_id, event_type_id, title, starts_at, ends_at, ics_uid) VALUES (${userId}, ${typeId}, 'x', ${new Date(s)}, ${new Date(s + 1_800_000)}, ${"u" + Math.random()})`;88    await ins(MON9);89    await expect(ins(MON9 + 600_000)).rejects.toMatchObject({ code: "23P01" });90    await ins(MON930); // adjacent is fine91  });92  it("idempotency key: a replay returns the same request, no duplicate", async () => {93    await addType(userId);94    const v = visitor({ startMs: MON9 });95    const a = await submitRequest(v), b = await submitRequest(v);96    expect(b.requestId).toBe(a.requestId); expect(b.replay).toBe(true); expect(b.manageToken).toBe(a.manageToken);97    expect((await sql`SELECT 1 FROM booking_requests`).length).toBe(1);98  });99  it("approval re-checks: a block added after the request makes approval fail", async () => {100    await addType(userId);101    const a = await submit({ startMs: MON9 });102    await sql`INSERT INTO busy_blocks (user_id, starts_at, ends_at) VALUES (${userId}, ${new Date(MON9)}, ${new Date(MON930)})`;103    expect(await code(approveRequest(userId, a.requestId))).toBe("slot_taken");104  });105  it("approval creates the booking and queues confirmation with a valid invitation; the visitor was first told 'received'", async () => {106    await addType(userId, { location_detail: "https://meet.example/abc", reminder_hours: 24 });107    const a = await submit({ startMs: MON9, reason: "Sujet confidentiel XYZ" });108    await processOutbox();109    expect(testOutbox.map((m) => m.subject).sort()).toEqual(["Demande reçue — Rencontre", expect.stringContaining("Nouvelle demande")]);110    expect(testOutbox.find((m) => m.subject.startsWith("Demande reçue"))!.text).toContain("pas encore un rendez-vous confirmé");111    expect(testOutbox.some((m) => m.ics)).toBe(false);112    await approveRequest(userId, a.requestId);113    await processOutbox();114    const conf = testOutbox.find((m) => m.subject.startsWith("Rendez-vous confirmé"))!;115    expect(conf.ics!.method).toBe("REQUEST");116    expect(conf.ics!.content).toMatch(/UID:[0-9a-f-]+@sp-calendar\r\n/);117    expect(conf.ics!.content).toContain("SEQUENCE:0");118    expect(conf.ics!.content).toContain("DTSTART:20261116T140000Z");119    expect(conf.ics!.content).toContain("DTEND:20261116T143000Z");120    expect(conf.text).toContain("https://meet.example/abc");121    // confidential reason: never in any e-mail or ICS122    for (const m of testOutbox) { expect(m.text + m.html + (m.ics?.content ?? "")).not.toContain("XYZ"); }123    expect((await jobs("reminder"))[0].status).toBe("queued");124  });125});126127describe("acceptance 6: decline and propose", () => {128  it("decline: status, polite notification, no location detail", async () => {129    await addType(userId, { location_detail: "SECRET-ROOM" });130    const a = await submit({ startMs: MON9 });131    await declineRequest(userId, a.requestId, "Je suis en déplacement.");132    expect(await statusOf(a.requestId)).toBe("declined");133    expect(await code(approveRequest(userId, a.requestId))).toBe("invalid_state");134    await processOutbox();135    const m = testOutbox.find((x) => x.subject.startsWith("Demande non retenue"))!;136    expect(m.text).toContain("Je suis en déplacement.");137    expect(m.text).not.toContain("SECRET-ROOM");138    expect(await publicSlots()).toContain("14:00");139  });140  it("propose another time: holds the proposed slot, visitor accepts, booking at the proposed time", async () => {141    await addType(userId);142    const a = await submit({ startMs: MON9 });143    await proposeTime(userId, a.requestId, MON11, "Plutôt 11 h ?");144    expect(await statusOf(a.requestId)).toBe("reschedule_requested");145    const slots = await publicSlots();146    expect(slots).toContain("14:00"); expect(slots).not.toContain("16:00");147    expect((await sql`SELECT 1 FROM bookings`).length).toBe(0);148    await visitorAcceptProposal(a.manageToken);149    const [b] = await sql<{ starts_at: Date; status: string }[]>`SELECT starts_at, status FROM bookings`;150    expect(b.starts_at.getTime()).toBe(MON11); expect(b.status).toBe("confirmed");151    expect(await statusOf(a.requestId)).toBe("approved");152  });153  it("accepting a proposal re-checks: if the time was taken meanwhile, nothing is confirmed", async () => {154    await addType(userId);155    const a = await submit({ startMs: MON9 });156    await proposeTime(userId, a.requestId, MON11, "");157    await sql`INSERT INTO busy_blocks (user_id, starts_at, ends_at) VALUES (${userId}, ${new Date(MON11)}, ${new Date(MON11 + 1_800_000)})`;158    expect(await code(visitorAcceptProposal(a.manageToken))).toBe("slot_taken");159    expect((await sql`SELECT 1 FROM bookings`).length).toBe(0);160  });161  it("cannot propose a taken time; an unanswered proposal expires", async () => {162    const typeId = await addType(userId);163    await createBookingByAdmin(userId, { eventTypeId: typeId, startMs: MON11, name: "A", email: "a@example.test", notify: false });164    const a = await submit({ startMs: MON9 });165    expect(await code(proposeTime(userId, a.requestId, MON11, ""))).toBe("slot_taken");166    await proposeTime(userId, a.requestId, MON10, "");167    setNow("2026-11-06T12:00:00Z"); // > 72 h168    expect(await expireStale()).toBe(1);169    expect(await statusOf(a.requestId)).toBe("expired");170    expect(await code(visitorAcceptProposal(a.manageToken))).toBe("invalid_state");171  });172});173174describe("acceptance 8: cancel, reschedule, visitor links", () => {175  it("visitor cancels a confirmed booking: slot freed, ICS CANCEL with higher SEQUENCE, owner told", async () => {176    await addType(userId);177    const a = await submit({ startMs: MON9 });178    await approveRequest(userId, a.requestId);179    await visitorCancel(a.manageToken, "");180    expect(await statusOf(a.requestId)).toBe("cancelled");181    expect(await publicSlots()).toContain("14:00");182    await processOutbox();183    const m = testOutbox.find((x) => x.subject.startsWith("Rendez-vous annulé"))!;184    expect(m.ics!.method).toBe("CANCEL"); expect(m.ics!.content).toContain("STATUS:CANCELLED"); expect(m.ics!.content).toContain("SEQUENCE:1");185    expect(testOutbox.some((x) => x.subject.startsWith("Annulation"))).toBe(true);186    expect(await code(visitorCancel(a.manageToken, ""))).toBe("invalid_state");187  });188  it("visitor cancellation can be disabled per type", async () => {189    await addType(userId, { allow_visitor_cancel: false, allow_visitor_reschedule: false });190    const a = await submit({ startMs: MON9 });191    await approveRequest(userId, a.requestId);192    expect(await code(visitorCancel(a.manageToken, ""))).toBe("not_allowed");193    expect(await code(visitorReschedule(a.manageToken, MON11))).toBe("not_allowed");194  });195  it("visitor reschedules a confirmed booking (manual approval): the booking stays until the owner approves, then moves with SEQUENCE+1", async () => {196    await addType(userId);197    const a = await submit({ startMs: MON9 });198    await approveRequest(userId, a.requestId);199    expect((await visitorReschedule(a.manageToken, MON11)).status).toBe("pending");200    const [b0] = await sql<{ starts_at: Date }[]>`SELECT starts_at FROM bookings`;201    expect(b0.starts_at.getTime()).toBe(MON9);202    expect(await code(visitorReschedule(a.manageToken, MON10))).toBe("reschedule_pending");203    const [child] = await sql<{ id: string }[]>`SELECT id FROM booking_requests WHERE reschedule_of_booking_id IS NOT NULL`;204    await approveRequest(userId, child.id);205    const [b1] = await sql<{ starts_at: Date; ics_sequence: number }[]>`SELECT starts_at, ics_sequence FROM bookings`;206    expect(b1.starts_at.getTime()).toBe(MON11); expect(b1.ics_sequence).toBe(1);207    expect((await sql`SELECT 1 FROM bookings`).length).toBe(1);208    const slots = await publicSlots();209    expect(slots).toContain("14:00"); expect(slots).not.toContain("16:00");210    await processOutbox();211    const m = testOutbox.find((x) => x.subject.startsWith("Rendez-vous déplacé"))!;212    expect(m.ics!.content).toContain("SEQUENCE:1"); expect(m.ics!.content).toContain("DTSTART:20261116T160000Z");213  });214  it("visitor reschedule with automatic approval moves at once; its own slot does not block the move", async () => {215    await addType(userId, { approval: "auto", duration_min: 60 });216    const a = await submit({ startMs: MON9 });217    expect(a.status).toBe("approved");218    expect((await visitorReschedule(a.manageToken, MON930)).status).toBe("approved"); // overlaps its own old time219    const [b] = await sql<{ starts_at: Date }[]>`SELECT starts_at FROM bookings WHERE status = 'confirmed'`;220    expect(b.starts_at.getTime()).toBe(MON930);221  });222  it("admin reschedule refuses a taken time and the past; admin cancel notifies", async () => {223    const typeId = await addType(userId);224    const b1 = await createBookingByAdmin(userId, { eventTypeId: typeId, startMs: MON9, name: "A", email: "a@example.test", notify: true });225    await createBookingByAdmin(userId, { eventTypeId: typeId, startMs: MON11, name: "B", email: "b@example.test", notify: false });226    expect(await code(rescheduleBooking(userId, b1.id, MON11))).toBe("slot_taken");227    expect(await code(rescheduleBooking(userId, b1.id, ms("2026-11-01T14:00:00Z")))).toBe("slot_past");228    await rescheduleBooking(userId, b1.id, MON10);229    await cancelBooking(userId, b1.id, "Imprévu");230    expect(await code(cancelBooking(userId, b1.id, ""))).toBe("invalid_state");231    await processOutbox();232    expect(testOutbox.filter((m) => m.to === "a@example.test").map((m) => m.subject.split(" — ")[0])).toEqual(["Rendez-vous annulé"]); // superseded messages are dropped233  });234  it("links: unknown, expired and revoked tokens are refused; tokens are stored hashed", async () => {235    await addType(userId);236    const a = await submit({ startMs: MON9 });237    expect(await code(resolveToken("nope"))).toBe("link_invalid");238    const [t] = await sql<{ token_hash: string }[]>`SELECT token_hash FROM booking_tokens`;239    expect(t.token_hash).not.toBe(a.manageToken); expect(t.token_hash).toHaveLength(64);240    await sql`UPDATE booking_tokens SET revoked_at = now()`;241    expect(await code(visitorCancel(a.manageToken, ""))).toBe("link_revoked");242    await sql`UPDATE booking_tokens SET revoked_at = NULL`;243    setNow("2027-01-15T00:00:00Z");244    expect(await code(visitorCancel(a.manageToken, ""))).toBe("link_expired");245  });246  it("an unanswered request expires when its time comes, and the visitor is told", async () => {247    await addType(userId);248    const a = await submit({ startMs: MON9 });249    setNow("2026-11-16T14:00:01Z");250    expect(await expireStale()).toBe(1);251    expect(await statusOf(a.requestId)).toBe("expired");252    expect(await code(approveRequest(userId, a.requestId))).toBe("invalid_state");253    expect((await jobs("request_expired")).length).toBe(1);254  });255});256257describe("acceptance 9: e-mail failures, retries, unconfigured transport; stale external calendar", () => {258  it("a failed send is retried with backoff, never reported as sent; then succeeds once", async () => {259    await addType(userId);260    await submit({ startMs: MON9 });261    await sql`DELETE FROM notification_jobs WHERE kind = 'admin_new_request'`;262    testControl.failNext = 1;263    const s1 = await processOutbox();264    expect(s1.failed).toBe(1); expect(s1.sent).toBe(0);265    const [j1] = await jobs("request_received");266    expect(j1.status).toBe("queued"); expect(j1.last_error).toContain("simulated failure"); expect(j1.attempts).toBe(1);267    await processOutbox(); // not due yet (backoff 1 min)268    expect((await jobs("request_received"))[0].status).toBe("queued");269    setNow("2026-11-02T12:02:00Z");270    await processOutbox();271    expect((await jobs("request_received"))[0].status).toBe("sent");272    expect(testOutbox.filter((m) => m.subject.startsWith("Demande reçue"))).toHaveLength(1);273  });274  it("a permanently failing address ends as dead after the retries", async () => {275    await addType(userId);276    await submit({ startMs: MON9, email: "bounce@example.test" });277    for (let k = 0; k < 7; k++) { await processOutbox(); setNow(new Date(ms(process.env.SPCAL_FAKE_NOW!) + 7 * 3_600_000).toISOString()); }278    const [j] = await jobs("request_received");279    expect(j.status).toBe("dead"); expect(j.attempts).toBe(6);280  });281  it("without a transport, jobs are parked as unconfigured and can be re-queued", async () => {282    await addType(userId);283    await submit({ startMs: MON9 });284    process.env.MAIL_TRANSPORT = "none";285    try {286      const s = await processOutbox();287      expect(s.unconfigured).toBe(2); expect(s.sent).toBe(0);288      expect((await jobs()).every((x) => x.status === "unconfigured")).toBe(true);289    } finally { process.env.MAIL_TRANSPORT = "test"; }290    expect(await requeue(userId)).toBe(2);291    expect((await processOutbox()).sent).toBe(2);292  });293  it("duplicate enqueue of the same event sends one e-mail", async () => {294    await addType(userId);295    const a = await submit({ startMs: MON9 });296    await sql`INSERT INTO notification_jobs (user_id, kind, dedup_key, to_email, request_id) VALUES (${userId}, 'request_received', ${"received:" + a.requestId}, 'x@example.test', ${a.requestId}) ON CONFLICT (dedup_key) DO NOTHING`;297    expect((await jobs("request_received")).length).toBe(1);298  });299  it("stale external calendar: automatic confirmation is withheld (conservative), allowed again when fresh or when policy is 'ignore'", async () => {300    await addType(userId, { approval: "auto" });301    const [acc] = await sql<{ id: string }[]>`INSERT INTO external_calendar_accounts (user_id, provider, label, secret_enc) VALUES (${userId}, 'ics', 'Perso', ${Buffer.from("x")}) RETURNING id`;302    const [cal] = await sql<{ id: string }[]>`INSERT INTO external_calendars (account_id, user_id, name) VALUES (${acc.id}, ${userId}, 'Perso') RETURNING id`;303    await sql`INSERT INTO sync_state (calendar_id, last_success_at, last_error) VALUES (${cal.id}, now() - interval '3 hours', 'HTTP 503')`;304    const a = await submit({ startMs: MON9 });305    expect(a.status).toBe("pending");306    expect((await sql<{ conflict_flag: string }[]>`SELECT conflict_flag FROM booking_requests WHERE id = ${a.requestId}`)[0].conflict_flag).toBe("external_stale");307    await sql`UPDATE sync_state SET last_success_at = now(), last_error = NULL`;308    expect((await submit({ startMs: MON10 })).status).toBe("approved");309    await sql`UPDATE sync_state SET last_success_at = NULL`;310    await sql`UPDATE settings SET external_stale_policy = 'ignore'`;311    expect((await submit({ startMs: MON11 })).status).toBe("approved");312  });313  it("busy periods from an external calendar block slots only when the calendar is set to block", async () => {314    await addType(userId);315    const [acc] = await sql<{ id: string }[]>`INSERT INTO external_calendar_accounts (user_id, provider, label, secret_enc) VALUES (${userId}, 'ics', 'Perso', ${Buffer.from("x")}) RETURNING id`;316    const [cal] = await sql<{ id: string }[]>`INSERT INTO external_calendars (account_id, user_id, name) VALUES (${acc.id}, ${userId}, 'Perso') RETURNING id`;317    await sql`INSERT INTO busy_blocks (user_id, starts_at, ends_at, source, external_calendar_id) VALUES (${userId}, ${new Date(MON9)}, ${new Date(MON10)}, 'external', ${cal.id})`;318    expect(await publicSlots()).toEqual(["15:00", "15:30", "16:00", "16:30"]);319    await sql`UPDATE external_calendars SET blocks_availability = false`;320    expect(await publicSlots()).toHaveLength(6);321  });322});323324describe("limits, domains, anti-abuse at the service level", () => {325  it("rate limiter: the call after the limit is refused with 429, another key is unaffected", async () => {326    const { rateLimit } = await import("@/lib/ratelimit");327    for (let k = 0; k < 3; k++) await rateLimit("t:1.2.3.4", 3, 3600);328    await expect(rateLimit("t:1.2.3.4", 3, 3600)).rejects.toMatchObject({ status: 429, code: "rate_limited" });329    await rateLimit("t:5.6.7.8", 3, 3600);330  });331  it("daily limit counts confirmed and held requests", async () => {332    await addType(userId, { daily_limit: 2 });333    await submit({ startMs: MON9 }); await submit({ startMs: MON10 });334    expect(await publicSlots()).toEqual([]);335    expect(await code(submit({ startMs: MON11 }))).toBe("slot_limit");336  });337  it("one visitor cannot pile up pending requests; blocked e-mail domains are refused", async () => {338    await addType(userId, { hold_policy: "none" });339    const email = "same@example.test";340    for (const s of [MON9, MON930, MON10]) await submit({ startMs: s, email });341    expect(await code(submit({ startMs: MON11, email }))).toBe("too_many_pending");342    await sql`UPDATE settings SET blocked_email_domains = '{spam.test}'`;343    expect(await code(submit({ startMs: MON11, email: "x@mail.spam.test" }))).toBe("email_domain_not_allowed");344  });345  it("type-specific availability replaces the default hours", async () => {346    const typeId = await addType(userId, { custom_availability: true });347    await addRules(userId, [1], 840, 900, typeId); // Monday 14–15 only348    expect(await publicSlots()).toEqual(["19:00", "19:30"]);349  });350  it("required custom questions are enforced server-side and stored", async () => {351    await addType(userId, { questions: sql.json([{ key: "sujet", label: "Sujet", kind: "text", required: true }]) });352    expect(await code(submit({ startMs: MON9 }))).toBe("invalid_input");353    const a = await submit({ startMs: MON9, answers: { sujet: "Thèse" } });354    expect((await sql`SELECT value FROM booking_answers WHERE request_id = ${a.requestId}`)[0].value).toBe("Thèse");355  });356});357
Files main
Menu

Theme