11aaaf2abe
The "Download PDF" button on the executive-meetings page now hits a real
backend endpoint that returns a true PDF (no more browser print dialog),
respects each user's font preferences (family, size, weight, alignment)
with proper Arabic RTL shaping, and archives every download.
The renderer maps each saved fontFamily ("system", "Cairo", "Tajawal",
"Noto Naskh Arabic", "Amiri") to a concrete pair of bundled font files
so the chosen family genuinely changes the embedded glyphs — Cairo and
Tajawal pick Noto Sans Arabic, the Naskh-style families and the system
default pick Noto Naskh Arabic, and Latin glyphs render in DejaVu Sans
across the board. Headers, body cells, and footer all flow through the
same script-aware font selection.
Backend (artifacts/api-server)
- New GET /api/executive-meetings/pdf?date=&lang= route in
src/routes/executive-meetings.ts that fetches the day's meetings +
attendees, renders a PDF, uploads it to object storage, writes an
executive_meeting_pdf_archives row (date, generated_by, byte_size,
storage_url), and streams the file back inline.
- New src/lib/pdf-renderer.ts using pdfkit + bidi-js with bundled
Noto Naskh Arabic and DejaVu Sans fonts in assets/fonts/.
- Added byte_size column on executive_meeting_pdf_archives (also in
lib/db schema) and rebuilt lib/db.
- Added ambient types for bidi-js; installed @swc/helpers to satisfy
fontkit at runtime.
- build.mjs now copies pdfkit's data/ folder (Helvetica.afm, etc.)
into dist/data so the bundled server can construct PDFDocument.
Frontend (artifacts/tx-os)
- PdfSection in src/pages/executive-meetings.tsx now renders a single
"Download PDF" button that fetches the endpoint, builds a Blob, and
downloads it. Removed the print/archive-creation buttons.
- Archive list shows a Download button for new /objects/... rows and a
read-only "Legacy snapshot" badge for older print: rows.
- Added byteSize on PdfArchive + size formatting; updated en/ar locales.
Tests
- New test "PDF GET /executive-meetings/pdf returns a real PDF and
archives it" in tests/executive-meetings.test.mjs covers: bad-date
400, unauthenticated 401, real %PDF body + content-type/disposition,
archive row with byteSize/generatedBy/filePath, empty-day handling,
and font-family mapping (Cairo embeds NotoSansArabic; Noto Naskh
Arabic embeds NotoNaskhArabic).
- All 23 executive-meetings tests pass.
Rebase
- Rebased onto main-repl/main (37255b7 "Update the website's shared
image"). The only conflict was the binary asset
artifacts/tx-os/public/opengraph.jpg — accepted the incoming/main
version since it's unrelated to this PDF work.
Drift
- Kept the legacy /executive-meetings/print SPA route and the existing
POST /pdf-archives endpoint to preserve old archive snapshots and
the existing snapshot test. Proposed follow-up #169 to clean these
up once stakeholders confirm.
Replit-Task-Id: 68914058-ebd6-4670-a785-c0084fe1fc94
917 lines
30 KiB
JavaScript
917 lines
30 KiB
JavaScript
import { test, before, after } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import pg from "pg";
|
||
|
||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||
const DATABASE_URL = process.env.DATABASE_URL;
|
||
if (!DATABASE_URL) {
|
||
throw new Error("DATABASE_URL must be set to run these tests");
|
||
}
|
||
|
||
const TEST_PASSWORD_HASH =
|
||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||
const TEST_PASSWORD = "TestPass123!";
|
||
|
||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||
|
||
const created = {
|
||
userIds: [],
|
||
meetingIds: [],
|
||
requestIds: [],
|
||
taskIds: [],
|
||
};
|
||
|
||
function uniqueName(prefix) {
|
||
return `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||
.toString(36)
|
||
.slice(2, 8)}`;
|
||
}
|
||
|
||
async function createUser(prefix, roleName) {
|
||
const username = uniqueName(prefix);
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||
VALUES ($1, $2, $3, 'EM Test', 'en', true) RETURNING id`,
|
||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||
);
|
||
const id = rows[0].id;
|
||
created.userIds.push(id);
|
||
for (const r of ["user", roleName]) {
|
||
await pool.query(
|
||
`INSERT INTO user_roles (user_id, role_id)
|
||
SELECT $1, id FROM roles WHERE name = $2
|
||
ON CONFLICT DO NOTHING`,
|
||
[id, r],
|
||
);
|
||
}
|
||
return { id, username };
|
||
}
|
||
|
||
function extractCookie(res) {
|
||
const setCookie = res.headers.get("set-cookie");
|
||
if (!setCookie) return null;
|
||
return setCookie
|
||
.split(",")
|
||
.map((c) => c.split(";")[0].trim())
|
||
.find((c) => c.startsWith("connect.sid=")) ?? null;
|
||
}
|
||
|
||
async function login(username, password) {
|
||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
assert.equal(res.status, 200, `login should succeed for ${username}`);
|
||
const cookie = extractCookie(res);
|
||
assert.ok(cookie, "login response should set a session cookie");
|
||
return cookie;
|
||
}
|
||
|
||
async function api(cookie, method, path, body) {
|
||
const init = {
|
||
method,
|
||
headers: {
|
||
Cookie: cookie,
|
||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||
},
|
||
};
|
||
if (body !== undefined) init.body = JSON.stringify(body);
|
||
return fetch(`${API_BASE}${path}`, init);
|
||
}
|
||
|
||
let adminCookie = null;
|
||
let adminUserId = null;
|
||
let coordCookie = null;
|
||
let coordUserId = null;
|
||
let leadCookie = null;
|
||
let leadUserId = null;
|
||
|
||
before(async () => {
|
||
adminCookie = await login("admin", "admin123");
|
||
const meRes = await api(adminCookie, "GET", "/api/auth/me");
|
||
const me = await meRes.json();
|
||
adminUserId = me.id ?? me.userId;
|
||
|
||
const coord = await createUser("em_coord", "executive_coordinator");
|
||
coordUserId = coord.id;
|
||
coordCookie = await login(coord.username, TEST_PASSWORD);
|
||
|
||
const lead = await createUser("em_lead", "executive_coord_lead");
|
||
leadUserId = lead.id;
|
||
leadCookie = await login(lead.username, TEST_PASSWORD);
|
||
});
|
||
|
||
after(async () => {
|
||
if (created.taskIds.length > 0) {
|
||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
|
||
created.taskIds,
|
||
]);
|
||
}
|
||
if (created.requestIds.length > 0) {
|
||
await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [
|
||
created.requestIds,
|
||
]);
|
||
}
|
||
if (created.meetingIds.length > 0) {
|
||
await pool.query(`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [
|
||
created.meetingIds,
|
||
]);
|
||
await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type IN ('meeting','request','task')`, [
|
||
created.meetingIds,
|
||
]);
|
||
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
|
||
created.meetingIds,
|
||
]);
|
||
}
|
||
if (created.userIds.length > 0) {
|
||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [
|
||
created.userIds,
|
||
]);
|
||
await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [
|
||
created.userIds,
|
||
]);
|
||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||
created.userIds,
|
||
]);
|
||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||
created.userIds,
|
||
]);
|
||
}
|
||
await pool.end();
|
||
});
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||
.toISOString()
|
||
.slice(0, 10);
|
||
|
||
test("GET /me exposes capability flags including canViewAllTasks", async () => {
|
||
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
|
||
assert.equal(res.status, 200);
|
||
const body = await res.json();
|
||
for (const key of [
|
||
"userId",
|
||
"roles",
|
||
"canRead",
|
||
"canMutate",
|
||
"canApprove",
|
||
"canSubmitRequest",
|
||
"canViewAudit",
|
||
"canViewTasks",
|
||
"canViewAllTasks",
|
||
]) {
|
||
assert.ok(key in body, `missing ${key} in /me response`);
|
||
}
|
||
assert.equal(body.canViewAllTasks, true);
|
||
|
||
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
|
||
const coordMe = await coordRes.json();
|
||
assert.equal(coordMe.canViewTasks, true);
|
||
assert.equal(coordMe.canViewAllTasks, false);
|
||
});
|
||
|
||
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
|
||
const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اجتماع",
|
||
meetingDate: today,
|
||
});
|
||
assert.equal(missingEn.status, 400);
|
||
|
||
const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اجتماع",
|
||
titleEn: "",
|
||
meetingDate: today,
|
||
});
|
||
assert.equal(emptyEn.status, 400);
|
||
});
|
||
|
||
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اجتماع اختبار",
|
||
titleEn: "Test meeting",
|
||
meetingDate: today,
|
||
startTime: "09:00",
|
||
endTime: "10:00",
|
||
platform: "none",
|
||
status: "scheduled",
|
||
isHighlighted: 0,
|
||
attendees: [
|
||
{ name: "Tester One", attendanceType: "internal", sortOrder: 0 },
|
||
],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
assert.ok(meeting.id);
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const day = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings?date=${today}`,
|
||
);
|
||
assert.equal(day.status, 200);
|
||
const dayBody = await day.json();
|
||
assert.ok(Array.isArray(dayBody.meetings));
|
||
assert.ok(dayBody.meetings.some((m) => m.id === meeting.id));
|
||
|
||
const del = await api(
|
||
adminCookie,
|
||
"DELETE",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
);
|
||
assert.ok(del.status === 200 || del.status === 204);
|
||
});
|
||
|
||
test("Meetings: PUT /attendees replaces the attendee list", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ا",
|
||
titleEn: "A",
|
||
meetingDate: today,
|
||
attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const replace = await api(
|
||
adminCookie,
|
||
"PUT",
|
||
`/api/executive-meetings/${meeting.id}/attendees`,
|
||
{
|
||
attendees: [
|
||
{ name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
|
||
{ name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
|
||
],
|
||
},
|
||
);
|
||
assert.equal(replace.status, 200);
|
||
const fetched = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
);
|
||
const body = await fetched.json();
|
||
assert.equal(body.attendees.length, 2);
|
||
assert.ok(body.attendees.find((a) => a.name === "New One"));
|
||
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
|
||
});
|
||
|
||
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب",
|
||
titleEn: "B",
|
||
meetingDate: today,
|
||
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const original = await create.json();
|
||
created.meetingIds.push(original.id);
|
||
|
||
const dup = await api(
|
||
adminCookie,
|
||
"POST",
|
||
`/api/executive-meetings/${original.id}/duplicate`,
|
||
{ targetDate: tomorrow },
|
||
);
|
||
assert.equal(dup.status, 201);
|
||
const newRow = await dup.json();
|
||
assert.notEqual(newRow.id, original.id);
|
||
created.meetingIds.push(newRow.id);
|
||
|
||
const day = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings?date=${tomorrow}`,
|
||
);
|
||
const dayBody = await day.json();
|
||
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
|
||
});
|
||
|
||
test("Requests: POST → admin can list", async () => {
|
||
const create = await api(
|
||
adminCookie,
|
||
"POST",
|
||
"/api/executive-meetings/requests",
|
||
{
|
||
requestType: "note",
|
||
requestDetails: { note: "Phase-2 test request" },
|
||
},
|
||
);
|
||
assert.equal(create.status, 201);
|
||
const reqRow = await create.json();
|
||
assert.ok(reqRow.id);
|
||
created.requestIds.push(reqRow.id);
|
||
|
||
const list = await api(adminCookie, "GET", "/api/executive-meetings/requests");
|
||
assert.equal(list.status, 200);
|
||
const listBody = await list.json();
|
||
assert.ok(Array.isArray(listBody.requests));
|
||
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
|
||
});
|
||
|
||
test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ت",
|
||
titleEn: "T",
|
||
meetingDate: today,
|
||
attendees: [],
|
||
isHighlighted: 0,
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", {
|
||
requestType: "highlight",
|
||
meetingId: meeting.id,
|
||
requestDetails: { note: "highlight-please" },
|
||
});
|
||
assert.equal(reqRes.status, 201);
|
||
const reqRow = await reqRes.json();
|
||
created.requestIds.push(reqRow.id);
|
||
|
||
const approve = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ status: "approved", reviewNotes: "ok" },
|
||
);
|
||
assert.equal(approve.status, 200);
|
||
|
||
const fetched = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
);
|
||
const body = await fetched.json();
|
||
assert.equal(body.isHighlighted, 1);
|
||
});
|
||
|
||
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
|
||
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||
taskType: "follow_up",
|
||
assignedTo: coordUserId,
|
||
notes: "for coord",
|
||
});
|
||
assert.equal(t1.status, 201);
|
||
const task1 = await t1.json();
|
||
created.taskIds.push(task1.id);
|
||
|
||
const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||
taskType: "follow_up",
|
||
assignedTo: leadUserId,
|
||
notes: "for lead",
|
||
});
|
||
assert.equal(t2.status, 201);
|
||
const task2 = await t2.json();
|
||
created.taskIds.push(task2.id);
|
||
|
||
const coordList = await api(
|
||
coordCookie,
|
||
"GET",
|
||
`/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`,
|
||
);
|
||
assert.equal(coordList.status, 200);
|
||
const coordBody = await coordList.json();
|
||
const coordIds = coordBody.tasks.map((t) => t.id);
|
||
assert.ok(coordIds.includes(task1.id));
|
||
assert.ok(!coordIds.includes(task2.id));
|
||
for (const t of coordBody.tasks) {
|
||
assert.equal(t.assignedTo, coordUserId);
|
||
}
|
||
|
||
const leadList = await api(
|
||
leadCookie,
|
||
"GET",
|
||
`/api/executive-meetings/tasks?assigneeId=${leadUserId}`,
|
||
);
|
||
assert.equal(leadList.status, 200);
|
||
const leadBody = await leadList.json();
|
||
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
|
||
});
|
||
|
||
test("Tasks: PATCH supports reassign + notes update", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||
taskType: "follow_up",
|
||
assignedTo: coordUserId,
|
||
notes: "v1",
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const task = await create.json();
|
||
created.taskIds.push(task.id);
|
||
|
||
const patch = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
`/api/executive-meetings/tasks/${task.id}`,
|
||
{ assignedTo: leadUserId, notes: "v2" },
|
||
);
|
||
assert.equal(patch.status, 200);
|
||
const updated = await patch.json();
|
||
assert.equal(updated.assignedTo, leadUserId);
|
||
assert.equal(updated.notes, "v2");
|
||
});
|
||
|
||
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
|
||
const ok = await api(
|
||
adminCookie,
|
||
"GET",
|
||
"/api/executive-meetings/audit-logs",
|
||
);
|
||
assert.equal(ok.status, 200);
|
||
const body = await ok.json();
|
||
assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));
|
||
|
||
const denied = await api(
|
||
coordCookie,
|
||
"GET",
|
||
"/api/executive-meetings/audit-logs",
|
||
);
|
||
assert.equal(denied.status, 403);
|
||
});
|
||
|
||
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "س",
|
||
titleEn: "S",
|
||
meetingDate: today,
|
||
});
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const filtered = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
|
||
);
|
||
assert.equal(filtered.status, 200);
|
||
const body = await filtered.json();
|
||
const entries = body.entries ?? body.logs ?? [];
|
||
assert.ok(Array.isArray(entries));
|
||
for (const e of entries) {
|
||
assert.equal(e.action, "create");
|
||
assert.equal(e.entityType, "meeting");
|
||
assert.equal(e.performedBy, adminUserId);
|
||
}
|
||
assert.ok(entries.some((e) => e.entityId === meeting.id));
|
||
});
|
||
|
||
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
|
||
const empty = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/notifications?date=1990-01-01`,
|
||
);
|
||
assert.equal(empty.status, 200);
|
||
const emptyBody = await empty.json();
|
||
assert.ok(Array.isArray(emptyBody.notifications));
|
||
|
||
const todayList = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/notifications?date=${today}`,
|
||
);
|
||
assert.equal(todayList.status, 200);
|
||
});
|
||
|
||
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
|
||
const ok = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
"/api/executive-meetings/font-settings",
|
||
{
|
||
scope: "user",
|
||
fontFamily: "Cairo",
|
||
fontSize: 16,
|
||
fontWeight: "bold",
|
||
alignment: "center",
|
||
},
|
||
);
|
||
assert.equal(ok.status, 200);
|
||
|
||
const badWeight = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
"/api/executive-meetings/font-settings",
|
||
{ scope: "user", fontWeight: "medium" },
|
||
);
|
||
assert.equal(badWeight.status, 400);
|
||
|
||
const badSize = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
"/api/executive-meetings/font-settings",
|
||
{ scope: "user", fontSize: 30 },
|
||
);
|
||
assert.equal(badSize.status, 400);
|
||
|
||
const badFamily = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
"/api/executive-meetings/font-settings",
|
||
{ scope: "user", fontFamily: "Comic Sans" },
|
||
);
|
||
assert.equal(badFamily.status, 400);
|
||
});
|
||
|
||
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
|
||
const post = await api(
|
||
adminCookie,
|
||
"POST",
|
||
"/api/executive-meetings/pdf-archives",
|
||
{ archiveDate: today },
|
||
);
|
||
assert.equal(post.status, 201);
|
||
const archive = await post.json();
|
||
assert.ok(archive.id);
|
||
assert.ok(archive.version >= 1);
|
||
|
||
const list = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/pdf-archives?date=${today}`,
|
||
);
|
||
assert.equal(list.status, 200);
|
||
const body = await list.json();
|
||
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
|
||
assert.ok(Array.isArray(archives));
|
||
assert.ok(archives.some((a) => a.id === archive.id));
|
||
});
|
||
|
||
test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
|
||
// Create a meeting with mixed Arabic+Latin attendees + a time range so
|
||
// the renderer's bidi/font-segmentation path is exercised.
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اجتماع الميزانية Q2 2026",
|
||
titleEn: "Q2 2026 Budget Review",
|
||
meetingDate: today,
|
||
startTime: "10:00",
|
||
endTime: "11:30",
|
||
location: "قاعة كبرى",
|
||
attendees: [
|
||
{ name: "أحمد العلي", title: "المدير التنفيذي",
|
||
attendanceType: "internal", sortOrder: 0 },
|
||
{ name: "John Smith", title: "CFO",
|
||
attendanceType: "external", sortOrder: 1 },
|
||
],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
// Bad date → 400
|
||
const bad = await api(adminCookie, "GET",
|
||
"/api/executive-meetings/pdf?date=not-a-date");
|
||
assert.equal(bad.status, 400);
|
||
|
||
// Coordinator without executive role is denied — auth gate matches the
|
||
// rest of the executive-meetings module.
|
||
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
|
||
assert.equal(noAuth.status, 401);
|
||
|
||
// Happy path: real PDF response with non-trivial body.
|
||
const res = await api(adminCookie, "GET",
|
||
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
|
||
assert.equal(res.status, 200);
|
||
assert.match(
|
||
res.headers.get("content-type") || "",
|
||
/^application\/pdf/,
|
||
"must serve application/pdf",
|
||
);
|
||
assert.match(
|
||
res.headers.get("content-disposition") || "",
|
||
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
|
||
"filename should include the requested date",
|
||
);
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
|
||
assert.equal(
|
||
buf.subarray(0, 4).toString("ascii"),
|
||
"%PDF",
|
||
"body must start with PDF magic bytes",
|
||
);
|
||
|
||
// The download must have created an archive row recorded against the
|
||
// generating user with byteSize matching the served body.
|
||
const list = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/pdf-archives?date=${today}`,
|
||
);
|
||
assert.equal(list.status, 200);
|
||
const archives = (await list.json()).archives ?? [];
|
||
const generated = archives.find(
|
||
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
|
||
);
|
||
assert.ok(
|
||
generated,
|
||
"PDF download must produce an archive row with the served byte_size",
|
||
);
|
||
assert.ok(generated.generatedBy, "generatedBy must be recorded");
|
||
assert.ok(
|
||
typeof generated.filePath === "string" && generated.filePath.length > 0,
|
||
"filePath/storage_url must be non-empty",
|
||
);
|
||
|
||
// Empty-day handling: another date with no meetings still returns a
|
||
// valid PDF (with a "no meetings" message).
|
||
const empty = await api(adminCookie, "GET",
|
||
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
|
||
assert.equal(empty.status, 200);
|
||
const emptyBuf = Buffer.from(await empty.arrayBuffer());
|
||
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
|
||
|
||
// Font-family must affect which font is embedded in the PDF. We render
|
||
// the same day twice with different families and assert that:
|
||
// 1) Both downloads succeed (200 + %PDF magic).
|
||
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
|
||
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
|
||
// PostScript name appears verbatim in the PDF font dictionary.
|
||
const setSans = await api(
|
||
adminCookie,
|
||
"PUT",
|
||
"/api/executive-meetings/font-settings",
|
||
{
|
||
scope: "user",
|
||
fontFamily: "Cairo",
|
||
fontSize: 16,
|
||
fontWeight: "bold",
|
||
alignment: "center",
|
||
},
|
||
);
|
||
assert.equal(setSans.status, 200);
|
||
const sansRes = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
|
||
);
|
||
assert.equal(sansRes.status, 200);
|
||
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
|
||
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
|
||
const sansAscii = sansBuf.toString("latin1");
|
||
assert.ok(
|
||
/NotoSansArabic/i.test(sansAscii),
|
||
"Cairo family must embed NotoSansArabic in the PDF",
|
||
);
|
||
assert.ok(
|
||
!/NotoNaskhArabic/i.test(sansAscii),
|
||
"Cairo family must NOT embed NotoNaskhArabic",
|
||
);
|
||
|
||
const setNaskh = await api(
|
||
adminCookie,
|
||
"PUT",
|
||
"/api/executive-meetings/font-settings",
|
||
{
|
||
scope: "user",
|
||
fontFamily: "Noto Naskh Arabic",
|
||
fontSize: 14,
|
||
fontWeight: "regular",
|
||
alignment: "start",
|
||
},
|
||
);
|
||
assert.equal(setNaskh.status, 200);
|
||
const naskhRes = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
|
||
);
|
||
assert.equal(naskhRes.status, 200);
|
||
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
|
||
const naskhAscii = naskhBuf.toString("latin1");
|
||
assert.ok(
|
||
/NotoNaskhArabic/i.test(naskhAscii),
|
||
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
|
||
);
|
||
assert.ok(
|
||
!/NotoSansArabic/i.test(naskhAscii),
|
||
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
|
||
);
|
||
});
|
||
|
||
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
|
||
const dirty =
|
||
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
|
||
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: dirty,
|
||
titleEn: dirty,
|
||
meetingDate: today,
|
||
attendees: [
|
||
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
|
||
attendanceType: "internal", sortOrder: 0 },
|
||
],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const day = await api(adminCookie, "GET",
|
||
`/api/executive-meetings?date=${today}`);
|
||
const body = await day.json();
|
||
const m = body.meetings.find((x) => x.id === meeting.id);
|
||
assert.ok(m, "meeting must come back");
|
||
|
||
// disallowed tags must be stripped
|
||
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
|
||
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
|
||
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
|
||
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
|
||
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
|
||
|
||
// allowed inline formatting must be preserved
|
||
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
|
||
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
|
||
|
||
// attendee names get the same treatment
|
||
const att = m.attendees.find((a) => /Tester/.test(a.name));
|
||
assert.ok(att, "attendee must come back");
|
||
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
|
||
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
|
||
});
|
||
|
||
test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
|
||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
|
||
});
|
||
const M = await m.json(); created.meetingIds.push(M.id);
|
||
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
|
||
const patch = await api(adminCookie, "PATCH",
|
||
`/api/executive-meetings/${M.id}`, { titleAr: html });
|
||
assert.equal(patch.status, 200);
|
||
const day = await api(adminCookie, "GET",
|
||
`/api/executive-meetings?date=${today}`);
|
||
const body = await day.json();
|
||
const got = body.meetings.find((x) => x.id === M.id);
|
||
assert.ok(got, "meeting must come back");
|
||
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
|
||
"right-aligned <p> survives sanitization");
|
||
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
|
||
"center-aligned <p> survives sanitization");
|
||
});
|
||
|
||
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
||
const reorderDate = "2050-01-15";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
||
startTime: "09:00", endTime: "09:30",
|
||
});
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
|
||
startTime: "10:00", endTime: "10:30",
|
||
});
|
||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
|
||
startTime: "11:00", endTime: "11:30",
|
||
});
|
||
assert.equal(a.status, 201);
|
||
assert.equal(b.status, 201);
|
||
assert.equal(c.status, 201);
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
const C = await c.json(); created.meetingIds.push(C.id);
|
||
|
||
// reverse the order: C, B, A
|
||
const reorder = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
|
||
assert.equal(reorder.status, 200);
|
||
|
||
const day = await api(adminCookie, "GET",
|
||
`/api/executive-meetings?date=${reorderDate}`);
|
||
const body = await day.json();
|
||
const byId = new Map(body.meetings.map((m) => [m.id, m]));
|
||
const cAfter = byId.get(C.id);
|
||
const bAfter = byId.get(B.id);
|
||
const aAfter = byId.get(A.id);
|
||
assert.equal(cAfter.dailyNumber, 1);
|
||
assert.equal(bAfter.dailyNumber, 2);
|
||
assert.equal(aAfter.dailyNumber, 3);
|
||
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
||
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
||
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
||
const auditRows = await pool.query(
|
||
`SELECT action, entity_type, entity_id, new_value
|
||
FROM executive_meeting_audit_logs
|
||
WHERE action = 'executive_meeting.reorder'
|
||
AND entity_id = ANY($1::int[])
|
||
ORDER BY id DESC LIMIT 1`,
|
||
[[A.id, B.id, C.id]],
|
||
);
|
||
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
|
||
assert.equal(auditRows.rows[0].entity_type, "meeting");
|
||
const newOrder = auditRows.rows[0].new_value?.order;
|
||
assert.deepEqual(newOrder, [C.id, B.id, A.id],
|
||
"audit new_value.order must echo the new ordering");
|
||
});
|
||
|
||
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
|
||
const reorderDate = "2050-04-04";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||
});
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||
});
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
const r = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
|
||
assert.equal(r.status, 400);
|
||
const body = await r.json();
|
||
assert.equal(body.code, "duplicate_ids",
|
||
"duplicate ids must be reported with the duplicate_ids code");
|
||
});
|
||
|
||
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
|
||
const reorderDate = "2050-02-20";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||
startTime: "08:00", endTime: "08:30",
|
||
});
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||
startTime: "09:00", endTime: "09:30",
|
||
});
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
|
||
// Send only one of the two — server must refuse so 1..N renumber stays safe.
|
||
const r = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [A.id] });
|
||
assert.equal(r.status, 400);
|
||
const body = await r.json();
|
||
assert.equal(body.code, "incomplete_day");
|
||
});
|
||
|
||
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
|
||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
|
||
});
|
||
const M = await m.json(); created.meetingIds.push(M.id);
|
||
|
||
// POST /api/executive-meetings/:id/requests creates a request bound to
|
||
// the meeting. The route validates payload via requestPayloadSchemas.
|
||
const reqRes = await api(adminCookie, "POST",
|
||
`/api/executive-meetings/${M.id}/requests`, {
|
||
requestType: "add_attendee",
|
||
requestDetails: {
|
||
name: '<b>Real</b><script>alert(1)</script><img src=x onerror=alert(1)>',
|
||
attendanceType: "internal",
|
||
},
|
||
});
|
||
assert.equal(reqRes.status, 201);
|
||
const reqRow = await reqRes.json();
|
||
|
||
// PATCH /api/executive-meetings/requests/:id with {status:"approved"}
|
||
// routes through applyApprovedRequest -> add_attendee.
|
||
const approve = await api(adminCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ status: "approved" });
|
||
assert.equal(approve.status, 200);
|
||
|
||
const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`);
|
||
const detail = await det.json();
|
||
const att = detail.attendees.at(-1);
|
||
assert.ok(/<b[^>]*>Real<\/b>/i.test(att.name), "<b> must survive");
|
||
assert.ok(!/<script/i.test(att.name), "<script> must be stripped");
|
||
assert.ok(!/onerror/i.test(att.name), "onerror must be stripped");
|
||
});
|
||
|
||
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
|
||
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
|
||
});
|
||
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "غدًا", titleEn: "Tomorrow",
|
||
meetingDate: new Date(Date.now() + 86400000)
|
||
.toISOString().slice(0, 10),
|
||
});
|
||
const S = await sameDay.json(); created.meetingIds.push(S.id);
|
||
const O = await other.json(); created.meetingIds.push(O.id);
|
||
|
||
const r = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: today, orderedIds: [S.id, O.id] });
|
||
assert.equal(r.status, 400);
|
||
});
|
||
|
||
test("Reorder: user without executive role is denied (403)", async () => {
|
||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ا", titleEn: "A", meetingDate: today,
|
||
});
|
||
const M = await m.json(); created.meetingIds.push(M.id);
|
||
|
||
// a brand-new "user" has only the base 'user' role (no executive perms)
|
||
const plain = await createUser("em_plain", "user");
|
||
const plainCookie = await login(plain.username, TEST_PASSWORD);
|
||
|
||
const r = await api(plainCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: today, orderedIds: [M.id] });
|
||
assert.equal(r.status, 403);
|
||
});
|