Files
TX/artifacts/api-server/tests/protocol-booking-slots.test.mjs
T
Replit Agent b56f1682a9 Task #710: Weekly availability intervals (Calendly-style) for protocol booking slots
- DB: protocol_booking_slots.days_of_week integer[] NOT NULL default {0..6} (0=Sun..6=Sat, Riyadh); pushed via drizzle-kit, legacy rows keep all-days behaviour.
- API (routes/protocol.ts): slot create/patch accept daysOfWeek (validated, deduped, sorted); new POST /protocol/booking-slots/generate (requireManageRooms) generates stepped slots in [from,to); duplicate rule is day-scoped: same start time on a different day merges the days into the existing slot (existing duration kept), fully covered times skipped; returns {created,merged,skipped,slots}; manual POST create also merges disjoint days instead of 409; matchesActiveSlot and public availability filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), toast shows created/merged/skipped, slot list shows day names, booking dialog filters slots by chosen date weekday with stale-selection reset and empty-day hint. i18n keys added (ar/en).
- Tests: 13/13 pass incl. generation, day-scoped merge (same time different day is NOT a duplicate), duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
2026-07-08 20:20:30 +00:00

649 lines
22 KiB
JavaScript

// 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.merged, 0);
assert.equal(b2.skipped, 3);
// Same start times but a *different* day: not a duplicate — the day is
// merged into the existing slots instead of being skipped.
const otherDayGen = await api(
adminCookie,
"POST",
"/protocol/booking-slots/generate",
{
startTime: "18:07",
endTime: "19:37",
durationMinutes: 30,
daysOfWeek: [otherWeekday],
},
);
assert.equal(otherDayGen.status, 201);
const b2b = await otherDayGen.json();
assert.equal(b2b.created, 0);
assert.equal(b2b.merged, 3, "different day must merge, not skip");
assert.equal(b2b.skipped, 0);
for (const s of b2b.slots) {
assert.deepEqual(s.daysOfWeek, [dayWeekday, otherWeekday].sort((a, b) => a - b));
}
// 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, "SLOT_A already covers all days → skipped");
assert.equal(b3.merged, 0);
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");
// Manual create at the same start time: overlapping day → 409; a
// disjoint day is merged into the existing slot instead.
const dup = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [otherWeekday],
});
assert.equal(dup.status, 409, "same time + same day is a duplicate");
const mergeDay = (otherWeekday + 1) % 7;
const mergedRes = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [mergeDay],
});
assert.equal(mergedRes.status, 201, "same time + new day merges");
const mergedRow = await mergedRes.json();
assert.equal(mergedRow.id, offDay.id, "must merge into the existing slot");
assert.deepEqual(
mergedRow.daysOfWeek,
[otherWeekday, mergeDay].sort((a, b) => a - b),
);
});