// API contract tests for admin-managed booking slots (أوقات الحجز) and the // public lead-time setting. Covers: // // 1. Admin slot CRUD: create returns 201, duplicate startTime returns 409 // with code "duplicate_slot". // 2. Settings roundtrip: PATCH minLeadMinutes persists and GET returns it. // 3. Public availability reflects booked/tooSoon/available per slot. // 4. Public request off-slot -> 400 invalid_slot. // 5. Public request on-slot but inside the lead window -> 400 lead_time. // 6. Public request on-slot outside the lead window -> 201; a second // request for the same slot conflicts (409). // 7. A deactivated slot no longer accepts public requests (while another // slot keeps slot-mode active). // 8. Staff (internal POST /protocol/bookings) must still match a slot but // are exempt from the lead time. // // The suite snapshots and clears protocol_booking_slots + the // booking.minLeadMinutes setting up front and restores both in `after`, so // it is safe against pre-existing data and repeated runs. // // NOTE: the public booking endpoint is rate limited at 8 requests / 10 min // per IP; this file performs at most 6 public POSTs. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const created = { slotIds: [], roomIds: [], bookingIds: [], userIds: [], }; const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } // A dedicated protocol_super_admin user (has protocol.rooms.manage + // protocol.mutate) so the suite doesn't depend on the real admin password. async function createSuperAdmin() { const username = uniqueName("slot_admin"); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Slot Test Admin', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); created.userIds.push(rows[0].id); for (const r of ["user", "protocol_super_admin"]) { await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2 ON CONFLICT DO NOTHING`, [rows[0].id, r], ); } return username; } let savedSlots = []; let savedLeadRow = null; function extractCookie(res) { const setCookie = res.headers.get("set-cookie"); if (!setCookie) return null; return ( setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")) ?? null ); } async function login(username, password) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200, `login should succeed for ${username}`); const cookie = extractCookie(res); assert.ok(cookie, "login response should set a session cookie"); return cookie; } async function api(cookie, method, path, body) { const init = { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, }; if (body !== undefined) init.body = JSON.stringify(body); return fetch(`${API_BASE}/api${path}`, init); } let adminCookie = null; let roomId = null; // Tomorrow's calendar day in Riyadh (fixed +03:00, no DST) so every slot we // book is comfortably in the future. function riyadhTomorrowYmd() { const now = new Date(Date.now() + (24 * 60 + 180) * 60 * 1000); return now.toISOString().slice(0, 10); } const day = riyadhTomorrowYmd(); function isoAt(hhmm, plusMinutes = 0) { const d = new Date(`${day}T${hhmm}:00+03:00`); return new Date(d.getTime() + plusMinutes * 60_000).toISOString(); } const SLOT_A = "09:37"; // uncommon times to avoid clashing with real data const SLOT_B = "13:41"; const DURATION = 45; function publicBody(startsAt, endsAt, title) { return { roomId, title, requesterName: "Slot Test Guest", requesterPhone: "0550000000", meetingType: "internal", startsAt, endsAt, }; } before(async () => { adminCookie = await login(await createSuperAdmin(), TEST_PASSWORD); // Snapshot + clear pre-existing slots and lead-time setting. savedSlots = ( await pool.query(`SELECT * FROM protocol_booking_slots ORDER BY id`) ).rows; await pool.query(`DELETE FROM protocol_booking_slots`); savedLeadRow = ( await pool.query( `SELECT value FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`, ) ).rows[0] ?? null; const roomRes = await api(adminCookie, "POST", "/protocol/rooms", { nameAr: `قاعة اختبار الأوقات ${Date.now().toString(36)}`, nameEn: "Slot Test Room", isActive: true, }); assert.equal(roomRes.status, 201, "room creation should succeed"); roomId = (await roomRes.json()).id; created.roomIds.push(roomId); }); after(async () => { try { if (created.bookingIds.length) { await pool.query( `DELETE FROM protocol_room_bookings WHERE id = ANY($1::int[])`, [created.bookingIds], ); } // Also remove any bookings created via the public endpoint against our // test room (ids captured from responses, but belt-and-braces by room). if (created.roomIds.length) { await pool.query( `DELETE FROM protocol_room_bookings WHERE room_id = ANY($1::int[])`, [created.roomIds], ); await pool.query(`DELETE FROM protocol_rooms WHERE id = ANY($1::int[])`, [ created.roomIds, ]); } if (created.userIds.length) { await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ created.userIds, ]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ created.userIds, ]); } await pool.query(`DELETE FROM protocol_booking_slots`); for (const s of savedSlots) { await pool.query( `INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, days_of_week, is_active, sort_order, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ s.id, s.start_time, s.duration_minutes, s.days_of_week, s.is_active, s.sort_order, s.created_at, ], ); } if (savedSlots.length) { await pool.query( `SELECT setval(pg_get_serial_sequence('protocol_booking_slots','id'), (SELECT MAX(id) FROM protocol_booking_slots))`, ); } if (savedLeadRow) { await pool.query( `INSERT INTO protocol_settings (key, value) VALUES ('booking.minLeadMinutes', $1) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, [savedLeadRow.value], ); } else { await pool.query( `DELETE FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`, ); } } finally { await pool.end(); } }); let slotAId = null; let slotBId = null; test("admin can create slots; duplicate startTime is rejected with 409", async () => { const resA = await api(adminCookie, "POST", "/protocol/booking-slots", { startTime: SLOT_A, durationMinutes: DURATION, }); assert.equal(resA.status, 201); slotAId = (await resA.json()).id; created.slotIds.push(slotAId); const resB = await api(adminCookie, "POST", "/protocol/booking-slots", { startTime: SLOT_B, durationMinutes: DURATION, }); assert.equal(resB.status, 201); slotBId = (await resB.json()).id; created.slotIds.push(slotBId); const dup = await api(adminCookie, "POST", "/protocol/booking-slots", { startTime: SLOT_A, }); assert.equal(dup.status, 409, "duplicate startTime should 409"); assert.equal((await dup.json()).code, "duplicate_slot"); }); test("minLeadMinutes setting persists via PATCH and GET", async () => { const patch = await api(adminCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 10080, }); assert.equal(patch.status, 200); const get = await api(adminCookie, "GET", "/protocol/settings"); assert.equal(get.status, 200); assert.equal((await get.json()).minLeadMinutes, 10080); }); test("public availability marks slots tooSoon inside the lead window", async () => { // lead is currently 7 days (10080), and our slots are tomorrow -> tooSoon. const res = await fetch( `${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`, ); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.minLeadMinutes, 10080); const a = data.slots.find((s) => s.id === slotAId); assert.ok(a, "slot A should appear in availability"); assert.equal(a.booked, false); assert.equal(a.tooSoon, true); assert.equal(a.available, false); }); test("public request off-slot is rejected with invalid_slot", async () => { const res = await api(null, "POST", "/protocol/public/booking-requests", publicBody(isoAt("10:15"), isoAt("10:15", DURATION), "Off-slot request")); assert.equal(res.status, 400); assert.equal((await res.json()).code, "invalid_slot"); }); test("public request on-slot inside lead window is rejected with lead_time", async () => { const res = await api(null, "POST", "/protocol/public/booking-requests", publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Too-soon request")); assert.equal(res.status, 400); assert.equal((await res.json()).code, "lead_time"); }); test("public request on-slot outside lead window succeeds; same slot then conflicts", async () => { const patch = await api(adminCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 0, }); assert.equal(patch.status, 200); const ok = await api(null, "POST", "/protocol/public/booking-requests", publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Valid slot request")); assert.equal(ok.status, 201, "on-slot request should be accepted"); const body = await ok.json(); assert.ok(body.id, "response should include an id"); created.bookingIds.push(body.id); const clash = await api(null, "POST", "/protocol/public/booking-requests", publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Clashing request")); assert.equal(clash.status, 409, "same slot should conflict"); // Availability now reports the slot as booked. const avail = await fetch( `${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`, ); const data = await avail.json(); const a = data.slots.find((s) => s.id === slotAId); assert.equal(a.booked, true); assert.equal(a.available, false); }); test("a deactivated slot no longer accepts public requests", async () => { const patch = await api( adminCookie, "PATCH", `/protocol/booking-slots/${slotBId}`, { isActive: false }, ); assert.equal(patch.status, 200); const res = await api(null, "POST", "/protocol/public/booking-requests", publicBody(isoAt(SLOT_B), isoAt(SLOT_B, DURATION), "Disabled slot request")); assert.equal(res.status, 400); assert.equal((await res.json()).code, "invalid_slot"); // Re-activate for the staff test below. const undo = await api( adminCookie, "PATCH", `/protocol/booking-slots/${slotBId}`, { isActive: true }, ); assert.equal(undo.status, 200); }); test("internal bookings enforce both the slot grid and the lead time", async () => { // Large lead time: internal bookings inside the window must be rejected // just like public ones (no internal bypass). const patch = await api(adminCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 10080, }); assert.equal(patch.status, 200); const offSlot = await api(adminCookie, "POST", "/protocol/bookings", { roomId, title: "Staff off-slot", requesterName: "Staff", startsAt: isoAt("16:03"), endsAt: isoAt("16:03", DURATION), }); assert.equal(offSlot.status, 400, "staff off-slot booking should 400"); assert.equal((await offSlot.json()).code, "invalid_slot"); const insideLead = await api(adminCookie, "POST", "/protocol/bookings", { roomId, title: "Staff on-slot inside lead window", requesterName: "Staff", startsAt: isoAt(SLOT_B), endsAt: isoAt(SLOT_B, DURATION), }); assert.equal( insideLead.status, 400, "internal booking inside the lead window must be rejected", ); assert.equal((await insideLead.json()).code, "lead_time"); // Outside the lead window (lead back to 0) the same booking succeeds. const reset = await api(adminCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 0, }); assert.equal(reset.status, 200); const onSlot = await api(adminCookie, "POST", "/protocol/bookings", { roomId, title: "Staff on-slot outside lead window", requesterName: "Staff", startsAt: isoAt(SLOT_B), endsAt: isoAt(SLOT_B, DURATION), }); assert.equal(onSlot.status, 201, "on-slot booking outside lead should 201"); created.bookingIds.push((await onSlot.json()).id); }); // Authz locked in per Task #704 spec: slot/settings admin endpoints — // including reads — require protocol.rooms.manage. Regular protocol users // may only read the minimal active-slot list via /booking-slots/active. test("protocol viewer can only read active slots; admin reads/writes are 403", async () => { const username = uniqueName("slot_viewer"); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Slot Test Viewer', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); created.userIds.push(rows[0].id); for (const r of ["user", "protocol_viewer"]) { await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2 ON CONFLICT DO NOTHING`, [rows[0].id, r], ); } const viewerCookie = await login(username, TEST_PASSWORD); const active = await api( viewerCookie, "GET", "/protocol/booking-slots/active", ); assert.equal(active.status, 200, "viewer should read active slots"); const activeRows = await active.json(); assert.ok(Array.isArray(activeRows)); for (const row of activeRows) { assert.deepEqual( Object.keys(row).sort(), ["daysOfWeek", "durationMinutes", "id", "startTime"], "active endpoint must expose only minimal fields", ); } const getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots"); assert.equal(getSlots.status, 403, "viewer must not read the admin slot list"); const getSettings = await api(viewerCookie, "GET", "/protocol/settings"); assert.equal(getSettings.status, 403, "viewer must not read settings"); const post = await api(viewerCookie, "POST", "/protocol/booking-slots", { startTime: "20:20", }); assert.equal(post.status, 403, "viewer must not create slots"); const gen = await api(viewerCookie, "POST", "/protocol/booking-slots/generate", { startTime: "10:00", endTime: "12:00", durationMinutes: 30, daysOfWeek: [1], }); assert.equal(gen.status, 403, "viewer must not generate slots"); const patch = await api(viewerCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 5, }); assert.equal(patch.status, 403, "viewer must not change settings"); const del = await api( viewerCookie, "DELETE", `/protocol/booking-slots/${slotAId}`, ); assert.equal(del.status, 403, "viewer must not delete slots"); }); test("deleting a slot removes it from the admin list", async () => { const del = await api( adminCookie, "DELETE", `/protocol/booking-slots/${slotBId}`, ); assert.equal(del.status, 204); const list = await api(adminCookie, "GET", "/protocol/booking-slots"); const rows = await list.json(); assert.ok(!rows.some((s) => s.id === slotBId)); }); // --------------------------------------------------------------------------- // Task #710: weekly availability intervals + per-slot days of week. // --------------------------------------------------------------------------- const dayWeekday = new Date(`${day}T00:00:00Z`).getUTCDay(); const otherWeekday = (dayWeekday + 1) % 7; test("slots created without daysOfWeek default to every day (legacy behaviour)", async () => { const list = await api(adminCookie, "GET", "/protocol/booking-slots"); assert.equal(list.status, 200); const a = (await list.json()).find((s) => s.id === slotAId); assert.ok(a, "slot A should still exist"); assert.deepEqual(a.daysOfWeek, [0, 1, 2, 3, 4, 5, 6]); }); test("interval generation creates slots, skips duplicates, validates input", async () => { const gen = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: "18:07", endTime: "19:37", durationMinutes: 30, daysOfWeek: [dayWeekday], }); assert.equal(gen.status, 201); const body = await gen.json(); assert.equal(body.created, 3, "18:07, 18:37, 19:07 should be generated"); assert.equal(body.skipped, 0); assert.deepEqual( body.slots.map((s) => s.startTime).sort(), ["18:07", "18:37", "19:07"], ); for (const s of body.slots) { assert.deepEqual(s.daysOfWeek, [dayWeekday]); } // Re-running the exact same interval generates nothing new. const again = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: "18:07", endTime: "19:37", durationMinutes: 30, daysOfWeek: [dayWeekday], }); assert.equal(again.status, 201); const b2 = await again.json(); assert.equal(b2.created, 0); assert.equal(b2.skipped, 3); // Overlapping an existing start time (SLOT_A) only creates the free ones. const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: SLOT_A, endTime: isoLikeAdd(SLOT_A, 60), durationMinutes: 30, daysOfWeek: [0, 1, 2, 3, 4, 5, 6], }); assert.equal(overlap.status, 201); const b3 = await overlap.json(); assert.equal(b3.skipped, 1, "existing SLOT_A start must be skipped"); assert.equal(b3.created, 1); // Invalid inputs are rejected. const badRange = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: "12:00", endTime: "11:00", durationMinutes: 30, daysOfWeek: [1], }); assert.equal(badRange.status, 400, "from must be before to"); const badDay = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: "11:00", endTime: "12:00", durationMinutes: 30, daysOfWeek: [7], }); assert.equal(badDay.status, 400, "day 7 is invalid"); const tooShort = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { startTime: "11:00", endTime: "11:10", durationMinutes: 30, daysOfWeek: [1], }); assert.equal(tooShort.status, 400); assert.equal((await tooShort.json()).code, "interval_too_short"); }); // "HH:mm" plus minutes, staying inside the same day (test helper). function isoLikeAdd(hhmm, plusMinutes) { const [h, m] = hhmm.split(":").map(Number); const total = h * 60 + m + plusMinutes; return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String( total % 60, ).padStart(2, "0")}`; } test("availability and booking validation filter slots by weekday", async () => { // A slot only offered on a different weekday than `day`. const res = await api(adminCookie, "POST", "/protocol/booking-slots", { startTime: "21:11", durationMinutes: DURATION, daysOfWeek: [otherWeekday], }); assert.equal(res.status, 201); const offDay = await res.json(); assert.deepEqual(offDay.daysOfWeek, [otherWeekday]); // Public availability for `day` must not list the off-day slot but must // still list slot A (all days). const avail = await fetch( `${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`, ); assert.equal(avail.status, 200); const data = await avail.json(); assert.ok( !data.slots.some((s) => s.id === offDay.id), "off-day slot must be hidden for this date", ); assert.ok( data.slots.some((s) => s.id === slotAId), "all-days slot must still be listed", ); // Internal booking on the off-day slot's time is rejected: right start // time, wrong weekday. const wrongDay = await api(adminCookie, "POST", "/protocol/bookings", { roomId, title: "Wrong weekday booking", requesterName: "Staff", startsAt: isoAt("21:11"), endsAt: isoAt("21:11", DURATION), }); assert.equal(wrongDay.status, 400); assert.equal((await wrongDay.json()).code, "invalid_slot"); });