209 lines
7.1 KiB
JavaScript
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");
|
||
|
|
});
|