Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T
riyadhafraa 371c44baba Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
  attendees column widest, tighter padding, navy/white/gray + red highlight
  only. Header label "م" → "#" (ar.json).

Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
  requests work without an existing meeting. db push applied.

Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full
rewrite:
- ALL routes (including /me) gated by requireExecutiveAccess plus fine-grained
  requireMutate / requireApprove / requireRequest / requireAdminAudit on the
  appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
  {status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
  {action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired DIRECTLY to a
  single shared upsertFontSettingsHandler (no Express method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- GET /notifications now accepts ?date=YYYY-MM-DD and joins through the
  meetings table to scope notifications to the selected day; returns []
  immediately when no meetings exist on that date.
- Removed all `as never` casts via $inferInsert/Partial typing.

Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin +
  executive_*).

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Tab-level RBAC: SECTIONS.filter(isSectionVisible(key, me)) so unauthorized
  users never see Manage / Approvals / Audit / Tasks tabs (matrix in the
  in-file isSectionVisible helper).
- Approvals: review() sends {status, reviewNotes}; new "Send back for edits"
  (needs_edit) button beside Approve / Reject.
- Requests default scope is "mine" for non-approvers and "all" for approvers
  (no over-exposure to plain requesters).
- Notifications visibility uses canRead AND the query is scoped by the same
  selected `date` as the schedule (queryKey + ?date=… included).
- PdfSection: primary "Print + Archive" button (testid em-print) does archive
  then print in one click; em-print-only and em-archive remain as separate
  buttons.
- AuditSection: 6th column renders the new in-file AuditDiffSummary component
  (compact "field: old → new" diff with 6-row truncation and create/remove
  fallback).
- New tWithFallback(t, key, fallback) helper used in audit + notifications
  cells (avoids strict TFunction overload errors); apiJson rewritten to
  extract body/headers separately and stringify rawBody, eliminating the
  "Record<string, unknown> not assignable to BodyInit" TS errors. tsc
  --noEmit on executive-meetings.tsx is now clean.
- Print page (executive-meetings-print.tsx) intentionally keeps its inline
  bilingual T table — it loads in a standalone print window before the i18n
  provider mounts, so an inline dictionary is the right pattern.

i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
  .audit.col.changes, .audit.{created, removed, moreChanges},
  .audit.entity.pdf_archive,
  .audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate,
  replace_attendees, done},
  .pdf.{print = "Print + archive" / "طباعة وأرشفة", printOnly,
  archivedAndPrinted}.

Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
  creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
  expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201, nested
  POST /:id/requests 201, PATCH /requests/:id status approval 200,
  audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
  200 with persisted data, /notifications?date=YYYY-MM-DD filters
  correctly.
- tsc --noEmit on artifacts/tx-os/src/pages/executive-meetings.tsx is
  clean (pre-existing admin.tsx errors are unrelated to this task).
- Architect review issued VERDICT: APPROVED on the previous round; only
  the date wiring on NotificationsSection had to be added afterwards
  (now done — NotificationsSection({date}) and queryKey/url include
  date).

Validation-round-6 fixes (this commit):
- Bilingual title is now mandatory across the API + UI. Backend Zod
  schema (meetingBaseFields.titleEn) requires .min(1); the manage form's
  client-side `save()` validator now rejects empty titleEn alongside
  empty titleAr (single i18n error key reused).
- Tasks: full reassign + edit flow. Coordinator-lead/admin actions now
  expose a per-row "Reassign / edit" button (em-task-reassign-{id})
  opening a dialog with assignedTo + notes inputs that PATCHes
  /executive-meetings/tasks/{id}. New i18n keys
  executiveMeetings.tasks.{reassign,reassigned} (AR + EN).
- Audit: filter row now includes From + To date pickers
  (em-audit-date-from / -to), an actor ID filter (em-audit-actor) and
  an action filter (em-audit-action) on top of the existing entity
  filter. Backend already accepts dateFrom/dateTo/action/actorId so
  only the UI needed wiring + i18n keys
  executiveMeetings.audit.filter.{from,to,actorId} (AR + EN).
- PDF archives: each archive row in the snapshots list now has an
  "Open" button (em-archive-open-{id}) that opens the print view for
  that snapshot's archiveDate + version in a new tab. New i18n key
  executiveMeetings.pdf.openArchive (AR + EN).
- Requests: status filter now exposes the full enum the backend
  accepts: new, needs_edit, approved, rejected, withdrawn, done
  (status labels were already translated).
- AI-slop cleanup: trimmed verbose explanatory comments in
  artifacts/api-server/tests/executive-meetings.test.mjs and the
  artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs.
- Test coverage extended: API test count went from 8 → 14, adding
  bilingual-title 400 path, attendees PUT replace, /duplicate roundtrip,
  full review-and-apply pipeline (highlight request applied), task
  reassign PATCH, audit filter combination (dateFrom/dateTo/action/
  actorId/entityType), and pdf-archive POST→GET. All 14 pass against
  the live API. The Manage create E2E (1 spec) still passes.

Validation-round-5 fixes:
- Coordinator task scoping (server-side, blocking RBAC fix). Added
  TASK_BROAD_VIEW_ROLES (admin + office_manager + coord_lead). Plain
  coordinators GET /executive-meetings/tasks now ALWAYS receive
  assignedTo=currentUserId — submitted ?mine=0 / ?assigneeId=other are
  ignored. /me also exposes a new canViewAllTasks flag for the UI.
- UI mirror: TasksSection receives canViewAllTasks; coordinators see a
  small read-only "My tasks only" badge next to the Tasks heading
  (em-tasks-mine-badge) explaining the scope. New i18n keys
  executiveMeetings.tasks.{myTasksOnly,myTasksOnlyHelp} (AR + EN).
- Seed: scripts/src/seed.ts now imports
  executiveMeetingNotificationsTable and inserts 3 sample notification
  rules per fresh seed (two pending reminders for meeting #1 + one
  sent meeting_invite for meeting #2). Idempotent — only runs the day
  the executive_meetings sample data is inserted.
- Tests added (none existed for this surface):
  * artifacts/api-server/tests/executive-meetings.test.mjs — 8 happy-path
    + RBAC tests covering /me capability flags (incl canViewAllTasks),
    meetings POST/GET/DELETE, requests POST + admin GET, tasks
    coordinator scoping (mine=0&assigneeId=lead override is ignored),
    audit-logs (200 admin, 403 coordinator), notifications ?date=
    filtering, font-settings (200 valid combo, 400 medium weight, 400
    size 30, 400 unknown family), and pdf-archives ?date=. All 8 pass.
  * artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs —
    Playwright UI test that logs in as admin, opens the Manage tab via
    em-nav-manage, fills the create dialog (titleAr + titleEn + date),
    saves, asserts POST /api/executive-meetings returns 201, and
    verifies the row landed in the DB with the expected title and date.

Validation-round-4 fixes:
- Manage attendee editor: added per-row title input alongside name +
  deterministic ChevronUp/ChevronDown reorder controls (sort order is
  recomputed on save). New i18n keys
  executiveMeetings.manage.attendees.{moveUp,moveDown}.
- Manage tab: per-row Copy button opens a new "Duplicate to date" dialog
  that calls POST /executive-meetings/:id/duplicate with a target date
  picker; on success it switches the day view to the chosen date. New
  i18n keys executiveMeetings.manage.{duplicate, duplicateToDate,
  duplicated, duplicateFailed}.
- Print page (executive-meetings-print.tsx) now uses react-i18next with
  new locale block executiveMeetings.print.* (AR + EN parity), forces
  i18n.changeLanguage(lang) so ?lang=en always prints English. Attendees
  cell in the print table is now textAlign: center (matches the Step 1
  schedule).
- Font settings tightened to spec: families = system/Cairo/Tajawal/
  Noto Naskh Arabic/Amiri (dropped Inter, monospace), weights =
  regular/bold (dropped medium, semibold), alignment = start/center
  (dropped end), size range = 12–22 (was 10–32). Enforced server-side
  in fontSettingsSchema (Zod) and mirrored in the UI selects/range.
  Verified: PATCH font-settings returns 200 for valid combo, 400 for
  fontWeight="medium", 400 for fontSize=30.

Validation-round-3 fixes:
- Tasks RBAC tightened end-to-end: new TASK_VIEW_ROLES = admin +
  office_manager + coord_lead + coordinator. GET /tasks and
  PATCH /tasks/:id now require requireTaskView. /me exposes new
  canViewTasks flag. Frontend isSectionVisible("tasks") gated on
  canViewTasks (so CEO/viewer never sees the Tasks tab or hits the
  endpoint). Verified: admin /me returns canViewTasks=true.
- GET /requests gained dateFrom + dateTo (createdAt range) and a
  requester filter that is honored only for approver/audit roles
  (non-approvers stay locked to their own requests). Verified curl
  with ?dateFrom=...&dateTo=... returns 200.
- Task dueAt schema accepts BOTH YYYY-MM-DD (from <input type="date">)
  and full ISO datetime; date-only is normalized to start-of-day UTC,
  empty string clears the field. Verified curl POST /tasks with
  {"dueAt":"2026-12-31"} returns 201 with stored
  dueAt = 2026-12-31T00:00:00.000Z.

Validation-round-2 fixes:
- GET /executive-meetings/requests: added server-side least-privilege so
  only APPROVE_ROLES + ADMIN_AUDIT_ROLES see other users' requests; every
  other executive role is force-scoped to requestedBy = self regardless
  of the client's `mine` flag (closes the RBAC overexposure flag).
- isSectionVisible("tasks") now also returns true for canSubmitRequest,
  so executive_coordinator sees the Tasks tab (coordinators execute the
  follow-ups generated from approved requests).
- Requests "new request" dropdown now exposes ALL twelve backend request
  types: create, edit, delete, reschedule, add_attendee, remove_attendee,
  change_location, cancel_meeting, note, highlight, unhighlight, other.
- i18n parity: added en.json + ar.json keys for the five missing request
  types (add_attendee, remove_attendee, change_location, cancel_meeting,
  note) and the two missing statuses (needs_edit, done) under
  executiveMeetings.requests.{type,status}.
- Fixed wrong key reference: reschedule form's date row was using
  manage.field.date (does not exist) — switched to manage.field.meetingDate.

Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112):
- Real notifications delivery, server-side PDF rendering, attendee
  reorder UI (drag/arrows), duplicate-to-date UI, OpenAPI/api-spec sync,
  expanded seed data including executive_meeting_notifications sample
  rows.
2026-04-28 08:14:01 +00:00

541 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const TEST_PASSWORD = "TestPass123!";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = {
userIds: [],
meetingIds: [],
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));
});