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 TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const TEST_PASSWORD = "TestPass123!"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const created = { userIds: [], meetingIds: [], requestIds: [], taskIds: [], }; function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } async function createUser(prefix, roleName) { const username = uniqueName(prefix); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'EM Test', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const id = rows[0].id; created.userIds.push(id); for (const r of ["user", roleName]) { await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2 ON CONFLICT DO NOTHING`, [id, r], ); } return { id, username }; } 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, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, }; if (body !== undefined) init.body = JSON.stringify(body); return fetch(`${API_BASE}${path}`, init); } let adminCookie = null; let adminUserId = null; let coordCookie = null; let coordUserId = null; let leadCookie = null; let leadUserId = null; before(async () => { adminCookie = await login("admin", "admin123"); const meRes = await api(adminCookie, "GET", "/api/auth/me"); const me = await meRes.json(); adminUserId = me.id ?? me.userId; const coord = await createUser("em_coord", "executive_coordinator"); coordUserId = coord.id; coordCookie = await login(coord.username, TEST_PASSWORD); const lead = await createUser("em_lead", "executive_coord_lead"); leadUserId = lead.id; leadCookie = await login(lead.username, TEST_PASSWORD); }); after(async () => { if (created.taskIds.length > 0) { await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [ created.taskIds, ]); } if (created.requestIds.length > 0) { await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [ created.requestIds, ]); } if (created.meetingIds.length > 0) { await pool.query(`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [ created.meetingIds, ]); await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type IN ('meeting','request','task')`, [ created.meetingIds, ]); await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [ created.meetingIds, ]); } if (created.userIds.length > 0) { await pool.query(`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [ created.userIds, ]); await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [ created.userIds, ]); 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.end(); }); const today = new Date().toISOString().slice(0, 10); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); test("GET /me exposes capability flags including canViewAllTasks", async () => { const res = await api(adminCookie, "GET", "/api/executive-meetings/me"); assert.equal(res.status, 200); const body = await res.json(); for (const key of [ "userId", "roles", "canRead", "canMutate", "canApprove", "canSubmitRequest", "canViewAudit", "canViewTasks", "canViewAllTasks", ]) { assert.ok(key in body, `missing ${key} in /me response`); } assert.equal(body.canViewAllTasks, true); const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me"); const coordMe = await coordRes.json(); assert.equal(coordMe.canViewTasks, true); assert.equal(coordMe.canViewAllTasks, false); }); test("Meetings: bilingual title required (titleEn cannot be empty)", async () => { const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "اجتماع", meetingDate: today, }); assert.equal(missingEn.status, 400); const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "اجتماع", titleEn: "", meetingDate: today, }); assert.equal(emptyEn.status, 400); }); test("Meetings: POST → GET day → DELETE roundtrip", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "اجتماع اختبار", titleEn: "Test meeting", meetingDate: today, startTime: "09:00", endTime: "10:00", platform: "none", status: "scheduled", isHighlighted: 0, attendees: [ { name: "Tester One", attendanceType: "internal", sortOrder: 0 }, ], }); assert.equal(create.status, 201); const meeting = await create.json(); assert.ok(meeting.id); created.meetingIds.push(meeting.id); const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); assert.equal(day.status, 200); const dayBody = await day.json(); assert.ok(Array.isArray(dayBody.meetings)); assert.ok(dayBody.meetings.some((m) => m.id === meeting.id)); const del = await api( adminCookie, "DELETE", `/api/executive-meetings/${meeting.id}`, ); assert.ok(del.status === 200 || del.status === 204); }); test("Meetings: PUT /attendees replaces the attendee list", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "ا", titleEn: "A", meetingDate: today, attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }], }); assert.equal(create.status, 201); const meeting = await create.json(); created.meetingIds.push(meeting.id); const replace = await api( adminCookie, "PUT", `/api/executive-meetings/${meeting.id}/attendees`, { attendees: [ { name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 }, { name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 }, ], }, ); assert.equal(replace.status, 200); const fetched = await api( adminCookie, "GET", `/api/executive-meetings/${meeting.id}`, ); const body = await fetched.json(); assert.equal(body.attendees.length, 2); assert.ok(body.attendees.find((a) => a.name === "New One")); assert.ok(!body.attendees.find((a) => a.name === "Old One")); }); test("Meetings: POST /duplicate clones a meeting onto another date", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "ب", titleEn: "B", meetingDate: today, attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }], }); assert.equal(create.status, 201); const original = await create.json(); created.meetingIds.push(original.id); const dup = await api( adminCookie, "POST", `/api/executive-meetings/${original.id}/duplicate`, { targetDate: tomorrow }, ); assert.equal(dup.status, 201); const newRow = await dup.json(); assert.notEqual(newRow.id, original.id); created.meetingIds.push(newRow.id); const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${tomorrow}`, ); const dayBody = await day.json(); assert.ok(dayBody.meetings.some((m) => m.id === newRow.id)); }); test("Requests: POST → admin can list", async () => { const create = await api( adminCookie, "POST", "/api/executive-meetings/requests", { requestType: "note", requestDetails: { note: "Phase-2 test request" }, }, ); assert.equal(create.status, 201); const reqRow = await create.json(); assert.ok(reqRow.id); created.requestIds.push(reqRow.id); const list = await api(adminCookie, "GET", "/api/executive-meetings/requests"); assert.equal(list.status, 200); const listBody = await list.json(); assert.ok(Array.isArray(listBody.requests)); assert.ok(listBody.requests.some((r) => r.id === reqRow.id)); }); test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "ت", titleEn: "T", meetingDate: today, attendees: [], isHighlighted: 0, }); assert.equal(create.status, 201); const meeting = await create.json(); created.meetingIds.push(meeting.id); const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", { requestType: "highlight", meetingId: meeting.id, requestDetails: { note: "highlight-please" }, }); assert.equal(reqRes.status, 201); const reqRow = await reqRes.json(); created.requestIds.push(reqRow.id); const approve = await api( adminCookie, "PATCH", `/api/executive-meetings/requests/${reqRow.id}`, { status: "approved", reviewNotes: "ok" }, ); assert.equal(approve.status, 200); const fetched = await api( adminCookie, "GET", `/api/executive-meetings/${meeting.id}`, ); const body = await fetched.json(); assert.equal(body.isHighlighted, 1); }); test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => { const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { taskType: "follow_up", assignedTo: coordUserId, notes: "for coord", }); assert.equal(t1.status, 201); const task1 = await t1.json(); created.taskIds.push(task1.id); const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { taskType: "follow_up", assignedTo: leadUserId, notes: "for lead", }); assert.equal(t2.status, 201); const task2 = await t2.json(); created.taskIds.push(task2.id); const coordList = await api( coordCookie, "GET", `/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`, ); assert.equal(coordList.status, 200); const coordBody = await coordList.json(); const coordIds = coordBody.tasks.map((t) => t.id); assert.ok(coordIds.includes(task1.id)); assert.ok(!coordIds.includes(task2.id)); for (const t of coordBody.tasks) { assert.equal(t.assignedTo, coordUserId); } const leadList = await api( leadCookie, "GET", `/api/executive-meetings/tasks?assigneeId=${leadUserId}`, ); assert.equal(leadList.status, 200); const leadBody = await leadList.json(); assert.ok(leadBody.tasks.some((t) => t.id === task2.id)); }); test("Tasks: PATCH supports reassign + notes update", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { taskType: "follow_up", assignedTo: coordUserId, notes: "v1", }); assert.equal(create.status, 201); const task = await create.json(); created.taskIds.push(task.id); const patch = await api( adminCookie, "PATCH", `/api/executive-meetings/tasks/${task.id}`, { assignedTo: leadUserId, notes: "v2" }, ); assert.equal(patch.status, 200); const updated = await patch.json(); assert.equal(updated.assignedTo, leadUserId); assert.equal(updated.notes, "v2"); }); test("Audit logs: admin can list, plain coordinator gets 403", async () => { const ok = await api( adminCookie, "GET", "/api/executive-meetings/audit-logs", ); assert.equal(ok.status, 200); const body = await ok.json(); assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? [])); const denied = await api( coordCookie, "GET", "/api/executive-meetings/audit-logs", ); assert.equal(denied.status, 403); }); test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "س", titleEn: "S", meetingDate: today, }); const meeting = await create.json(); created.meetingIds.push(meeting.id); const filtered = await api( adminCookie, "GET", `/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`, ); assert.equal(filtered.status, 200); const body = await filtered.json(); const entries = body.entries ?? body.logs ?? []; assert.ok(Array.isArray(entries)); for (const e of entries) { assert.equal(e.action, "create"); assert.equal(e.entityType, "meeting"); assert.equal(e.performedBy, adminUserId); } assert.ok(entries.some((e) => e.entityId === meeting.id)); }); test("Notifications: GET filters by ?date= and tolerates empty days", async () => { const empty = await api( adminCookie, "GET", `/api/executive-meetings/notifications?date=1990-01-01`, ); assert.equal(empty.status, 200); const emptyBody = await empty.json(); assert.ok(Array.isArray(emptyBody.notifications)); const todayList = await api( adminCookie, "GET", `/api/executive-meetings/notifications?date=${today}`, ); assert.equal(todayList.status, 200); }); test("Font settings: valid combo 200; invalid weight/size/family 400", async () => { const ok = await api( adminCookie, "PATCH", "/api/executive-meetings/font-settings", { scope: "user", fontFamily: "Cairo", fontSize: 16, fontWeight: "bold", alignment: "center", }, ); assert.equal(ok.status, 200); const badWeight = await api( adminCookie, "PATCH", "/api/executive-meetings/font-settings", { scope: "user", fontWeight: "medium" }, ); assert.equal(badWeight.status, 400); const badSize = await api( adminCookie, "PATCH", "/api/executive-meetings/font-settings", { scope: "user", fontSize: 30 }, ); assert.equal(badSize.status, 400); const badFamily = await api( adminCookie, "PATCH", "/api/executive-meetings/font-settings", { scope: "user", fontFamily: "Comic Sans" }, ); assert.equal(badFamily.status, 400); }); test("PDF archives: POST creates a snapshot and GET returns it", async () => { const post = await api( adminCookie, "POST", "/api/executive-meetings/pdf-archives", { archiveDate: today }, ); assert.equal(post.status, 201); const archive = await post.json(); assert.ok(archive.id); assert.ok(archive.version >= 1); const list = await api( adminCookie, "GET", `/api/executive-meetings/pdf-archives?date=${today}`, ); assert.equal(list.status, 200); const body = await list.json(); const archives = body.archives ?? body.items ?? body.pdfArchives ?? []; assert.ok(Array.isArray(archives)); assert.ok(archives.some((a) => a.id === archive.id)); }); test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => { const dirty = 'Hello World' + 'x'; const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: dirty, titleEn: dirty, meetingDate: today, attendees: [ { name: 'Tester One', attendanceType: "internal", sortOrder: 0 }, ], }); assert.equal(create.status, 201); const meeting = await create.json(); created.meetingIds.push(meeting.id); const day = await api(adminCookie, "GET", `/api/executive-meetings?date=${today}`); const body = await day.json(); const m = body.meetings.find((x) => x.id === meeting.id); assert.ok(m, "meeting must come back"); // disallowed tags must be stripped assert.ok(!/', attendanceType: "internal", }, }); assert.equal(reqRes.status, 201); const reqRow = await reqRes.json(); // PATCH /api/executive-meetings/requests/:id with {status:"approved"} // routes through applyApprovedRequest -> add_attendee. const approve = await api(adminCookie, "PATCH", `/api/executive-meetings/requests/${reqRow.id}`, { status: "approved" }); assert.equal(approve.status, 200); const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`); const detail = await det.json(); const att = detail.attendees.at(-1); assert.ok(/]*>Real<\/b>/i.test(att.name), " must survive"); assert.ok(!/