Files
TX/artifacts/api-server/tests/protocol-rooms-stats.test.mjs
T
Replit Agent 89331004f3 Task #714: rooms management submenu + rooms dashboard
- protocol.tsx: added collapsible "إدارة القاعات" nav group (giftsGroup pattern)
  containing لوحة القاعات (roomsDashboard, new), القاعات (rooms),
  حجز القاعات (bookings), إعدادات الحجوزات (bookingSettings, canManageRooms only).
- Deep-link slugs: /protocol/rooms-dashboard, /protocol/booking-settings.
- SlotsAdminSection moved from rooms tab to booking-settings tab; non-manager
  deep-link shows no-access empty state.
- New RoomsDashboardView (recharts): KPI cards, next-booking countdown,
  14/30-day area chart, by-room bar chart, by-status pie.
- API: new GET /api/protocol/rooms-stats (requireProtocolAccess), Riyadh-tz
  day boundaries, 30-day zero-filled daily series, byRoom/byStatus, nextBooking.
- i18n keys ar/en (tabs + protocol.roomsDash.* with Arabic plurals).
- Tests: artifacts/api-server/tests/protocol-rooms-stats.test.mjs (2 pass).
- Architect review passed; typechecks clean (pre-existing errors in
  executive-meetings.ts/push.ts untouched).
2026-07-08 20:39:29 +00:00

209 lines
7.1 KiB
JavaScript

// 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");
});