Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T
riyadhafraa 389e8b785c #262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections.

Backend
- routes/executive-meetings.ts: deleted /requests* + /tasks* handlers,
  REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES, the three
  capability flags from /me, retired imports, and dead schemas
  (detailsByType, request*Schema, taskCreateSchema, taskPatchSchema,
  dueAtSchema, dateOnly, timeHm). canApprove kept (FontSettings).
- lib/executive-meeting-notify.ts: types collapsed to ['meeting_created'].

Frontend
- pages/executive-meetings.tsx: deleted Requests/Approvals/Tasks
  sections, RequestListRow, retired SECTIONS entries, MeRoles type, and
  unused icon imports.
- hooks/use-notifications-socket.ts: dropped two retired invalidations.
- locales/{ar,en}.json: removed nav + section + 6 retired type keys.

Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables/relations removed.
- scripts/cleanup-em-requests-tasks.sql: idempotent cleanup — orphan
  prefs / notifications / audit rows then DROP TABLE … CASCADE.
  Applied to dev DB; `db push` re-synced.

Tests
- Sequential `node --test --test-concurrency=1` → 226/226 pass.
- 3 pre-existing parallel-file pollution failures in the workflow
  runner are unrelated to #262 (verified by sequential run).
- Pre-existing tsc warnings at routes L509/625/L1594 untouched.
2026-05-01 08:28:11 +00:00

1501 lines
53 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: [],
};
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.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 = 'meeting'`, [
created.meetingIds,
]);
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
created.meetingIds,
]);
}
if (created.userIds.length > 0) {
// Notification prefs are owned by users with ON DELETE CASCADE,
// but be explicit so we don't rely on the cascade if the FK is ever
// changed.
await pool.query(
`DELETE FROM executive_meeting_notification_prefs WHERE user_id = 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 the surviving capability flags", 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",
"canViewAudit",
]) {
assert.ok(key in body, `missing ${key} in /me response`);
}
// The retired flags must not leak back in.
for (const removed of ["canSubmitRequest", "canViewTasks", "canViewAllTasks"]) {
assert.ok(!(removed in body), `unexpected ${removed} in /me response`);
}
assert.equal(body.canApprove, true);
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
const coordMe = await coordRes.json();
assert.equal(coordMe.canRead, true);
assert.equal(coordMe.canApprove, 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"));
});
// --- Attendee payload contract (Task #221) ---------------------------------
// Server is `.strict()` on the attendee schema so client-only fields like
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
// loudly with 400 instead of being silently stripped. This locks down the
// wire contract so a future client refactor that accidentally serializes
// `_sid` (or any other internal field) breaks tests immediately rather
// than being absorbed by lenient parsing.
test("Attendee payload contract: POST rejects unknown fields like `_sid`", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س",
titleEn: "S",
meetingDate: today,
attendees: [
{
name: "Leaky One",
attendanceType: "internal",
sortOrder: 0,
_sid: "client-only-row-id",
},
],
});
assert.equal(
create.status,
400,
`expected 400 when attendee carries _sid, got ${create.status}`,
);
const body = await create.json();
// Zod surfaces the rejected key in the error path.
const serialized = JSON.stringify(body);
assert.ok(
serialized.includes("_sid") || serialized.toLowerCase().includes("unrecognized"),
`expected error to mention the rejected key, got: ${serialized}`,
);
});
test("Attendee payload contract: PATCH /:id rejects unknown attendee fields like `_sid`", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ت",
titleEn: "T",
meetingDate: today,
attendees: [{ name: "Patch Seed", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
attendees: [
{
name: "Leaky Three",
attendanceType: "internal",
sortOrder: 0,
_sid: "client-only-row-id",
},
],
},
);
assert.equal(
patch.status,
400,
`expected 400 when PATCH attendee carries _sid, got ${patch.status}`,
);
// The seed attendee must still be the only one stored — the rejected
// PATCH must not have replaced the attendee list.
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.attendees.length, 1);
assert.equal(body.attendees[0].name, "Patch Seed");
});
test("Attendee payload contract: PUT /attendees rejects unknown fields like `_sid`", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ش",
titleEn: "Sh",
meetingDate: today,
attendees: [{ name: "Seed", 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: "Leaky Two",
attendanceType: "internal",
sortOrder: 0,
_sid: "client-only-row-id",
},
],
},
);
assert.equal(
replace.status,
400,
`expected 400 when PUT attendee carries _sid, got ${replace.status}`,
);
// And the meeting's attendee list must not have changed (the seed row
// still wins, the leaky row never persists).
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.attendees.length, 1);
assert.equal(body.attendees[0].name, "Seed");
});
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));
});
// #189: plain-text sanitization for location / meetingUrl / notes.
test("Sanitization: POST strips HTML from location, meetingUrl, and notes", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ل",
titleEn: "L",
meetingDate: today,
location: '<script>alert("xss")</script>Conference Room A',
meetingUrl:
'<img src=x onerror=alert(1)>https://example.com/meet?a=1&b=2#room',
notes: '<b>bold</b> note: 5 < 10 & ok <script>bad()</script>',
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
assert.ok(!/<script/i.test(meeting.location ?? ""));
assert.ok((meeting.location ?? "").includes("Conference Room A"));
assert.ok(!/<img|onerror/i.test(meeting.meetingUrl ?? ""));
assert.equal(meeting.meetingUrl, "https://example.com/meet?a=1&b=2#room");
assert.ok(!/<script|<b>/i.test(meeting.notes ?? ""));
assert.ok((meeting.notes ?? "").includes("5 < 10"));
assert.ok((meeting.notes ?? "").includes("& ok"));
assert.ok(!/&amp;|&lt;|&gt;/.test(meeting.notes ?? ""));
});
// Regression: encoded payloads must NOT be decoded back into live tags.
test("Sanitization: encoded HTML payloads (&lt;script&gt;…) are NOT decoded into live tags", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ه",
titleEn: "H",
meetingDate: today,
location: "&lt;script&gt;alert('loc')&lt;/script&gt;Boardroom",
meetingUrl:
"&#x3C;script&#x3E;alert('url')&#x3C;/script&#x3E;https://x.test/?a=1&b=2",
notes: "ok &amp; safe — &lt;img onerror=alert(1) src=x&gt;",
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
for (const field of ["location", "meetingUrl", "notes"]) {
const v = meeting[field] ?? "";
assert.ok(!/<script|<img|<iframe|<\/script/i.test(v));
}
assert.ok((meeting.location ?? "").includes("&lt;script&gt;"));
assert.ok((meeting.meetingUrl ?? "").includes("&#x3C;script&#x3E;"));
assert.ok(
(meeting.notes ?? "").includes("&amp;") &&
(meeting.notes ?? "").includes("&lt;img"),
);
const mixed = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "خ",
titleEn: "X",
meetingDate: today,
location: "<ScRiPt>alert(1)</ScRiPt>Boardroom B",
notes: "<IFRAME src=evil>x</IFRAME>visible note",
attendees: [],
});
assert.equal(mixed.status, 201);
const mm = await mixed.json();
created.meetingIds.push(mm.id);
assert.ok(!/<script|<iframe/i.test((mm.location ?? "") + (mm.notes ?? "")));
assert.ok((mm.location ?? "").includes("Boardroom B"));
assert.ok((mm.notes ?? "").includes("visible note"));
});
test("Sanitization: PATCH strips HTML from location, meetingUrl, and notes", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "م",
titleEn: "M",
meetingDate: today,
location: "Original",
notes: "Original note",
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
location: '<script>steal()</script>Updated Room',
meetingUrl: '<a href="javascript:bad()">https://meet.example.com</a>',
notes: '<iframe src="x"></iframe>Updated note',
},
);
assert.equal(patch.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.ok(!/<script/i.test(body.location ?? ""));
assert.ok((body.location ?? "").includes("Updated Room"));
assert.ok(!/<a |<iframe|javascript:/i.test(body.meetingUrl ?? ""));
assert.ok((body.meetingUrl ?? "").includes("https://meet.example.com"));
assert.ok(!/<iframe/i.test(body.notes ?? ""));
assert.ok((body.notes ?? "").includes("Updated note"));
});
// #202: titleEn / titleAr are independent columns; saveTitle in
// executive-meetings.tsx routes by visible i18n.language, so a PATCH
// for one column must never clobber the other.
test("EditableCell contract: PATCH titleEn alone leaves titleAr untouched (and vice versa)", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "العنوان الأصلي",
titleEn: "Original Title",
meetingDate: today,
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const patchEn = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{ titleEn: "<p>Edited English</p>" },
);
assert.equal(patchEn.status, 200);
let fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
let body = await fetched.json();
assert.ok(body.titleEn.includes("Edited English"));
assert.ok(body.titleAr.includes("العنوان الأصلي"));
const patchAr = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{ titleAr: "<p>عنوان معدل</p>" },
);
assert.equal(patchAr.status, 200);
fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
body = await fetched.json();
assert.ok(body.titleAr.includes("عنوان معدل"));
assert.ok(body.titleEn.includes("Edited English"));
});
test("Sanitization: duplicate path re-sanitizes location/meetingUrl/notes and preserves URL ampersands", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "د",
titleEn: "D",
meetingDate: today,
location: "Boardroom 7",
meetingUrl: "https://meet.example.com/x?room=42&token=abc",
notes: "Bring slides & notes",
attendees: [],
});
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 cloned = await dup.json();
created.meetingIds.push(cloned.id);
assert.equal(cloned.meetingUrl, "https://meet.example.com/x?room=42&token=abc");
assert.ok((cloned.notes ?? "").includes("&"));
assert.ok(
!/&amp;/.test(cloned.meetingUrl ?? "") && !/&amp;/.test(cloned.notes ?? ""),
);
});
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("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الميزانية ‪Q2 2026",
titleEn: "Q2 2026 Budget Review",
meetingDate: today,
startTime: "10:00",
endTime: "11:30",
location: "قاعة كبرى",
attendees: [
{ name: "أحمد العلي", title: "المدير التنفيذي",
attendanceType: "internal", sortOrder: 0 },
{ name: "John Smith", title: "CFO",
attendanceType: "external", sortOrder: 1 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
// Bad date → 400
const bad = await api(adminCookie, "GET",
"/api/executive-meetings/pdf?date=not-a-date");
assert.equal(bad.status, 400);
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
assert.equal(noAuth.status, 401);
// Happy path: real PDF response with non-trivial body.
const res = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") || "",
/^application\/pdf/,
"must serve application/pdf",
);
assert.match(
res.headers.get("content-disposition") || "",
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
"filename should include the requested date",
);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
assert.equal(
buf.subarray(0, 4).toString("ascii"),
"%PDF",
"body must start with PDF magic bytes",
);
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const archives = (await list.json()).archives ?? [];
const generated = archives.find(
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
);
assert.ok(
generated,
"PDF download must produce an archive row with the served byte_size",
);
assert.ok(generated.generatedBy, "generatedBy must be recorded");
assert.ok(
typeof generated.filePath === "string" && generated.filePath.length > 0,
"filePath/storage_url must be non-empty",
);
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
assert.equal(empty.status, 200);
const emptyBuf = Buffer.from(await empty.arrayBuffer());
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
// PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(setSans.status, 200);
const sansRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(sansRes.status, 200);
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"Cairo family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"Cairo family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Noto Naskh Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setNaskh.status, 200);
const naskhRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(naskhRes.status, 200);
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
);
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
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(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Sanitization: attendee.title is stripped of HTML on every write path", async () => {
const malicious = '<script>alert(1)</script><b onclick="x">CFO</b>';
// POST creates the meeting with a malicious title on one attendee.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "تنظيف العنوان",
titleEn: "Title sanitization",
meetingDate: today,
attendees: [
{
name: "Attendee POST",
title: malicious,
attendanceType: "internal",
sortOrder: 0,
},
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const postedTitle = meeting.attendees[0].title;
assert.ok(postedTitle, "title must round-trip");
assert.ok(!/<script/i.test(postedTitle), "POST: <script> stripped from title");
assert.ok(!/<b/i.test(postedTitle), "POST: <b> stripped from title");
assert.ok(!/onclick/i.test(postedTitle), "POST: onclick stripped from title");
assert.ok(/CFO/.test(postedTitle), "POST: visible text preserved in title");
// PATCH replaces attendees with a different malicious title.
const patched = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
attendees: [
{
name: "Attendee PATCH",
title: '<img src=x onerror=alert(1)>Director',
attendanceType: "internal",
sortOrder: 0,
},
],
},
);
assert.equal(patched.status, 200);
const patchedBody = await patched.json();
const patchedTitle = patchedBody.attendees[0].title;
assert.ok(!/<img/i.test(patchedTitle), "PATCH: <img> stripped from title");
assert.ok(!/onerror/i.test(patchedTitle), "PATCH: onerror stripped from title");
assert.ok(/Director/.test(patchedTitle), "PATCH: visible text preserved");
// PUT /attendees replaces the list and must sanitize too.
const put = await api(
adminCookie,
"PUT",
`/api/executive-meetings/${meeting.id}/attendees`,
{
attendees: [
{
name: "Attendee PUT",
title: '<a href="javascript:bad()">Manager</a>',
attendanceType: "virtual",
sortOrder: 0,
},
],
},
);
assert.equal(put.status, 200);
const putBody = await put.json();
const putTitle = putBody.attendees[0].title;
assert.ok(!/<a/i.test(putTitle), "PUT: <a> stripped from title");
assert.ok(!/javascript:/i.test(putTitle), "PUT: javascript: scheme gone");
assert.ok(/Manager/.test(putTitle), "PUT: visible text preserved");
// Duplicate path: copy of a meeting with a sanitized title must stay sanitized.
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${meeting.id}/duplicate`,
{ targetDate: today },
);
assert.equal(dup.status, 201);
const dupBody = await dup.json();
created.meetingIds.push(dupBody.id);
const dupTitle = dupBody.attendees[0].title;
assert.ok(!/<a/i.test(dupTitle), "duplicate: title remains free of HTML tags");
assert.ok(!/<script/i.test(dupTitle), "duplicate: no <script> in copied title");
assert.ok(/Manager/.test(dupTitle), "duplicate: visible text preserved");
});
test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${M.id}`, { titleAr: html });
assert.equal(patch.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const got = body.meetings.find((x) => x.id === M.id);
assert.ok(got, "meeting must come back");
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
"right-aligned <p> survives sanitization");
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
"center-aligned <p> survives sanitization");
});
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
const reorderDate = "2050-01-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
assert.equal(cAfter.dailyNumber, 1);
assert.equal(bAfter.dailyNumber, 2);
assert.equal(aAfter.dailyNumber, 3);
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
const auditRows = await pool.query(
`SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'executive_meeting.reorder'
AND entity_id = ANY($1::int[])
ORDER BY id DESC LIMIT 1`,
[[A.id, B.id, C.id]],
);
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
assert.equal(auditRows.rows[0].entity_type, "meeting");
const newOrder = auditRows.rows[0].new_value?.order;
assert.deepEqual(newOrder, [C.id, B.id, A.id],
"audit new_value.order must echo the new ordering");
});
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
const reorderDate = "2050-04-04";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
});
const A = await a.json(); created.meetingIds.push(A.id);
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
});
const B = await b.json(); created.meetingIds.push(B.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "duplicate_ids",
"duplicate ids must be reported with the duplicate_ids code");
});
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
const reorderDate = "2050-02-20";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "08:00", endTime: "08:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
// Send only one of the two — server must refuse so 1..N renumber stays safe.
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "incomplete_day");
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
});
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "غدًا", titleEn: "Tomorrow",
meetingDate: new Date(Date.now() + 86400000)
.toISOString().slice(0, 10),
});
const S = await sameDay.json(); created.meetingIds.push(S.id);
const O = await other.json(); created.meetingIds.push(O.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [S.id, O.id] });
assert.equal(r.status, 400);
});
test("Reorder: user without executive role is denied (403)", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا", titleEn: "A", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser("em_plain", "user");
const plainCookie = await login(plain.username, TEST_PASSWORD);
const r = await api(plainCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [M.id] });
assert.equal(r.status, 403);
});
// ---------------------------------------------------------------------
// Phase-2 task #112 additions:
// - per-role meeting CRUD permissions
// - request reject / withdraw flows
// - task assignee-only status updates
// - font-settings PATCH/GET roundtrip
// - non-numeric :id returns 404 (router.param guard) instead of crashing
// - audit-insert failure rolls back the parent mutation (transactional)
// ---------------------------------------------------------------------
test("Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed", async () => {
// executive_coordinator is in REQUEST_ROLES but NOT in MUTATE_ROLES, so
// a direct POST/PATCH/DELETE on /executive-meetings must come back 403.
const coordCreate = await api(coordCookie, "POST", "/api/executive-meetings", {
titleAr: "م", titleEn: "M", meetingDate: today,
});
assert.equal(coordCreate.status, 403,
"executive_coordinator must not be able to POST a meeting");
// executive_coord_lead is in MUTATE_ROLES, so the same call must succeed.
const leadCreate = await api(leadCookie, "POST", "/api/executive-meetings", {
titleAr: "ل", titleEn: "L", meetingDate: today,
});
assert.equal(leadCreate.status, 201,
"executive_coord_lead must be able to POST a meeting");
const leadMeeting = await leadCreate.json();
created.meetingIds.push(leadMeeting.id);
// Coordinator can still GET (read role) but cannot PATCH or DELETE.
const coordRead = await api(coordCookie, "GET",
`/api/executive-meetings/${leadMeeting.id}`);
assert.equal(coordRead.status, 200,
"coordinator should still be allowed to read meeting details");
const coordPatch = await api(coordCookie, "PATCH",
`/api/executive-meetings/${leadMeeting.id}`, { notes: "no" });
assert.equal(coordPatch.status, 403,
"coordinator must not PATCH meetings directly");
const coordDelete = await api(coordCookie, "DELETE",
`/api/executive-meetings/${leadMeeting.id}`);
assert.equal(coordDelete.status, 403,
"coordinator must not DELETE meetings directly");
// Lead can mutate.
const leadPatch = await api(leadCookie, "PATCH",
`/api/executive-meetings/${leadMeeting.id}`, { notes: "ok" });
assert.equal(leadPatch.status, 200,
"executive_coord_lead must be able to PATCH meetings");
});
test("Font settings: PUT then GET returns the user-scoped row roundtrip", async () => {
const put = await api(adminCookie, "PUT",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "Tajawal",
fontSize: 18,
fontWeight: "bold",
alignment: "center",
});
assert.equal(put.status, 200);
const get = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
assert.equal(get.status, 200);
const body = await get.json();
assert.ok(body.user, "GET must include the user-scoped row");
assert.equal(body.user.scope, "user");
assert.equal(body.user.fontFamily, "Tajawal");
assert.equal(body.user.fontSize, 18);
assert.equal(body.user.fontWeight, "bold");
assert.equal(body.user.alignment, "center");
// PATCH alias must hit the same upsert handler.
const patch = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "Cairo",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
});
assert.equal(patch.status, 200);
const get2 = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
const body2 = await get2.json();
assert.equal(body2.user.fontFamily, "Cairo");
assert.equal(body2.user.fontSize, 14);
assert.equal(body2.user.fontWeight, "regular");
assert.equal(body2.user.alignment, "start");
});
test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => {
// The router.param("id", ...) guard in executive-meetings.ts must reject
// non-digit ids by calling next("route"), so Express falls through to 404
// instead of attempting Number("abc") and crashing further down.
const cases = [
["GET", "/api/executive-meetings/abc"],
["PATCH", "/api/executive-meetings/abc"],
["DELETE", "/api/executive-meetings/abc"],
["GET", "/api/executive-meetings/123abc"],
["GET", "/api/executive-meetings/-1"],
["POST", "/api/executive-meetings/abc/duplicate"],
["PUT", "/api/executive-meetings/abc/attendees"],
];
for (const [method, path] of cases) {
const res = await api(adminCookie, method, path,
method === "GET" || method === "DELETE" ? undefined : {});
assert.equal(res.status, 404,
`${method} ${path} should 404 (got ${res.status})`);
// Express's default 404 fallthrough returns an HTML "Cannot METHOD
// /path" page (text/html), so we assert that the body is *not* the
// pretty-printed JSON of an unhandled exception. Whichever Express
// produces, it must not be a 5xx HTML error stack trace.
const body = await res.text();
assert.ok(!/<pre>Error:/i.test(body),
`${method} ${path} body must not contain a thrown Error stack`);
assert.ok(!/TypeError|ReferenceError/i.test(body),
`${method} ${path} body must not contain a JS exception name`);
}
});
test("Transactional safety: a failing audit insert rolls back the parent DELETE", async () => {
// Create a meeting we can target.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اختبار-صفقة", titleEn: "Tx Rollback Target", meetingDate: today,
});
assert.equal(create.status, 201);
const meeting = await create.json();
// Do NOT push to created.meetingIds yet — we want to verify the row is
// still there after the deliberately-failing DELETE, then clean it up
// ourselves.
const meetingId = meeting.id;
// Install a BEFORE INSERT trigger on the audit log table that raises an
// exception only when our specific (entity_type='meeting', action='delete',
// entity_id=<this meeting>) row is being inserted. Other audit inserts —
// including the ones that other tests run in parallel/sequence — are
// unaffected.
const triggerName = `tx_rollback_test_${meetingId}`;
const fnName = `tx_rollback_test_fn_${meetingId}`;
await pool.query(`
CREATE OR REPLACE FUNCTION ${fnName}() RETURNS trigger AS $$
BEGIN
IF NEW.entity_type = 'meeting'
AND NEW.action = 'delete'
AND NEW.entity_id = ${meetingId} THEN
RAISE EXCEPTION 'forced audit failure for tx rollback test';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await pool.query(`
CREATE TRIGGER ${triggerName}
BEFORE INSERT ON executive_meeting_audit_logs
FOR EACH ROW EXECUTE FUNCTION ${fnName}();
`);
let deleteStatus = null;
try {
const del = await api(adminCookie, "DELETE",
`/api/executive-meetings/${meetingId}`);
deleteStatus = del.status;
} finally {
// Always tear the trigger + function down so the rest of the test file
// (and any other test runs) are not poisoned.
await pool.query(`DROP TRIGGER IF EXISTS ${triggerName} ON executive_meeting_audit_logs`);
await pool.query(`DROP FUNCTION IF EXISTS ${fnName}()`);
}
assert.equal(deleteStatus, 500,
"DELETE must surface a 500 when the audit insert raises");
// The meeting row must still exist — the surrounding db.transaction()
// should have rolled the DELETE back when the audit insert failed.
const stillThere = await pool.query(
`SELECT id FROM executive_meetings WHERE id = $1`,
[meetingId],
);
assert.equal(stillThere.rowCount, 1,
"parent DELETE must be rolled back when audit insert fails");
// Now clean up for real — register the id so afterAll deletes it.
created.meetingIds.push(meetingId);
});
test("Notification prefs: GET defaults every event type to in-app + email on", async () => {
// Use a brand-new coordinator so we know there are no pre-existing rows.
const fresh = await createUser("em_pref_a", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const res = await api(cookie, "GET", "/api/executive-meetings/notification-prefs");
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.types) && body.types.length > 0,
"response must include the canonical types list");
assert.ok(Array.isArray(body.prefs) && body.prefs.length === body.types.length,
"response must return one merged pref per canonical type");
for (const p of body.prefs) {
assert.equal(p.inApp, true, `default inApp must be true for ${p.notificationType}`);
assert.equal(p.email, true, `default email must be true for ${p.notificationType}`);
}
});
test("Notification prefs: PUT upserts and is reflected in subsequent GET", async () => {
const fresh = await createUser("em_pref_b", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
// Pick a known type from the canonical list returned by GET.
const initial = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const targetType = initial.types[0];
const put = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: targetType, inApp: false, email: true }] });
assert.equal(put.status, 200);
const putBody = await put.json();
assert.equal(putBody.ok, true);
assert.equal(putBody.count, 1);
const after = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const row = after.prefs.find((p) => p.notificationType === targetType);
assert.ok(row, "updated pref must come back in GET");
assert.equal(row.inApp, false);
assert.equal(row.email, true);
// Other types must remain at default (true / true).
for (const p of after.prefs) {
if (p.notificationType === targetType) continue;
assert.equal(p.inApp, true, `${p.notificationType} should remain default-on`);
assert.equal(p.email, true, `${p.notificationType} should remain default-on`);
}
// Re-PUT the same type should overwrite (upsert), not duplicate.
const put2 = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: targetType, inApp: true, email: false }] });
assert.equal(put2.status, 200);
const after2 = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const row2 = after2.prefs.find((p) => p.notificationType === targetType);
assert.equal(row2.inApp, true);
assert.equal(row2.email, false);
});
test("Notification prefs: PUT rejects unknown notificationType with 400", async () => {
const fresh = await createUser("em_pref_c", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const bad = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: "totally_made_up", inApp: false, email: false }] });
assert.equal(bad.status, 400);
});
test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to defaults", async () => {
// #236: clicking "Restore defaults" in the UI hits DELETE. After it
// returns, every event type must read default-on regardless of what
// had been muted before, and a follow-up fan-out must reach the user
// again. Use a fresh approver so we don't disturb other prefs tests.
const fresh = await createUser("em_pref_restore", "executive_office_manager");
const cookie = await login(fresh.username, TEST_PASSWORD);
const put = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{
prefs: [
{ notificationType: "meeting_created", inApp: false, email: false },
],
});
assert.equal(put.status, 200);
const { rows: pre } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $1`,
[fresh.id],
);
assert.equal(pre[0].n, 1, "PUT must have left 1 pref row");
const del = await api(cookie, "DELETE",
"/api/executive-meetings/notification-prefs");
assert.equal(del.status, 200);
const delBody = await del.json();
assert.equal(delBody.ok, true);
assert.equal(delBody.count, 1, "DELETE must report wiping the row");
const { rows: post } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $1`,
[fresh.id],
);
assert.equal(post[0].n, 0, "no pref rows must remain after DELETE");
// GET must now synthesize default-on for every type.
const after = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
for (const p of after.prefs) {
assert.equal(p.inApp, true, `${p.notificationType} must be default-on after DELETE`);
assert.equal(p.email, true, `${p.notificationType} must be default-on after DELETE`);
}
// And actual fan-out must reach the user again — a regression in the
// DELETE handler that left rows behind would silently re-mute them.
const beforeCount = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $1
AND notification_type = 'meeting_created'`,
[fresh.id],
);
const submit = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "إخطار-إعادة-الافتراضات",
titleEn: "Restore-defaults fan-out",
meetingDate: today,
attendees: [],
});
assert.equal(submit.status, 201);
const submittedMeeting = await submit.json();
created.meetingIds.push(submittedMeeting.id);
// Give the fan-out a moment to land before counting.
await new Promise((r) => setTimeout(r, 250));
const afterCount = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $1
AND notification_type = 'meeting_created'`,
[fresh.id],
);
assert.ok(
afterCount.rows[0].n > beforeCount.rows[0].n,
"user with no pref rows (post-DELETE) must receive new meeting_created rows",
);
});
test("Notification prefs: DELETE on a user with no rows is a 200 no-op", async () => {
// Cheap idempotence check — clicking Restore defaults twice in a row
// must not 500 or surface a misleading error.
const fresh = await createUser("em_pref_restore_noop", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const del = await api(cookie, "DELETE",
"/api/executive-meetings/notification-prefs");
assert.equal(del.status, 200);
const body = await del.json();
assert.equal(body.ok, true);
assert.equal(body.count, 0);
});