2026-07-08 14:43:44 +00:00
|
|
|
// 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, is_active, sort_order, created_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
|
|
|
[
|
|
|
|
|
s.id,
|
|
|
|
|
s.start_time,
|
|
|
|
|
s.duration_minutes,
|
|
|
|
|
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("staff bookings must match a slot but ignore the lead time", async () => {
|
|
|
|
|
// Restore a large lead time; staff must NOT be blocked by it.
|
|
|
|
|
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 onSlot = 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(
|
|
|
|
|
onSlot.status,
|
|
|
|
|
201,
|
|
|
|
|
"staff on-slot booking should succeed despite lead time",
|
|
|
|
|
);
|
|
|
|
|
created.bookingIds.push((await onSlot.json()).id);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 14:48:11 +00:00
|
|
|
// 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 () => {
|
2026-07-08 14:43:44 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-07-08 14:48:11 +00:00
|
|
|
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(),
|
|
|
|
|
["durationMinutes", "id", "startTime"],
|
|
|
|
|
"active endpoint must expose only minimal fields",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
const getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots");
|
2026-07-08 14:48:11 +00:00
|
|
|
assert.equal(getSlots.status, 403, "viewer must not read the admin slot list");
|
2026-07-08 14:43:44 +00:00
|
|
|
const getSettings = await api(viewerCookie, "GET", "/protocol/settings");
|
2026-07-08 14:48:11 +00:00
|
|
|
assert.equal(getSettings.status, 403, "viewer must not read settings");
|
2026-07-08 14:43:44 +00:00
|
|
|
|
|
|
|
|
const post = await api(viewerCookie, "POST", "/protocol/booking-slots", {
|
|
|
|
|
startTime: "20:20",
|
|
|
|
|
});
|
|
|
|
|
assert.equal(post.status, 403, "viewer must not create 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));
|
|
|
|
|
});
|