Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T
riyadhafraa a34eb5f56d Task #588: PDF uses real DIN Next LT Arabic (and other branded fonts)
The HTML/puppeteer PDF renderer in
artifacts/api-server/src/lib/pdf-html-renderer.ts mapped every branded
sans family to NotoSansArabic-*.ttf — so even though the user picked
"DIN Next LT Arabic" in settings, the embedded glyph shapes were Noto
Sans Arabic. Same silent substitution applied to Tajawal, Helvetica
Neue LT Arabic, and Majalla. (pdf-renderer.ts was already wired
correctly to the real files in an earlier change; pdf-html-renderer.ts
was missed.)

Fix in pdf-html-renderer.ts FONT_FILES:
  DIN Next LT Arabic       → DINNextLTArabic-Regular/Bold.ttf
  Tajawal                  → Tajawal-Regular/Bold.ttf
  Helvetica Neue LT Arabic → HelveticaNeueLTArabic-Regular/Bold.ttf
  Majalla                  → Majalla-Regular/Bold.ttf
  system                   → NotoNaskhArabic-Regular/Bold.ttf (unchanged)
  Helvetica Neue           → HelveticaNeue-Regular/Bold.ttf (unchanged)

All target .ttf files were already bundled in
artifacts/api-server/assets/fonts/ — no asset changes needed.

Updated the matching test assertions in
artifacts/api-server/tests/executive-meetings.test.mjs so the
DIN Next LT Arabic path now expects /DINNextLTArabic/ in the PDF
bytes, and the Majalla path expects /Majalla/ instead of
/NotoNaskhArabic/. Kept the negative assertion that Majalla never
embeds a sans face.

API workflow restarted clean. Mac rebuild:
  cd ~/Downloads/TX && git pull && docker compose up -d --build api
2026-05-18 10:26:22 +00:00

2861 lines
106 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 zlib from "node:zlib";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
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",
"canEditGlobalFontSettings",
"canViewAudit",
]) {
assert.ok(key in body, `missing ${key} in /me response`);
}
for (const removed of [
"canApprove",
"canSubmitRequest",
"canViewTasks",
"canViewAllTasks",
]) {
assert.ok(!(removed in body), `unexpected ${removed} in /me response`);
}
assert.equal(body.canEditGlobalFontSettings, true);
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
const coordMe = await coordRes.json();
assert.equal(coordMe.canRead, true);
assert.equal(coordMe.canEditGlobalFontSettings, 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 ---------------------------------
// 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: "DIN Next LT Arabic",
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("Font settings: user-scope rejects logoObjectPath with 400; null is allowed", async () => {
const rejected = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: "/objects/some-fake-id",
},
);
assert.equal(rejected.status, 400);
const body = await rejected.json();
assert.equal(body.code, "logo_user_scope_forbidden");
// null is allowed on user rows.
const okNull = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: null,
},
);
assert.equal(okNull.status, 200);
});
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 branded sans family ("DIN Next LT Arabic") embeds the real
// DINNextLTArabic face, while the Naskh-style family ("Majalla")
// embeds the real Majalla face. The PostScript name appears
// verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "DIN Next LT Arabic",
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(
/DINNextLTArabic/i.test(sansAscii),
"DIN Next LT Arabic family must embed DINNextLTArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"DIN Next LT Arabic family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Majalla",
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(
/Majalla/i.test(naskhAscii),
"Majalla family must embed Majalla in the PDF",
);
assert.ok(
!/NotoSansArabic|DINNextLTArabic/i.test(naskhAscii),
"Majalla family must NOT embed a sans face",
);
// Exhaustively cover every remaining family in the FONT_FILES map so a
// future silent stand-in regresses loudly. Each entry pairs the user-
// facing family name with the PostScript-name fragment we expect to
// see embedded verbatim in the PDF font dictionary.
const extraFamilyCases = [
{ family: "Tajawal", expect: /Tajawal/i, forbid: /NotoSansArabic|DINNextLTArabic/i },
{
family: "Helvetica Neue LT Arabic",
expect: /HelveticaNeueLTArabic/i,
forbid: /NotoSansArabic|DINNextLTArabic/i,
},
{ family: "system", expect: /NotoNaskhArabic/i, forbid: /NotoSansArabic|DINNextLTArabic/i },
];
for (const { family, expect, forbid } of extraFamilyCases) {
const setRes = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: family,
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setRes.status, 200, `${family}: PUT font-settings`);
const pdfRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(pdfRes.status, 200, `${family}: GET pdf`);
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
assert.equal(
pdfBuf.subarray(0, 4).toString("ascii"),
"%PDF",
`${family}: %PDF magic`,
);
const ascii = pdfBuf.toString("latin1");
assert.ok(expect.test(ascii), `${family} must embed ${expect}`);
assert.ok(!forbid.test(ascii), `${family} must NOT embed ${forbid}`);
}
});
test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor", async () => {
// Inflate FlateDecode streams and assert title/tint/fontColor ops.
const zlib = await import("node:zlib");
const decodeStreams = (buf) => {
const out = [];
let cursor = 0;
while (true) {
const start = buf.indexOf("stream\n", cursor);
if (start < 0) break;
const end = buf.indexOf("\nendstream", start);
if (end < 0) break;
try {
out.push(
zlib
.inflateSync(buf.slice(start + "stream\n".length, end))
.toString("latin1"),
);
} catch {
// not a deflate stream (font/image bytes) — skip
}
cursor = end + "\nendstream".length;
}
return out.join("\n");
};
const pdfDate = "2099-05-03";
const ambered = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع تجريبي",
titleEn: "Tinted Meeting",
meetingDate: pdfDate,
attendees: [{ name: "ع", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(ambered.status, 201);
const amberedId = (await ambered.json()).id;
created.meetingIds.push(amberedId);
const tint = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${amberedId}`,
{ rowColor: "amber" },
);
assert.equal(tint.status, 200);
const highlighted = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع آخر",
titleEn: "Highlighted Meeting",
meetingDate: pdfDate,
isHighlighted: true,
attendees: [{ name: "ب", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(highlighted.status, 201);
created.meetingIds.push((await highlighted.json()).id);
// #336699 → 0.2/0.4/0.6 in PDFKit's rg op.
const setColor = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Majalla",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#336699",
},
);
assert.equal(setColor.status, 200);
const res = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`,
);
assert.equal(res.status, 200);
const buf = Buffer.from(await res.arrayBuffer());
const ascii = buf.toString("latin1");
const decoded = decodeStreams(buf);
// (1) /Title prefix as UTF-16BE for "قائم" (0x0642 0x0627 0x0626 0x0645).
const titlePrefix = Buffer.from([
0xfe, 0xff, 0x06, 0x42, 0x06, 0x27, 0x06, 0x26, 0x06, 0x45,
]);
assert.ok(
buf.includes(titlePrefix),
"PDF /Title metadata must start with the new Arabic title (قائم…)",
);
// Accept rg or scn (PDFKit varies by version).
const op = "(?:rg|scn)";
// (2) rowColor=amber → #fef3c7 = 254/255, 243/255, 199/255.
assert.match(
decoded,
new RegExp(`0\\.9960\\d* 0\\.9529\\d* 0\\.7803\\d* ${op}`),
"amber rowColor must paint the saved palette fill",
);
// (3) Legacy isHighlighted fill (#fecaca) must NOT appear.
assert.doesNotMatch(
decoded,
new RegExp(`0\\.9960\\d* 0\\.7921\\d* 0\\.7921\\d* ${op}`),
"isHighlighted must no longer be baked into the PDF",
);
// (4) fontColor #336699 must appear as a fill op.
assert.match(
decoded,
new RegExp(`(?:^|\\s)0\\.2 0\\.4 0\\.6 ${op}`),
"user fontColor #336699 must appear as a fill operator",
);
void ascii;
});
test("PDF Arabic shaping: renderer hands raw Unicode to PDFKit (no manual pre-shaping)", async () => {
// Regression for the bug where Arabic in the printed PDF appeared as
// disconnected, visually-reversed letters. The fix: let PDFKit/fontkit
// do the shaping + RTL reorder via `features: ['rtla','rclt',...]`,
// and DO NOT call shapeArabic() ourselves before drawing.
//
// Why we no longer scan the PDF binary for U+FExx codepoints:
// ToUnicode CMaps are emitted by fontkit and naturally include
// presentation-form codepoints when the embedded Arabic font's own
// cmap subtable maps glyphs back to those codepoints. That's normal
// font internals — not pre-shaping by us — so a presence-check on
// the inflated streams produced false positives.
//
// Instead we do two things:
// 1) End-to-end: render a real PDF for an Arabic meeting and assert
// the response is a non-trivial PDF that references at least one
// base Arabic codepoint somewhere in its inflated streams (proves
// Arabic content was drawn).
// 2) Source-level guard: assert pdf-renderer.ts never CALLS
// shapeArabic() — the only allowed mention is its own export
// definition. This is the precise signal we care about.
const shapedDate = "2099-05-04";
const shapedMeeting = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الاختبار",
titleEn: "Shaping Smoke Test",
meetingDate: shapedDate,
attendees: [
{ name: "محمد عبدالله", attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(shapedMeeting.status, 201);
created.meetingIds.push((await shapedMeeting.json()).id);
const res = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${shapedDate}&lang=ar`,
);
assert.equal(res.status, 200);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, "rendered PDF must be a real, non-trivial document");
assert.ok(
buf.slice(0, 5).toString("latin1") === "%PDF-",
"response must start with the %PDF- magic header",
);
// (1) Inflate streams and confirm at least one base Arabic codepoint
// appears — proves Arabic content was drawn through the pipeline.
let cursor = 0;
const decoded = [];
while (true) {
const start = buf.indexOf("stream\n", cursor);
if (start < 0) break;
const end = buf.indexOf("\nendstream", start);
if (end < 0) break;
try {
decoded.push(
zlib
.inflateSync(buf.slice(start + "stream\n".length, end))
.toString("latin1"),
);
} catch {
// not a deflate stream
}
cursor = end + "\nendstream".length;
}
const allDecoded = decoded.join("\n");
assert.match(
allDecoded,
/<(?:06[0-9A-F]{2})>/i,
"rendered PDF must reference base Arabic codepoints somewhere in its streams",
);
// (2) Source-level guard: pdf-renderer.ts must not call shapeArabic().
// The only allowed appearance is the export's own signature.
const here = path.dirname(fileURLToPath(import.meta.url));
const rendererPath = path.resolve(here, "..", "src", "lib", "pdf-renderer.ts");
const src = readFileSync(rendererPath, "utf8");
// Hard-pin the contract: there must be exactly one definition of
// `shapeArabic` in this file (export form). If someone refactors it
// away or duplicates it, this guard fails fast instead of silently
// letting an alias slip through.
const defMatches = src.match(/^export\s+function\s+shapeArabic\b/gm) ?? [];
assert.equal(
defMatches.length,
1,
`pdf-renderer.ts must declare exactly one \`export function shapeArabic\` — found ${defMatches.length}. Update this guard if the contract intentionally changed.`,
);
// Strip the single definition (matched at start-of-line, balanced via
// the closing brace at column 0) and assert the symbol is never
// CALLED elsewhere — `name(` syntax including whitespace/comments.
const withoutDef = src.replace(
/^export\s+function\s+shapeArabic\b[\s\S]*?^\}\s*$/m,
"",
);
// Match `shapeArabic` followed by optional whitespace/block-comments
// then an opening paren — covers `shapeArabic(` and `shapeArabic /**/(`.
const callRegex = /\bshapeArabic\b(?:\s|\/\*[\s\S]*?\*\/)*\(/;
const callMatch = withoutDef.match(callRegex);
assert.equal(
callMatch,
null,
`pdf-renderer.ts must NOT call shapeArabic() — fontkit handles shaping via Arabic OpenType features. Re-introducing a pre-shaping pass causes the visually-reversed disconnected-letters regression. Found call: ${callMatch?.[0]}`,
);
});
test("PDF embeds the brand logo when set in global font settings", async () => {
// Build a real 1x1 RGB PNG (png-js rejects most base64 fixtures).
const u32 = (n) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
};
const crc32 = (buf) => {
const table = new Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++)
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++)
crc = (table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0;
return (crc ^ 0xffffffff) >>> 0;
};
const mkChunk = (type, data) => {
const typeBuf = Buffer.from(type, "ascii");
const crc = crc32(Buffer.concat([typeBuf, data]));
return Buffer.concat([u32(data.length), typeBuf, data, u32(crc)]);
};
const ihdr = Buffer.concat([u32(1), u32(1), Buffer.from([8, 2, 0, 0, 0])]);
const idat = zlib.deflateSync(Buffer.from([0, 0, 255, 0]));
const tinyPng = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
mkChunk("IHDR", ihdr),
mkChunk("IDAT", idat),
mkChunk("IEND", Buffer.alloc(0)),
]);
// 1) Sign upload URL.
const signRes = await api(
adminCookie,
"POST",
"/api/storage/uploads/request-url",
{
name: "tx-os-test-logo.png",
size: tinyPng.byteLength,
contentType: "image/png",
},
);
assert.equal(signRes.status, 200, "must be able to sign an upload URL");
const { uploadURL, objectPath } = await signRes.json();
assert.ok(/^\/objects\//.test(objectPath), `expected /objects/<id>, got ${objectPath}`);
// 2) PUT bytes; skip on network/5xx, fail on 4xx.
let putRes;
try {
putRes = await fetch(uploadURL, {
method: "PUT",
body: tinyPng,
headers: {
"Content-Type": "image/png",
"Content-Length": String(tinyPng.byteLength),
},
});
} catch (err) {
console.warn(
`[test] storage sidecar unreachable, skipping logo embed test: ${err.message}`,
);
return;
}
if (putRes.status >= 500) {
console.warn(
`[test] storage sidecar returned ${putRes.status}, skipping logo embed test`,
);
return;
}
assert.ok(
putRes.ok,
`signed PUT must succeed (got ${putRes.status} ${putRes.statusText})`,
);
// 3) Save logo on global row (try/finally resets it).
const setLogo = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "global",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: objectPath,
},
);
assert.equal(setLogo.status, 200, "admin must be able to save the global logo");
try {
// 4) Render PDF (header draws unconditionally).
const pdfDate = "2099-05-04";
const res = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`,
);
assert.equal(
res.status,
200,
"PDF render must succeed with a logo configured",
);
const buf = Buffer.from(await res.arrayBuffer());
const ascii = buf.toString("latin1");
// 5) Assert PDFKit emitted an Image XObject.
assert.match(
ascii,
/\/Subtype\s*\/Image/,
"uploaded brand logo must be embedded as an /Image XObject in the PDF",
);
// /Width 1 confirms our 1x1 fixture was embedded.
assert.match(
ascii,
/\/Width\s+1[^0-9]/,
"embedded image dictionary must reflect the 1x1 source PNG",
);
} finally {
await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "global",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: null,
},
);
}
});
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 () => {
// Use a per-run unique far-future date so leftover meetings from a
// previously-failed run can't cause an `incomplete_day_reorder` 400
// (the route rejects payloads that omit any non-cancelled meeting on
// the day, and we don't try to clean a stale fixed date here).
const stamp = Date.now();
const dd = String((stamp % 28) + 1).padStart(2, "0");
const mm = String((Math.floor(stamp / 28) % 12) + 1).padStart(2, "0");
const reorderDate = `2099-${mm}-${dd}`;
// Best-effort cleanup of any prior rows on the chosen slot (cheap and
// keeps re-runs deterministic without trusting external state).
await pool.query(
`DELETE FROM executive_meetings WHERE meeting_date = $1`,
[reorderDate],
);
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");
});
// dragging a visible meeting from one row to another must not
// disturb cancelled rows on the same day. The schedule view hides
// cancelled meetings (#273), so dnd-kit can only ever drag visible
// rows; the reorder endpoint must accept an orderedIds payload that
// covers exactly the visible (non-cancelled) meetings, slot-swap among
// them, and leave cancelled rows' time slots and daily numbers
// completely untouched. Before the fix, cancelled meetings were
// included in the slot pool and silently stole chronologically-sorted
// slots from the visible list, which made dragging a meeting from top
// to bottom land it in a non-chronological position on screen.
test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => {
const reorderDate = "2050-05-21";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
const d = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "د", titleEn: "D", meetingDate: reorderDate,
startTime: "12:00", endTime: "12:30",
});
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);
const D = await d.json(); created.meetingIds.push(D.id);
// Cancel B in-place so the visible list is [A, C, D] but the day
// still holds 4 rows. Capture B's pre-reorder slot to assert it
// stays put after the reorder.
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const { rows: bBeforeRows } = await pool.query(
`SELECT daily_number, start_time, end_time, status
FROM executive_meetings WHERE id = $1`,
[B.id],
);
const bBefore = bBeforeRows[0];
// User drags D (bottom of the visible list) up to the top, so the
// visible order becomes [D, A, C]. The orderedIds payload covers
// ONLY visible meetings — that is what the patched client sends.
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] });
assert.equal(reorder.status, 200,
"subset reorder (cancelled excluded) must be accepted");
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]));
// Visible meetings inherit each other's slots, sorted chronologically:
// D ← (09:00, daily_number from A's slot)
// A ← (11:00, daily_number from C's slot)
// C ← (12:00, daily_number from D's slot)
// After the swap the visible rows must read top-to-bottom in
// chronological start-time order.
const visible = body.meetings
.filter((m) => m.status !== "cancelled")
.sort((a, b) => a.dailyNumber - b.dailyNumber);
assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id],
"visible rows must be in the dropped order [D, A, C]");
const starts = visible.map((m) => m.startTime.slice(0, 5));
for (let i = 1; i < starts.length; i++) {
assert.ok(starts[i - 1] <= starts[i],
`visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`);
}
// Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
// [D, A, C] (B's 10:00 slot stays with B, untouched).
assert.equal(visible[0].startTime.slice(0, 5), "09:00");
assert.equal(visible[1].startTime.slice(0, 5), "11:00");
assert.equal(visible[2].startTime.slice(0, 5), "12:00");
// Cancelled B: time slot AND daily_number must be unchanged.
const bAfter = byId.get(B.id);
assert.equal(bAfter.status, "cancelled");
assert.equal(bAfter.startTime.slice(0, 5),
String(bBefore.start_time).slice(0, 5),
"cancelled meeting startTime must not change");
assert.equal(bAfter.endTime.slice(0, 5),
String(bBefore.end_time).slice(0, 5),
"cancelled meeting endTime must not change");
assert.equal(bAfter.dailyNumber, bBefore.daily_number,
"cancelled meeting dailyNumber must not change");
});
test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => {
const reorderDate = "2050-05-22";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [B.id, A.id] });
assert.equal(r.status, 400,
"orderedIds containing a cancelled meeting must be rejected");
const body = await r.json();
assert.equal(body.code, "cancelled_in_reorder",
"rejection must be reported with the cancelled_in_reorder code");
});
test("Reorder: handles a day with a null-startTime meeting deterministically", async () => {
// Per the slot-swap sort, rows with non-null startTime come first
// (chronological), and null-startTime rows come last. After a reorder
// of [null-row, A, B], the null row should land in the LAST slot
// (whatever its startTime/dailyNumber were originally), and A/B should
// get the first two chronological slots. This proves the slot-swap
// does not crash on nulls and produces a stable order.
const reorderDate = "2050-06-10";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const n = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ن", titleEn: "Null-time", meetingDate: reorderDate,
// startTime/endTime intentionally omitted -> null in DB
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const N = await n.json(); created.meetingIds.push(N.id);
// Ask: put null-row first, then A, then B
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [N.id, A.id, B.id] });
assert.equal(r.status, 200, "reorder with a null-time row must succeed");
const after = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings
WHERE meeting_date = $1
ORDER BY daily_number`,
[reorderDate],
);
// Slots sorted by startTime (nulls last) were:
// slot[0] = (09:00, 09:30, dn=A) for A originally
// slot[1] = (10:00, 10:30, dn=B) for B originally
// slot[2] = (null, null, dn=N) for N originally
// Assignment N->slot[0], A->slot[1], B->slot[2]:
const byId = Object.fromEntries(after.rows.map((r) => [r.id, r]));
assert.equal(byId[N.id].start_time, "09:00:00",
"null-time row should inherit the first chronological slot's startTime");
assert.equal(byId[A.id].start_time, "10:00:00",
"A should inherit the second chronological slot's startTime");
assert.equal(byId[B.id].start_time, null,
"B should inherit the originally-null third slot's startTime");
// dailyNumbers must be unique 1..3 across the day
const dns = after.rows.map((r) => r.daily_number).sort();
assert.deepEqual(dns, [1, 2, 3].sort(),
"dailyNumbers across the day must remain a unique 1..N permutation");
});
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);
});
// ---------------------------------------------------------------------
// Auto-sort the day chronologically on every write that
// affects time/order. The user reported a row showing 13:00 sitting
// above a 12:00 row after editing a single startTime — that bug exists
// because the inline-edit PATCH never re-derived `daily_number`. These
// tests lock in the new contract: any time a write touches startTime,
// endTime, meetingDate, status, or creates/duplicates/deletes a row,
// the day is renumbered 1..N by start_time so the visible list is
// strictly chronological. Cancelled rows go to the tail (preserving
// the existing renumberDayByStartTime convention) so the per-day
// unique index on (meeting_date, daily_number) stays satisfied.
// ---------------------------------------------------------------------
// Helper: assert that the visible (non-cancelled) rows on `date` read
// 1..N by start_time order, with no chronological inversions and no
// duplicate dailyNumbers. Returns the visible rows for further checks.
async function assertDayChronological(date) {
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${date}`);
assert.equal(day.status, 200);
const body = await day.json();
const visible = body.meetings
.filter((m) => m.status !== "cancelled")
.sort((a, b) => a.dailyNumber - b.dailyNumber);
// dailyNumbers must be 1..N with no gaps.
for (let i = 0; i < visible.length; i++) {
assert.equal(visible[i].dailyNumber, i + 1,
`visible row index ${i} on ${date} must have dailyNumber ${i + 1}, got ${visible[i].dailyNumber}`);
}
// start_times must be non-decreasing top-to-bottom (nulls last per
// renumberDayByStartTime's NULLS LAST clause).
let lastTime = null;
let seenNull = false;
for (const m of visible) {
if (m.startTime == null) {
seenNull = true;
continue;
}
assert.ok(!seenNull,
`null-startTime row on ${date} must come after timed rows, but a timed row (${m.startTime}) followed a null one`);
const t = String(m.startTime).slice(0, 8);
if (lastTime != null) {
assert.ok(lastTime <= t,
`start_time inversion on ${date}: ${lastTime} (row N) > ${t} (row N+1)`);
}
lastTime = t;
}
// No duplicate dailyNumbers across the whole day (cancelled + visible).
const seen = new Set();
for (const m of body.meetings) {
assert.ok(!seen.has(m.dailyNumber),
`duplicate dailyNumber ${m.dailyNumber} on ${date} after auto-sort`);
seen.add(m.dailyNumber);
}
return visible;
}
test("Auto-sort: PATCH startTime later than next row demotes it to its new chronological slot", async () => {
const d = "2050-06-01";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
await assertDayChronological(d);
// The exact scenario from the user's screenshot: A is at the top
// (09:00). User edits A's startTime to 13:00 — without auto-sort, A
// stays in row 1 even though it now has the latest time.
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${A.id}`,
{ startTime: "13:00", endTime: "13:30" });
assert.equal(patch.status, 200);
const visible = await assertDayChronological(d);
// After auto-sort the order must be B, C, A — A demoted to the tail
// because its 13:00 is now the latest start time on the day.
assert.deepEqual(visible.map((m) => m.id), [B.id, C.id, A.id],
"the row whose startTime was bumped to 13:00 must move to the bottom");
});
test("Auto-sort: PATCH startTime earlier than first row promotes it to the top", async () => {
const d = "2050-06-02";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
// Move C (currently bottom @ 11:00) to 07:00 — must jump to the top.
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${C.id}`,
{ startTime: "07:00", endTime: "07:30" });
assert.equal(patch.status, 200);
const visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [C.id, A.id, B.id],
"the row whose startTime was moved earliest must rise to the top");
});
test("Auto-sort: POST create with a middle startTime slots the new row between existing ones", async () => {
const d = "2050-06-03";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const C = await c.json(); created.meetingIds.push(C.id);
// Create B at 10:00 — without auto-sort it would land at the tail
// (nextDailyNumber = 3) so the visible order would be [A, C, B] even
// though chronologically it belongs in the middle.
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
assert.equal(b.status, 201);
const B = await b.json(); created.meetingIds.push(B.id);
const visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id],
"newly-created meeting with middle startTime must slot between existing rows");
});
test("Auto-sort: clearing startTime to null pushes the row to the tail of the visible list", async () => {
const d = "2050-06-04";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
// Clear B's start/end times — renumberDayByStartTime puts NULL-time
// rows at the tail of the visible 1..N (NULLS LAST).
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${B.id}`,
{ startTime: null, endTime: null });
assert.equal(patch.status, 200);
const visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, C.id, B.id],
"row with cleared startTime must sink below the timed rows");
assert.equal(visible[2].startTime, null, "B's startTime must be null after clearing");
});
test("Auto-sort: cancel pushes a row out of the visible list and renumbers, uncancel re-slots chronologically", async () => {
const d = "2050-06-05";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
// Cancel B via the dedicated endpoint (which already calls
// renumberDayByStartTime). The visible list shrinks to [A, C] and
// their dailyNumbers must compact to 1..2 with B sitting at the tail.
const cancel = await api(adminCookie, "POST",
`/api/executive-meetings/${B.id}/cancel`);
assert.equal(cancel.status, 200);
let visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, C.id],
"cancelled row must drop out of the visible list");
// Uncancel via PATCH status='scheduled'. B must re-enter the visible
// list at its chronological slot (between A @ 09:00 and C @ 11:00).
const uncancel = await api(adminCookie, "PATCH",
`/api/executive-meetings/${B.id}`,
{ status: "scheduled" });
assert.equal(uncancel.status, 200);
visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id],
"uncancelled row must re-slot chronologically by its startTime");
});
test("Auto-sort: PATCH meetingDate cross-day move renumbers BOTH days chronologically", async () => {
const dSrc = "2050-07-10";
const dDst = "2050-07-11";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A-src", meetingDate: dSrc,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B-src", meetingDate: dSrc,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C-src", meetingDate: dSrc,
startTime: "11:00", endTime: "11:30",
});
const x = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س", titleEn: "X-dst", meetingDate: dDst,
startTime: "08:00", endTime: "08:30",
});
const y = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ص", titleEn: "Y-dst", meetingDate: dDst,
startTime: "13:00", endTime: "13:30",
});
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);
const X = await x.json(); created.meetingIds.push(X.id);
const Y = await y.json(); created.meetingIds.push(Y.id);
// Move B from the source day to the destination day at 10:00 — it
// must arrive between X (08:00) and Y (13:00) on dDst, and the
// source day must close ranks so [A, C] reads 1..2.
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${B.id}`,
{ meetingDate: dDst });
assert.equal(patch.status, 200);
const srcVisible = await assertDayChronological(dSrc);
assert.deepEqual(srcVisible.map((m) => m.id), [A.id, C.id],
"source day must compact to [A, C] after B moves away");
const dstVisible = await assertDayChronological(dDst);
assert.deepEqual(dstVisible.map((m) => m.id), [X.id, B.id, Y.id],
"destination day must accept B at its chronological slot between X and Y");
});
test("Auto-sort: DELETE leaves the day's `#` sequence gap-free and chronological", async () => {
const d = "2050-06-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
// Delete the middle row. Without auto-sort, dailyNumbers would be
// [1, 3] with a gap, and a future POST would reuse dailyNumber=2.
const del = await api(adminCookie, "DELETE",
`/api/executive-meetings/${B.id}`);
assert.ok(del.status === 200 || del.status === 204);
// Drop B from cleanup since it's already gone.
created.meetingIds = created.meetingIds.filter((x) => x !== B.id);
const visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, C.id],
"remaining rows must compact to 1..2 with no gap after delete");
});
test("Auto-sort: POST /duplicate slots the cloned meeting at its chronological position on the target day", async () => {
const dSrc = "2050-06-20";
const dDst = "2050-06-21";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A-src", meetingDate: dSrc,
startTime: "10:00", endTime: "10:30",
});
const x = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س", titleEn: "X-dst", meetingDate: dDst,
startTime: "08:00", endTime: "08:30",
});
const y = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ص", titleEn: "Y-dst", meetingDate: dDst,
startTime: "13:00", endTime: "13:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const X = await x.json(); created.meetingIds.push(X.id);
const Y = await y.json(); created.meetingIds.push(Y.id);
// Duplicate A (10:00) onto dDst — the clone must land between X
// (08:00) and Y (13:00). Without auto-sort, the duplicate would be
// appended at the tail (dailyNumber = 3) and the visible order would
// be [X, Y, clone] even though clone @ 10:00 belongs in the middle.
const dup = await api(adminCookie, "POST",
`/api/executive-meetings/${A.id}/duplicate`,
{ targetDate: dDst });
assert.equal(dup.status, 201);
const Clone = await dup.json(); created.meetingIds.push(Clone.id);
const visible = await assertDayChronological(dDst);
assert.deepEqual(visible.map((m) => m.id), [X.id, Clone.id, Y.id],
"duplicate must slot at its chronological position on the target day");
});
// #312: when a PATCH (e.g. typing 13:00 on the row currently sitting
// above a 12:00 row) triggers auto-sort and the row visibly moves to a
// new dailyNumber, the audit row for that PATCH must record both the
// post-renumber dailyNumber and an `orderShifted: true` flag with the
// before/after visible-row order. Without this an admin reading the
// audit cannot tell that the user's startTime edit also moved the row
// from `#1` to `#3`.
test("Auto-sort: PATCH that reorders the day records orderShifted + final dailyNumber + dayOrder in the audit row", async () => {
const d = "2050-07-12";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: d,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: d,
startTime: "11:00", endTime: "11:30",
});
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);
// Sanity: starting visible order is A(09), B(10), C(11) at #1,#2,#3.
let visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id]);
// Move A from 09:00 -> 13:00 (the user's exact bug scenario). After
// auto-sort the visible order should become B(10), C(11), A(13) and
// A's dailyNumber should be 3.
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${A.id}`,
{ startTime: "13:00", endTime: "13:30" });
assert.equal(patch.status, 200);
visible = await assertDayChronological(d);
assert.deepEqual(visible.map((m) => m.id), [B.id, C.id, A.id]);
// Read the audit row for THIS PATCH and assert the shift metadata.
const auditRows = await pool.query(
`SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'update'
AND entity_type = 'meeting'
AND entity_id = $1
ORDER BY id DESC LIMIT 1`,
[A.id],
);
assert.equal(auditRows.rowCount, 1, "expected one update audit row for A");
const newValue = auditRows.rows[0].new_value;
assert.ok(newValue, "audit new_value must be present");
assert.equal(newValue.orderShifted, true,
"audit must flag orderShifted=true when auto-sort moved the row");
assert.equal(newValue.dailyNumber, 3,
"audit's newValue.dailyNumber must echo the post-renumber position (#3), not the pre-renumber #1");
assert.ok(Array.isArray(newValue.dayOrder),
"audit must include dayOrder array describing the visible-row shift");
const shift = newValue.dayOrder.find((s) => s.date === d);
assert.ok(shift, `dayOrder must include an entry for the affected date ${d}`);
assert.deepEqual(shift.before, [A.id, B.id, C.id],
"dayOrder.before must be the visible row IDs in pre-renumber dailyNumber order");
assert.deepEqual(shift.after, [B.id, C.id, A.id],
"dayOrder.after must be the visible row IDs in post-renumber dailyNumber order");
});
// #312: a non-order-affecting PATCH (e.g. just changing the title)
// must NOT add orderShifted/dayOrder to the audit row, since auto-sort
// did not run. This guards against accidentally treating every PATCH
// as a reorder in the audit UI.
test("Auto-sort: PATCH that does not touch time/date/status leaves orderShifted absent from the audit", async () => {
const d = "2050-07-13";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: d,
startTime: "09:00", endTime: "09:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${A.id}`,
{ titleEn: "A renamed" });
assert.equal(patch.status, 200);
const auditRows = await pool.query(
`SELECT new_value FROM executive_meeting_audit_logs
WHERE action = 'update' AND entity_type = 'meeting'
AND entity_id = $1
ORDER BY id DESC LIMIT 1`,
[A.id],
);
assert.equal(auditRows.rowCount, 1);
const newValue = auditRows.rows[0].new_value;
assert.ok(!("orderShifted" in newValue),
"title-only PATCH must not add orderShifted to the audit");
assert.ok(!("dayOrder" in newValue),
"title-only PATCH must not add dayOrder to the audit");
});
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 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",
fontColor: "#1f2937",
});
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");
assert.equal(body.user.fontColor, "#1f2937",
"fontColor must persist via the user-scope row");
// PATCH alias must hit the same upsert handler.
const patch = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "DIN Next LT Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
});
assert.equal(patch.status, 200);
const get2 = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
const body2 = await get2.json();
// The upsert handler stores `null` on the user row for any field
// whose value matches the current global default — see the
// "nullified" overlay block in the route. The client resolves the
// effective value at read time as `user[field] ?? global[field]`.
// So for fields that DO differ from the global default we keep the
// user value; for fields that MATCH the global default we expect
// null on the user row.
const effective = (field) => body2.user[field] ?? body2.global?.[field] ?? null;
assert.equal(effective("fontFamily"), "DIN Next LT Arabic");
assert.equal(effective("fontSize"), 14);
assert.equal(effective("fontWeight"), "regular");
assert.equal(effective("alignment"), "start");
assert.equal(effective("fontColor"), "#000000");
});
test("Font settings: rejects malformed fontColor and logoObjectPath", async () => {
// The hex regex on the API must reject non-#RRGGBB inputs so the
// value can be safely round-tripped through CSS and PDFKit.
const badColor = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "red",
});
assert.equal(badColor.status, 400, "non-hex fontColor must 400");
// Anything that isn't a /objects/<id> path should also be rejected
// — we don't want callers smuggling http URLs or relative paths
// into the brand logo slot.
const badPath = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "global",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: "https://evil.example.com/logo.png",
});
assert.equal(badPath.status, 400, "non-/objects path must 400");
});
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",
);
});
// ---------------------------------------------------------------------------
// #302 — Cascade-shift on postpone / reschedule.
// Each test uses its own far-future date so the day's meeting set is
// isolated from every other test (and from any seeded data on `today`).
// ---------------------------------------------------------------------------
async function createMeeting(date, titleEn, startTime, endTime, opts = {}) {
const res = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: titleEn,
titleEn,
meetingDate: date,
startTime,
endTime,
platform: "none",
status: opts.status ?? "scheduled",
isHighlighted: 0,
});
assert.equal(res.status, 201, `create ${titleEn} should succeed`);
const body = await res.json();
created.meetingIds.push(body.id);
return body;
}
async function getMeeting(id) {
const res = await api(adminCookie, "GET", `/api/executive-meetings/${id}`);
assert.equal(res.status, 200, `GET meeting ${id} should succeed`);
return res.json();
}
test("#302 cascade-preview: returns later active meetings with shifted times", async () => {
const d = "2050-05-10";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const c = await createMeeting(d, "C follower", "11:30", "12:00");
// Earlier meeting must NOT be in the preview.
const z = await createMeeting(d, "Z earlier", "08:00", "08:30");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/cascade-preview`,
{ deltaMinutes: 30 },
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.blockedBy, null);
const ids = body.followers.map((f) => f.id);
assert.deepEqual(ids, [b.id, c.id], "only later active meetings, in start order");
assert.ok(!ids.includes(z.id), "earlier meetings must not be cascaded");
assert.equal(body.followers[0].newStart.slice(0, 5), "11:00");
assert.equal(body.followers[0].newEnd.slice(0, 5), "11:30");
assert.equal(body.followers[1].newStart.slice(0, 5), "12:00");
});
test("#302 cascade-preview: skips cancelled and completed followers", async () => {
const d = "2050-05-11";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B cancelled", "10:30", "11:00", {
status: "cancelled",
});
const c = await createMeeting(d, "C completed", "11:30", "12:00", {
status: "completed",
});
const dActive = await createMeeting(d, "D active", "13:00", "14:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/cascade-preview`,
{ deltaMinutes: 15 },
);
assert.equal(res.status, 200);
const body = await res.json();
const ids = body.followers.map((f) => f.id);
assert.deepEqual(ids, [dActive.id]);
assert.ok(!ids.includes(b.id));
assert.ok(!ids.includes(c.id));
});
test("#302 postpone-minutes cascadeFollowing=true atomically shifts all later meetings + writes audit", async () => {
const d = "2050-05-12";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const c = await createMeeting(d, "C follower", "11:30", "12:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 30,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.cascadedIds.sort((x, y) => x - y), [b.id, c.id].sort((x, y) => x - y));
const aFresh = await getMeeting(a.id);
const bFresh = await getMeeting(b.id);
const cFresh = await getMeeting(c.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:30");
assert.equal(aFresh.endTime.slice(0, 5), "10:30");
assert.equal(bFresh.startTime.slice(0, 5), "11:00");
assert.equal(bFresh.endTime.slice(0, 5), "11:30");
assert.equal(cFresh.startTime.slice(0, 5), "12:00");
assert.equal(cFresh.endTime.slice(0, 5), "12:30");
// Audit rows for the cascade — one per follower, naming the trigger.
const audits = await pool.query(
`SELECT entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = ANY($1::int[])
ORDER BY entity_id`,
[[b.id, c.id]],
);
assert.equal(audits.rowCount, 2, "one cascade audit per follower");
for (const row of audits.rows) {
assert.equal(row.new_value.triggerMeetingId, a.id);
assert.equal(row.new_value.deltaMinutes, 30);
}
});
test("#302 postpone-minutes cascadeFollowing=false leaves followers untouched (no audit)", async () => {
const d = "2050-05-13";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 15,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: false,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, []);
const aFresh = await getMeeting(a.id);
const bFresh = await getMeeting(b.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:15", "primary moved");
assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower unchanged");
const audits = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = $1`,
[b.id],
);
assert.equal(audits.rows[0].n, 0, "no cascade audit row when flag is false");
});
test("#302 postpone-minutes cascade midnight rejection rolls back primary too", async () => {
const d = "2050-05-14";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
// 23:30 follower would be pushed to 24:30 by a 60-minute delta.
const b = await createMeeting(d, "B late follower", "23:30", "23:45");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 60,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: true,
},
);
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.code, "cascade_crosses_midnight");
assert.equal(body.blockedBy.id, b.id);
// Primary must be unchanged because the whole transaction rolled back.
const aFresh = await getMeeting(a.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:00");
assert.equal(aFresh.endTime.slice(0, 5), "10:00");
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "23:30");
});
test("#302 reschedule cascadeFollowing=true uses (newStart - oldStart) as delta on same day", async () => {
const d = "2050-05-15";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d,
startTime: "09:45",
endTime: "10:45",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, [b.id]);
const bFresh = await getMeeting(b.id);
// delta = 09:45 - 09:00 = 45m → 10:30 → 11:15
assert.equal(bFresh.startTime.slice(0, 5), "11:15");
assert.equal(bFresh.endTime.slice(0, 5), "11:45");
});
test("#302 reschedule cascadeFollowing=true is a no-op when date moves", async () => {
const d1 = "2050-05-16";
const d2 = "2050-05-17";
const a = await createMeeting(d1, "A primary", "09:00", "10:00");
const b = await createMeeting(d1, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d2,
startTime: "09:30",
endTime: "10:30",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, [], "cross-day reschedule must not cascade");
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower untouched");
});
test("#302 reschedule cascadeFollowing=true is a no-op when newStart <= oldStart", async () => {
const d = "2050-05-18";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
// Reschedule earlier: same day, newStart < oldStart → ignore flag.
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d,
startTime: "08:30",
endTime: "09:30",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, []);
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "10:30");
});
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);
});