Task #704: أوقات حجز محددة يديرها الأدمن + مهلة حجز مسبقة
- DB: protocol_booking_slots (وقت "HH:mm" بتوقيت الرياض، مدة، تفعيل، ترتيب) + protocol_settings (booking.minLeadMinutes، الافتراضي 60 دقيقة). - API: CRUD /protocol/booking-slots (الكتابة تتطلب protocol.rooms.manage، تكرار الوقت → 409 duplicate_slot)، GET/PATCH /protocol/settings (upsert + سجل تدقيق)، ونقطة عامة /protocol/public/availability (محدّد معدل خاص، تعيد booked/tooSoon/available لكل وقت). - الفرض في الخادم: الطلب العام يجب أن يطابق وقتاً مفعّلاً + يحترم المهلة (400 invalid_slot / lead_time برسائل عربية)؛ الموظفون ملزمون بالأوقات لكن معفيون من المهلة. بدون أوقات مفعّلة يعمل الوضع الحر القديم. - الواجهة: نموذج الطلب العام يعرض شبكة أوقات حسب التوفر (مع fallback قديم)؛ تبويب القاعات يعرض قسم إدارة الأوقات + المهلة لمن يملك canManageRooms؛ حوار الحجز الداخلي يتحول لتاريخ + قائمة أوقات عند وجود أوقات مفعّلة. - i18n: مفاتيح protocol.slots.* بالعربية والإنجليزية. - اختبارات: tests/protocol-booking-slots.test.mjs — 10 اختبارات ناجحة (CRUD/تكرار، الإعدادات، التوفر، invalid_slot، lead_time، تعارض، وقت معطّل، إعفاء الموظفين، صلاحيات القراءة/الكتابة، الحذف). - مراجعة المعماري: PASS؛ عولجت الملاحظات (توثيق قرار الصلاحيات + اختبار regression له، ومنع الإرسال في الواجهة قبل اختيار التاريخ والوقت).
This commit is contained in:
@@ -25,6 +25,8 @@ import {
|
||||
protocolPhotoAlbumsTable,
|
||||
protocolPhotosTable,
|
||||
protocolAuditLogsTable,
|
||||
protocolBookingSlotsTable,
|
||||
protocolSettingsTable,
|
||||
rolesTable,
|
||||
permissionsTable,
|
||||
rolePermissionsTable,
|
||||
@@ -268,6 +270,38 @@ const publicBookingRequestSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
// Bookable time slot management (أوقات الحجز). startTime is a canonical
|
||||
// zero-padded 24h "HH:mm" wall-clock time in Asia/Riyadh.
|
||||
// Postgres unique violation (23505). Drizzle may wrap the pg error in a
|
||||
// DrizzleQueryError, so check the error itself and its `cause`.
|
||||
function isUniqueViolation(e: unknown): boolean {
|
||||
const codeOf = (x: unknown) =>
|
||||
x instanceof Error && "code" in x
|
||||
? (x as { code?: string }).code
|
||||
: undefined;
|
||||
return (
|
||||
codeOf(e) === "23505" ||
|
||||
(e instanceof Error && codeOf((e as { cause?: unknown }).cause) === "23505")
|
||||
);
|
||||
}
|
||||
|
||||
const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const slotCreateSchema = z.object({
|
||||
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
|
||||
durationMinutes: z.number().int().min(5).max(480).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).max(100000).optional(),
|
||||
});
|
||||
const slotPatchSchema = slotCreateSchema
|
||||
.partial()
|
||||
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
|
||||
|
||||
const settingsPatchSchema = z.object({
|
||||
// Minimum advance notice for PUBLIC booking requests, in minutes.
|
||||
// 0 disables the restriction; capped at 7 days.
|
||||
minLeadMinutes: z.number().int().min(0).max(10080),
|
||||
});
|
||||
|
||||
const rejectSchema = z.object({
|
||||
reason: z.string().trim().max(1000).nullable().optional(),
|
||||
});
|
||||
@@ -425,6 +459,80 @@ async function hasBookingConflict(
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Booking slots + lead-time helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIN_LEAD_SETTING_KEY = "booking.minLeadMinutes";
|
||||
const DEFAULT_MIN_LEAD_MINUTES = 60;
|
||||
// Riyadh has no DST, so a fixed +03:00 offset is always correct — but we
|
||||
// still derive the wall-clock time via Intl so the mapping stays exact.
|
||||
const RIYADH_TZ = "Asia/Riyadh";
|
||||
const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: RIYADH_TZ,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
function riyadhHHmm(d: Date): string {
|
||||
return riyadhTimeFmt.format(d);
|
||||
}
|
||||
// Build the absolute Date at which slot `startTime` ("HH:mm" Riyadh wall
|
||||
// clock) begins on `dateStr` ("YYYY-MM-DD", a Riyadh calendar day).
|
||||
function slotStartOn(dateStr: string, startTime: string): Date {
|
||||
return new Date(`${dateStr}T${startTime}:00+03:00`);
|
||||
}
|
||||
|
||||
async function getMinLeadMinutes(executor: DbExecutor): Promise<number> {
|
||||
const [row] = await executor
|
||||
.select({ value: protocolSettingsTable.value })
|
||||
.from(protocolSettingsTable)
|
||||
.where(eq(protocolSettingsTable.key, MIN_LEAD_SETTING_KEY));
|
||||
if (!row) return DEFAULT_MIN_LEAD_MINUTES;
|
||||
const n = Number(row.value);
|
||||
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES;
|
||||
}
|
||||
|
||||
type ActiveSlot = { id: number; startTime: string; durationMinutes: number };
|
||||
async function getActiveSlots(executor: DbExecutor): Promise<ActiveSlot[]> {
|
||||
return executor
|
||||
.select({
|
||||
id: protocolBookingSlotsTable.id,
|
||||
startTime: protocolBookingSlotsTable.startTime,
|
||||
durationMinutes: protocolBookingSlotsTable.durationMinutes,
|
||||
})
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.isActive, true))
|
||||
.orderBy(
|
||||
asc(protocolBookingSlotsTable.sortOrder),
|
||||
asc(protocolBookingSlotsTable.startTime),
|
||||
);
|
||||
}
|
||||
|
||||
// Does [startsAt, endsAt) exactly match one of the configured slots?
|
||||
// Backwards-compat rule: when NO active slots are configured the module
|
||||
// behaves as before (free time input) — enforcement only kicks in once the
|
||||
// admin has defined at least one slot.
|
||||
function matchesActiveSlot(
|
||||
slots: ActiveSlot[],
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
): boolean {
|
||||
if (slots.length === 0) return true;
|
||||
if (startsAt.getUTCSeconds() !== 0 || startsAt.getUTCMilliseconds() !== 0) {
|
||||
return false;
|
||||
}
|
||||
const wall = riyadhHHmm(startsAt);
|
||||
const durationMs = endsAt.getTime() - startsAt.getTime();
|
||||
return slots.some(
|
||||
(s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
const INVALID_SLOT_MESSAGE_AR =
|
||||
"الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة.";
|
||||
const LEAD_TIME_MESSAGE_AR =
|
||||
"لا يمكن الحجز في هذا الوقت، يجب الحجز قبل الموعد بمدة كافية.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -522,6 +630,26 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
// Slot + lead-time enforcement for anonymous guests. When the admin has
|
||||
// configured booking slots, a public request must land exactly on an
|
||||
// active slot; and it must start at least `minLeadMinutes` from now.
|
||||
const [activeSlots, minLeadMinutes] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
]);
|
||||
if (!matchesActiveSlot(activeSlots, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
if (startsAt.getTime() - Date.now() < minLeadMinutes * 60_000) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: LEAD_TIME_MESSAGE_AR, code: "lead_time" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
if (!(await lockRoomRow(tx, body.roomId))) {
|
||||
@@ -596,6 +724,284 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Public availability: which slots can a guest still pick for a room on a
|
||||
// given Riyadh calendar day. Deliberately reveals only per-slot booleans —
|
||||
// never who booked, what, or exact busy ranges beyond the slot grid itself.
|
||||
// Not behind the strict submission limiter (browsing dates must stay cheap),
|
||||
// but still lightly rate-limited.
|
||||
const publicAvailabilityLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""),
|
||||
message: {
|
||||
error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.",
|
||||
code: "rate_limited",
|
||||
},
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/protocol/public/availability",
|
||||
publicAvailabilityLimiter,
|
||||
async (req: Request, res: Response) => {
|
||||
const roomIdRaw = req.query.roomId;
|
||||
const dateRaw = req.query.date;
|
||||
if (typeof roomIdRaw !== "string" || !/^\d+$/.test(roomIdRaw)) {
|
||||
res.status(400).json({ error: "roomId is required", code: "validation" });
|
||||
return;
|
||||
}
|
||||
if (typeof dateRaw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "date must be YYYY-MM-DD", code: "validation" });
|
||||
return;
|
||||
}
|
||||
const roomId = Number(roomIdRaw);
|
||||
const dayStart = slotStartOn(dateRaw, "00:00");
|
||||
if (Number.isNaN(dayStart.getTime())) {
|
||||
res.status(400).json({ error: "invalid date", code: "validation" });
|
||||
return;
|
||||
}
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const [room] = await db
|
||||
.select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
|
||||
.from(protocolRoomsTable)
|
||||
.where(eq(protocolRoomsTable.id, roomId));
|
||||
if (!room || !room.isActive) {
|
||||
res.status(400).json({ error: "room not found", code: "invalid_room" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [slots, minLeadMinutes, busy] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
db
|
||||
.select({
|
||||
startsAt: protocolRoomBookingsTable.startsAt,
|
||||
endsAt: protocolRoomBookingsTable.endsAt,
|
||||
})
|
||||
.from(protocolRoomBookingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(protocolRoomBookingsTable.roomId, roomId),
|
||||
inArray(
|
||||
protocolRoomBookingsTable.status,
|
||||
OCCUPYING_STATUSES as unknown as string[],
|
||||
),
|
||||
sql`${protocolRoomBookingsTable.startsAt} < ${dayEnd.toISOString()}`,
|
||||
sql`${protocolRoomBookingsTable.endsAt} > ${dayStart.toISOString()}`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
const result = slots.map((s) => {
|
||||
const start = slotStartOn(dateRaw, s.startTime);
|
||||
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
|
||||
const booked = busy.some(
|
||||
(b) => b.startsAt < end && b.endsAt > start,
|
||||
);
|
||||
// A slot is "too soon" for a public guest when its start is closer
|
||||
// than the configured lead time (staff are not bound by this).
|
||||
const tooSoon = start.getTime() - now < minLeadMinutes * 60_000;
|
||||
return {
|
||||
id: s.id,
|
||||
startTime: s.startTime,
|
||||
durationMinutes: s.durationMinutes,
|
||||
booked,
|
||||
tooSoon,
|
||||
available: !booked && !tooSoon,
|
||||
};
|
||||
});
|
||||
res.json({ minLeadMinutes, slots: result });
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Booking slots admin (أوقات الحجز) + module settings
|
||||
//
|
||||
// Authorization decision: READS of slots/settings are deliberately open to
|
||||
// any protocol.access holder (not just protocol.rooms.manage) because every
|
||||
// protocol user's booking dialog needs the active slot list, and viewers
|
||||
// need the lead-time value for display. WRITES stay behind
|
||||
// protocol.rooms.manage. Locked in by the authz regression test in
|
||||
// tests/protocol-booking-slots.test.mjs.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get(
|
||||
"/protocol/booking-slots",
|
||||
requireProtocolAccess,
|
||||
async (_req: Request, res: Response) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.orderBy(
|
||||
asc(protocolBookingSlotsTable.sortOrder),
|
||||
asc(protocolBookingSlotsTable.startTime),
|
||||
);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/protocol/booking-slots",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, slotCreateSchema, req.body);
|
||||
if (!body) return;
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(protocolBookingSlotsTable)
|
||||
.values({
|
||||
startTime: body.startTime,
|
||||
durationMinutes: body.durationMinutes ?? 30,
|
||||
isActive: body.isActive ?? true,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
createdBy: req.session.userId!,
|
||||
})
|
||||
.returning();
|
||||
await logAudit(db, {
|
||||
action: "slot.create",
|
||||
entityType: "booking_slot",
|
||||
entityId: row.id,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.status(201).json(row);
|
||||
} catch (e) {
|
||||
// Unique start_time violation → a slot already starts at this time.
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
|
||||
code: "duplicate_slot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/protocol/booking-slots/:id",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const body = parseBody(res, slotPatchSchema, req.body);
|
||||
if (!body) return;
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Not found", code: "not_found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [row] = await db
|
||||
.update(protocolBookingSlotsTable)
|
||||
.set({
|
||||
...(body.startTime !== undefined ? { startTime: body.startTime } : {}),
|
||||
...(body.durationMinutes !== undefined
|
||||
? { durationMinutes: body.durationMinutes }
|
||||
: {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
})
|
||||
.where(eq(protocolBookingSlotsTable.id, id))
|
||||
.returning();
|
||||
await logAudit(db, {
|
||||
action: "slot.update",
|
||||
entityType: "booking_slot",
|
||||
entityId: id,
|
||||
oldValue: existing,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.json(row);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
|
||||
code: "duplicate_slot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/protocol/booking-slots/:id",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Not found", code: "not_found" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.delete(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
await logAudit(db, {
|
||||
action: "slot.delete",
|
||||
entityType: "booking_slot",
|
||||
entityId: id,
|
||||
oldValue: existing,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.status(204).end();
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/protocol/settings",
|
||||
requireProtocolAccess,
|
||||
async (_req: Request, res: Response) => {
|
||||
res.json({ minLeadMinutes: await getMinLeadMinutes(db) });
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/protocol/settings",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, settingsPatchSchema, req.body);
|
||||
if (!body) return;
|
||||
const oldValue = await getMinLeadMinutes(db);
|
||||
await db
|
||||
.insert(protocolSettingsTable)
|
||||
.values({
|
||||
key: MIN_LEAD_SETTING_KEY,
|
||||
value: String(body.minLeadMinutes),
|
||||
updatedBy: req.session.userId!,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: protocolSettingsTable.key,
|
||||
set: {
|
||||
value: String(body.minLeadMinutes),
|
||||
updatedBy: req.session.userId!,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit(db, {
|
||||
action: "settings.update",
|
||||
entityType: "settings",
|
||||
entityId: null,
|
||||
oldValue: { minLeadMinutes: oldValue },
|
||||
newValue: { minLeadMinutes: body.minLeadMinutes },
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.json({ minLeadMinutes: body.minLeadMinutes });
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -772,6 +1178,18 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
// Internal bookings must also land on the configured slot grid (when one
|
||||
// exists), but staff are deliberately NOT bound by the public lead-time
|
||||
// setting — the lead time exists to give the protocol team room to
|
||||
// prepare, and the team itself can book at short notice.
|
||||
const activeSlotsForCreate = await getActiveSlots(db);
|
||||
if (!matchesActiveSlot(activeSlotsForCreate, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-approvers submit requests as "pending"; users with the approve
|
||||
// permission may create an already-approved booking directly. Either way
|
||||
// the slot is checked.
|
||||
@@ -875,6 +1293,18 @@ router.patch(
|
||||
.json({ error: "startsAt must be before endsAt", code: "validation" });
|
||||
return;
|
||||
}
|
||||
// If the edit changes the meeting time, the new time must still land on
|
||||
// the configured slot grid (when one exists). Same staff exemption from
|
||||
// the lead-time rule as on create.
|
||||
if (body.startsAt !== undefined || body.endsAt !== undefined) {
|
||||
const activeSlotsForPatch = await getActiveSlots(db);
|
||||
if (!matchesActiveSlot(activeSlotsForPatch, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
// Lock the (possibly new) room to serialize against concurrent
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Authz decision locked in: any protocol.access holder may READ slots and
|
||||
// settings (the booking dialog needs them), but WRITES require
|
||||
// protocol.rooms.manage.
|
||||
test("protocol viewer can read slots/settings but cannot write them", 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 getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots");
|
||||
assert.equal(getSlots.status, 200, "viewer should read slots");
|
||||
const getSettings = await api(viewerCookie, "GET", "/protocol/settings");
|
||||
assert.equal(getSettings.status, 200, "viewer should 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 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));
|
||||
});
|
||||
Reference in New Issue
Block a user