Repository Public
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