// API contract tests for GET /protocol/rooms-stats (لوحة بيانات القاعات). // Covers: // 1. Auth guard: unauthenticated request -> 401. // 2. Shape: rooms{active,inactive}, todayBookings, weekBookings, // pendingBookings, nextBooking, daily (30 zero-filled Riyadh days), // byRoom, byStatus. // 3. Counters reflect seeded data: a pending booking tomorrow shows up in // weekBookings + pendingBookings + nextBooking + byRoom/byStatus; // a cancelled booking is excluded from occupancy counters. // // Seeded rows are deleted in `after`; no global state is mutated. 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 = { 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)}`; } async function createSuperAdmin() { const username = uniqueName("stats_admin"); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Stats 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, id: rows[0].id }; } 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) { return fetch(`${API_BASE}/api${path}`, { method, headers: cookie ? { Cookie: cookie } : {}, }); } let adminCookie = null; let adminId = null; let roomId = null; let baseline = null; before(async () => { const admin = await createSuperAdmin(); adminId = admin.id; adminCookie = await login(admin.username, TEST_PASSWORD); // Baseline snapshot before seeding, so assertions are delta-based and // robust against pre-existing data. const res = await api(adminCookie, "GET", "/protocol/rooms-stats"); assert.equal(res.status, 200); baseline = await res.json(); const room = await pool.query( `INSERT INTO protocol_rooms (name_ar, name_en, capacity, is_active) VALUES ($1, $2, 10, true) RETURNING id`, [uniqueName("قاعة_stats"), uniqueName("StatsRoom")], ); roomId = room.rows[0].id; created.roomIds.push(roomId); // Pending booking tomorrow 10:00-11:00 Riyadh (counts everywhere). const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const b1 = await pool.query( `INSERT INTO protocol_room_bookings (room_id, title, starts_at, ends_at, status, created_by, booking_reference) VALUES ($1, $2, $3::timestamptz, $4::timestamptz, 'pending', $5, $6) RETURNING id`, [ roomId, "Stats test booking", `${tomorrow}T10:00:00+03:00`, `${tomorrow}T11:00:00+03:00`, adminId, uniqueName("REF"), ], ); created.bookingIds.push(b1.rows[0].id); // Cancelled booking tomorrow (must NOT count in week/pending/nextBooking). const b2 = await pool.query( `INSERT INTO protocol_room_bookings (room_id, title, starts_at, ends_at, status, created_by, booking_reference) VALUES ($1, $2, $3::timestamptz, $4::timestamptz, 'cancelled', $5, $6) RETURNING id`, [ roomId, "Stats cancelled booking", `${tomorrow}T12:00:00+03:00`, `${tomorrow}T13:00:00+03:00`, adminId, uniqueName("REF"), ], ); created.bookingIds.push(b2.rows[0].id); }); after(async () => { if (created.bookingIds.length) { await pool.query(`DELETE FROM protocol_room_bookings WHERE id = ANY($1)`, [ created.bookingIds, ]); } if (created.roomIds.length) { await pool.query(`DELETE FROM protocol_rooms WHERE id = ANY($1)`, [ created.roomIds, ]); } if (created.userIds.length) { await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1)`, [ created.userIds, ]); await pool.query(`DELETE FROM users WHERE id = ANY($1)`, [ created.userIds, ]); } await pool.end(); }); test("unauthenticated request is rejected", async () => { const res = await api(null, "GET", "/protocol/rooms-stats"); assert.equal(res.status, 401); }); test("stats shape and seeded counters", async () => { const res = await api(adminCookie, "GET", "/protocol/rooms-stats"); assert.equal(res.status, 200); const s = await res.json(); // Shape assert.equal(typeof s.rooms.active, "number"); assert.equal(typeof s.rooms.inactive, "number"); assert.equal(typeof s.todayBookings, "number"); assert.equal(typeof s.weekBookings, "number"); assert.equal(typeof s.pendingBookings, "number"); assert.ok(Array.isArray(s.daily)); assert.equal(s.daily.length, 30, "daily series is zero-filled to 30 days"); for (const d of s.daily) { assert.match(d.date, /^\d{4}-\d{2}-\d{2}$/); assert.equal(typeof d.count, "number"); } assert.ok(Array.isArray(s.byRoom)); assert.ok(Array.isArray(s.byStatus)); // Deltas vs baseline assert.equal(s.rooms.active, baseline.rooms.active + 1); assert.equal(s.weekBookings, baseline.weekBookings + 1); assert.equal(s.pendingBookings, baseline.pendingBookings + 1); // nextBooking exists and starts in the future; if it is ours, check names. assert.ok(s.nextBooking, "nextBooking should exist after seeding"); assert.ok(new Date(s.nextBooking.startsAt).getTime() > Date.now()); assert.ok(["pending", "approved"].includes(s.nextBooking.status)); // Seeded room appears in byRoom with 2 bookings (pending + cancelled are // both inside the 30-day window? cancelled tomorrow is OUTSIDE the window // [today-29d, today+1d) only the ones starting before end of today count). // Tomorrow's bookings start after endOfToday, so byRoom must NOT include // the seeded room unless it had past bookings. const seeded = s.byRoom.find((r) => r.roomId === roomId); assert.equal(seeded, undefined, "future bookings are outside the 30-day window"); });