1b40d7dc00
Why: Task #220 added a client-only `_sid` field on attendee rows so React DnD can identify rows. The two client save sites already enumerate wire fields explicitly and never serialize `_sid`, but nothing prevents a future refactor from accidentally leaking it. The server was zod-default lenient (silently strips unknowns), so a regression would either be silently absorbed (bad — silent contract drift) or land in a future JSONB metadata column without anyone noticing. What changed: - `attendeeSchema` in artifacts/api-server/src/routes/executive-meetings.ts is now `.strict()`. Any unknown attendee key (including `_sid` or any future client-only field) is rejected with HTTP 400 instead of being silently stripped. The schema is reused by all three attendee-bearing endpoints (POST /executive-meetings, PATCH /executive-meetings/:id, PUT /executive-meetings/:id/attendees), so all three are covered by one change. Tests: - Added three API tests in artifacts/api-server/tests/executive-meetings.test.mjs: 1. POST /executive-meetings with attendee carrying `_sid` returns 400 and the error mentions the rejected key. 2. PATCH /executive-meetings/:id with attendees carrying `_sid` returns 400 AND the meeting's existing attendee list is preserved (no partial mutation). 3. PUT /executive-meetings/:id/attendees with attendee carrying `_sid` returns 400 AND the seeded attendee is unchanged. Verification: - Full API test suite: 207/207 green (was 204/204 before; +3 new). - No client-side change needed: existing `saveAttendeeName` (~L943 in artifacts/tx-os/src/pages/executive-meetings.tsx) and the manage-dialog save (~L4004) already project to the documented wire shape (`name, title, attendanceType, sortOrder, kind`). - Architect review: addressed the one gap (PATCH coverage) by adding test #2 above; verdict resolved. Out of scope cleanup: - Marked the descriptions of stale tasks #172 and #179 as STALE (both PDF tests pass and PDF export works in current main). Final cancellation left to the user.
1624 lines
58 KiB
JavaScript
1624 lines
58 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,
|
||
]);
|
||
// 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 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"));
|
||
});
|
||
|
||
// --- Attendee payload contract (Task #221) ---------------------------------
|
||
// Server is `.strict()` on the attendee schema so client-only fields like
|
||
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
|
||
// loudly with 400 instead of being silently stripped. This locks down the
|
||
// wire contract so a future client refactor that accidentally serializes
|
||
// `_sid` (or any other internal field) breaks tests immediately rather
|
||
// than being absorbed by lenient parsing.
|
||
test("Attendee payload contract: POST rejects unknown fields like `_sid`", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "س",
|
||
titleEn: "S",
|
||
meetingDate: today,
|
||
attendees: [
|
||
{
|
||
name: "Leaky One",
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
_sid: "client-only-row-id",
|
||
},
|
||
],
|
||
});
|
||
assert.equal(
|
||
create.status,
|
||
400,
|
||
`expected 400 when attendee carries _sid, got ${create.status}`,
|
||
);
|
||
const body = await create.json();
|
||
// Zod surfaces the rejected key in the error path.
|
||
const serialized = JSON.stringify(body);
|
||
assert.ok(
|
||
serialized.includes("_sid") || serialized.toLowerCase().includes("unrecognized"),
|
||
`expected error to mention the rejected key, got: ${serialized}`,
|
||
);
|
||
});
|
||
|
||
test("Attendee payload contract: PATCH /:id rejects unknown attendee fields like `_sid`", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ت",
|
||
titleEn: "T",
|
||
meetingDate: today,
|
||
attendees: [{ name: "Patch Seed", attendanceType: "internal", sortOrder: 0 }],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const patch = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
{
|
||
attendees: [
|
||
{
|
||
name: "Leaky Three",
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
_sid: "client-only-row-id",
|
||
},
|
||
],
|
||
},
|
||
);
|
||
assert.equal(
|
||
patch.status,
|
||
400,
|
||
`expected 400 when PATCH attendee carries _sid, got ${patch.status}`,
|
||
);
|
||
|
||
// The seed attendee must still be the only one stored — the rejected
|
||
// PATCH must not have replaced the attendee list.
|
||
const fetched = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
);
|
||
const body = await fetched.json();
|
||
assert.equal(body.attendees.length, 1);
|
||
assert.equal(body.attendees[0].name, "Patch Seed");
|
||
});
|
||
|
||
test("Attendee payload contract: PUT /attendees rejects unknown fields like `_sid`", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ش",
|
||
titleEn: "Sh",
|
||
meetingDate: today,
|
||
attendees: [{ name: "Seed", attendanceType: "internal", sortOrder: 0 }],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const replace = await api(
|
||
adminCookie,
|
||
"PUT",
|
||
`/api/executive-meetings/${meeting.id}/attendees`,
|
||
{
|
||
attendees: [
|
||
{
|
||
name: "Leaky Two",
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
_sid: "client-only-row-id",
|
||
},
|
||
],
|
||
},
|
||
);
|
||
assert.equal(
|
||
replace.status,
|
||
400,
|
||
`expected 400 when PUT attendee carries _sid, got ${replace.status}`,
|
||
);
|
||
|
||
// And the meeting's attendee list must not have changed (the seed row
|
||
// still wins, the leaky row never persists).
|
||
const fetched = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
);
|
||
const body = await fetched.json();
|
||
assert.equal(body.attendees.length, 1);
|
||
assert.equal(body.attendees[0].name, "Seed");
|
||
});
|
||
|
||
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب",
|
||
titleEn: "B",
|
||
meetingDate: today,
|
||
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const original = await create.json();
|
||
created.meetingIds.push(original.id);
|
||
|
||
const dup = await api(
|
||
adminCookie,
|
||
"POST",
|
||
`/api/executive-meetings/${original.id}/duplicate`,
|
||
{ targetDate: tomorrow },
|
||
);
|
||
assert.equal(dup.status, 201);
|
||
const newRow = await dup.json();
|
||
assert.notEqual(newRow.id, original.id);
|
||
created.meetingIds.push(newRow.id);
|
||
|
||
const day = await api(
|
||
adminCookie,
|
||
"GET",
|
||
`/api/executive-meetings?date=${tomorrow}`,
|
||
);
|
||
const dayBody = await day.json();
|
||
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
|
||
});
|
||
|
||
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: attendee.title is stripped of HTML on every write path", async () => {
|
||
const malicious = '<script>alert(1)</script><b onclick="x">CFO</b>';
|
||
// POST creates the meeting with a malicious title on one attendee.
|
||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "تنظيف العنوان",
|
||
titleEn: "Title sanitization",
|
||
meetingDate: today,
|
||
attendees: [
|
||
{
|
||
name: "Attendee POST",
|
||
title: malicious,
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
},
|
||
],
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const meeting = await create.json();
|
||
created.meetingIds.push(meeting.id);
|
||
|
||
const postedTitle = meeting.attendees[0].title;
|
||
assert.ok(postedTitle, "title must round-trip");
|
||
assert.ok(!/<script/i.test(postedTitle), "POST: <script> stripped from title");
|
||
assert.ok(!/<b/i.test(postedTitle), "POST: <b> stripped from title");
|
||
assert.ok(!/onclick/i.test(postedTitle), "POST: onclick stripped from title");
|
||
assert.ok(/CFO/.test(postedTitle), "POST: visible text preserved in title");
|
||
|
||
// PATCH replaces attendees with a different malicious title.
|
||
const patched = await api(
|
||
adminCookie,
|
||
"PATCH",
|
||
`/api/executive-meetings/${meeting.id}`,
|
||
{
|
||
attendees: [
|
||
{
|
||
name: "Attendee PATCH",
|
||
title: '<img src=x onerror=alert(1)>Director',
|
||
attendanceType: "internal",
|
||
sortOrder: 0,
|
||
},
|
||
],
|
||
},
|
||
);
|
||
assert.equal(patched.status, 200);
|
||
const patchedBody = await patched.json();
|
||
const patchedTitle = patchedBody.attendees[0].title;
|
||
assert.ok(!/<img/i.test(patchedTitle), "PATCH: <img> stripped from title");
|
||
assert.ok(!/onerror/i.test(patchedTitle), "PATCH: onerror stripped from title");
|
||
assert.ok(/Director/.test(patchedTitle), "PATCH: visible text preserved");
|
||
|
||
// PUT /attendees replaces the list and must sanitize too.
|
||
const put = await api(
|
||
adminCookie,
|
||
"PUT",
|
||
`/api/executive-meetings/${meeting.id}/attendees`,
|
||
{
|
||
attendees: [
|
||
{
|
||
name: "Attendee PUT",
|
||
title: '<a href="javascript:bad()">Manager</a>',
|
||
attendanceType: "virtual",
|
||
sortOrder: 0,
|
||
},
|
||
],
|
||
},
|
||
);
|
||
assert.equal(put.status, 200);
|
||
const putBody = await put.json();
|
||
const putTitle = putBody.attendees[0].title;
|
||
assert.ok(!/<a/i.test(putTitle), "PUT: <a> stripped from title");
|
||
assert.ok(!/javascript:/i.test(putTitle), "PUT: javascript: scheme gone");
|
||
assert.ok(/Manager/.test(putTitle), "PUT: visible text preserved");
|
||
|
||
// Duplicate path: copy of a meeting with a sanitized title must stay sanitized.
|
||
const dup = await api(
|
||
adminCookie,
|
||
"POST",
|
||
`/api/executive-meetings/${meeting.id}/duplicate`,
|
||
{ targetDate: today },
|
||
);
|
||
assert.equal(dup.status, 201);
|
||
const dupBody = await dup.json();
|
||
created.meetingIds.push(dupBody.id);
|
||
const dupTitle = dupBody.attendees[0].title;
|
||
assert.ok(!/<a/i.test(dupTitle), "duplicate: title remains free of HTML tags");
|
||
assert.ok(!/<script/i.test(dupTitle), "duplicate: no <script> in copied title");
|
||
assert.ok(/Manager/.test(dupTitle), "duplicate: visible text preserved");
|
||
});
|
||
|
||
test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
|
||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
|
||
});
|
||
const M = await m.json(); created.meetingIds.push(M.id);
|
||
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
|
||
const patch = await api(adminCookie, "PATCH",
|
||
`/api/executive-meetings/${M.id}`, { titleAr: html });
|
||
assert.equal(patch.status, 200);
|
||
const day = await api(adminCookie, "GET",
|
||
`/api/executive-meetings?date=${today}`);
|
||
const body = await day.json();
|
||
const got = body.meetings.find((x) => x.id === M.id);
|
||
assert.ok(got, "meeting must come back");
|
||
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
|
||
"right-aligned <p> survives sanitization");
|
||
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
|
||
"center-aligned <p> survives sanitization");
|
||
});
|
||
|
||
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
||
const reorderDate = "2050-01-15";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
||
startTime: "09:00", endTime: "09:30",
|
||
});
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
|
||
startTime: "10:00", endTime: "10:30",
|
||
});
|
||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
|
||
startTime: "11:00", endTime: "11:30",
|
||
});
|
||
assert.equal(a.status, 201);
|
||
assert.equal(b.status, 201);
|
||
assert.equal(c.status, 201);
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
const C = await c.json(); created.meetingIds.push(C.id);
|
||
|
||
// reverse the order: C, B, A
|
||
const reorder = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
|
||
assert.equal(reorder.status, 200);
|
||
|
||
const day = await api(adminCookie, "GET",
|
||
`/api/executive-meetings?date=${reorderDate}`);
|
||
const body = await day.json();
|
||
const byId = new Map(body.meetings.map((m) => [m.id, m]));
|
||
const cAfter = byId.get(C.id);
|
||
const bAfter = byId.get(B.id);
|
||
const aAfter = byId.get(A.id);
|
||
assert.equal(cAfter.dailyNumber, 1);
|
||
assert.equal(bAfter.dailyNumber, 2);
|
||
assert.equal(aAfter.dailyNumber, 3);
|
||
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
||
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
||
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
||
const auditRows = await pool.query(
|
||
`SELECT action, entity_type, entity_id, new_value
|
||
FROM executive_meeting_audit_logs
|
||
WHERE action = 'executive_meeting.reorder'
|
||
AND entity_id = ANY($1::int[])
|
||
ORDER BY id DESC LIMIT 1`,
|
||
[[A.id, B.id, C.id]],
|
||
);
|
||
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
|
||
assert.equal(auditRows.rows[0].entity_type, "meeting");
|
||
const newOrder = auditRows.rows[0].new_value?.order;
|
||
assert.deepEqual(newOrder, [C.id, B.id, A.id],
|
||
"audit new_value.order must echo the new ordering");
|
||
});
|
||
|
||
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
|
||
const reorderDate = "2050-04-04";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||
});
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||
});
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
const r = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
|
||
assert.equal(r.status, 400);
|
||
const body = await r.json();
|
||
assert.equal(body.code, "duplicate_ids",
|
||
"duplicate ids must be reported with the duplicate_ids code");
|
||
});
|
||
|
||
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
|
||
const reorderDate = "2050-02-20";
|
||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||
startTime: "08:00", endTime: "08:30",
|
||
});
|
||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||
startTime: "09:00", endTime: "09:30",
|
||
});
|
||
const A = await a.json(); created.meetingIds.push(A.id);
|
||
const B = await b.json(); created.meetingIds.push(B.id);
|
||
|
||
// Send only one of the two — server must refuse so 1..N renumber stays safe.
|
||
const r = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/reorder",
|
||
{ meetingDate: reorderDate, orderedIds: [A.id] });
|
||
assert.equal(r.status, 400);
|
||
const body = await r.json();
|
||
assert.equal(body.code, "incomplete_day");
|
||
});
|
||
|
||
test("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);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Phase-2 task #112 additions:
|
||
// - per-role meeting CRUD permissions
|
||
// - request reject / withdraw flows
|
||
// - task assignee-only status updates
|
||
// - font-settings PATCH/GET roundtrip
|
||
// - non-numeric :id returns 404 (router.param guard) instead of crashing
|
||
// - audit-insert failure rolls back the parent mutation (transactional)
|
||
// ---------------------------------------------------------------------
|
||
|
||
test("Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed", async () => {
|
||
// executive_coordinator is in REQUEST_ROLES but NOT in MUTATE_ROLES, so
|
||
// a direct POST/PATCH/DELETE on /executive-meetings must come back 403.
|
||
const coordCreate = await api(coordCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "م", titleEn: "M", meetingDate: today,
|
||
});
|
||
assert.equal(coordCreate.status, 403,
|
||
"executive_coordinator must not be able to POST a meeting");
|
||
|
||
// executive_coord_lead is in MUTATE_ROLES, so the same call must succeed.
|
||
const leadCreate = await api(leadCookie, "POST", "/api/executive-meetings", {
|
||
titleAr: "ل", titleEn: "L", meetingDate: today,
|
||
});
|
||
assert.equal(leadCreate.status, 201,
|
||
"executive_coord_lead must be able to POST a meeting");
|
||
const leadMeeting = await leadCreate.json();
|
||
created.meetingIds.push(leadMeeting.id);
|
||
|
||
// Coordinator can still GET (read role) but cannot PATCH or DELETE.
|
||
const coordRead = await api(coordCookie, "GET",
|
||
`/api/executive-meetings/${leadMeeting.id}`);
|
||
assert.equal(coordRead.status, 200,
|
||
"coordinator should still be allowed to read meeting details");
|
||
|
||
const coordPatch = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/${leadMeeting.id}`, { notes: "no" });
|
||
assert.equal(coordPatch.status, 403,
|
||
"coordinator must not PATCH meetings directly");
|
||
|
||
const coordDelete = await api(coordCookie, "DELETE",
|
||
`/api/executive-meetings/${leadMeeting.id}`);
|
||
assert.equal(coordDelete.status, 403,
|
||
"coordinator must not DELETE meetings directly");
|
||
|
||
// Lead can mutate.
|
||
const leadPatch = await api(leadCookie, "PATCH",
|
||
`/api/executive-meetings/${leadMeeting.id}`, { notes: "ok" });
|
||
assert.equal(leadPatch.status, 200,
|
||
"executive_coord_lead must be able to PATCH meetings");
|
||
});
|
||
|
||
test("Requests: coordinator can submit + withdraw their own request", async () => {
|
||
const reqRes = await api(coordCookie, "POST",
|
||
"/api/executive-meetings/requests", {
|
||
requestType: "note",
|
||
requestDetails: { note: "coord submitting" },
|
||
});
|
||
assert.equal(reqRes.status, 201,
|
||
"coordinator (REQUEST_ROLES) must be able to POST a request");
|
||
const reqRow = await reqRes.json();
|
||
assert.equal(reqRow.status, "new");
|
||
assert.equal(reqRow.requestedBy, coordUserId);
|
||
created.requestIds.push(reqRow.id);
|
||
|
||
// A different coordinator cannot withdraw someone else's request.
|
||
const otherCoord = await createUser("em_coord_other", "executive_coordinator");
|
||
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
||
const otherWithdraw = await api(otherCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ action: "withdraw" });
|
||
assert.equal(otherWithdraw.status, 403,
|
||
"non-requester must not be able to withdraw someone else's request");
|
||
|
||
// The original requester can withdraw while still 'new'.
|
||
const withdraw = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ action: "withdraw" });
|
||
assert.equal(withdraw.status, 200);
|
||
const withdrawn = await withdraw.json();
|
||
assert.equal(withdrawn.status, "withdrawn");
|
||
|
||
// Once withdrawn, repeated withdraw must 409 (bad_state), not crash.
|
||
const again = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ action: "withdraw" });
|
||
assert.equal(again.status, 409);
|
||
const againBody = await again.json();
|
||
assert.equal(againBody.code, "bad_state");
|
||
});
|
||
|
||
test("Requests: admin can reject; rejected requests cannot be re-reviewed", async () => {
|
||
const reqRes = await api(coordCookie, "POST",
|
||
"/api/executive-meetings/requests", {
|
||
requestType: "note",
|
||
requestDetails: { note: "to be rejected" },
|
||
});
|
||
assert.equal(reqRes.status, 201);
|
||
const reqRow = await reqRes.json();
|
||
created.requestIds.push(reqRow.id);
|
||
|
||
// Coordinator (no APPROVE_ROLES) cannot reject.
|
||
const coordReject = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ status: "rejected", reviewNotes: "not allowed" });
|
||
assert.equal(coordReject.status, 403,
|
||
"non-approver must not be able to reject a request");
|
||
|
||
// Admin can reject. The response must reflect the new status and reviewer.
|
||
const reject = await api(adminCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ status: "rejected", reviewNotes: "no" });
|
||
assert.equal(reject.status, 200);
|
||
const rejected = await reject.json();
|
||
assert.equal(rejected.status, "rejected");
|
||
assert.equal(rejected.reviewedBy, adminUserId);
|
||
assert.equal(rejected.reviewNotes, "no");
|
||
|
||
// After review the original requester can no longer withdraw.
|
||
const lateWithdraw = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ action: "withdraw" });
|
||
assert.equal(lateWithdraw.status, 409,
|
||
"withdrawing an already-reviewed request must 409, not crash");
|
||
|
||
// Trying to re-review must also 409 with code:bad_state.
|
||
const reReview = await api(adminCookie, "PATCH",
|
||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||
{ status: "approved", reviewNotes: "flip" });
|
||
assert.equal(reReview.status, 409);
|
||
const reBody = await reReview.json();
|
||
assert.equal(reBody.code, "bad_state");
|
||
});
|
||
|
||
test("Tasks: assignee can update status; non-assignee non-mutator gets 403", async () => {
|
||
// Admin creates a task assigned to coord.
|
||
const create = await api(adminCookie, "POST",
|
||
"/api/executive-meetings/tasks", {
|
||
taskType: "follow_up",
|
||
assignedTo: coordUserId,
|
||
notes: "assignee-only test",
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const task = await create.json();
|
||
created.taskIds.push(task.id);
|
||
|
||
// The assignee (coord) can flip the status even though they are NOT in
|
||
// MUTATE_ROLES. This is the assignedTo carve-out in the PATCH handler.
|
||
const assigneeUpdate = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/tasks/${task.id}`, { status: "in_progress" });
|
||
assert.equal(assigneeUpdate.status, 200,
|
||
"assignee must be able to update their own task status");
|
||
const updated = await assigneeUpdate.json();
|
||
assert.equal(updated.status, "in_progress");
|
||
|
||
// The assignee CANNOT change non-status fields (mutator-only).
|
||
const assigneeReassign = await api(coordCookie, "PATCH",
|
||
`/api/executive-meetings/tasks/${task.id}`,
|
||
{ assignedTo: leadUserId });
|
||
assert.equal(assigneeReassign.status, 200,
|
||
"assignee patching a mutator-only field returns the row but ignores it");
|
||
const stillCoord = await assigneeReassign.json();
|
||
assert.equal(stillCoord.assignedTo, coordUserId,
|
||
"assignee may not reassign a task to someone else");
|
||
|
||
// A different coordinator (not the assignee, not a mutator) is rejected.
|
||
const otherCoord = await createUser("em_other_coord", "executive_coordinator");
|
||
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
||
const nonAssignee = await api(otherCookie, "PATCH",
|
||
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
||
assert.equal(nonAssignee.status, 403,
|
||
"non-assignee non-mutator must be denied by the PATCH handler");
|
||
|
||
// Mutator (lead) is always allowed.
|
||
const mutator = await api(leadCookie, "PATCH",
|
||
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
||
assert.equal(mutator.status, 200);
|
||
});
|
||
|
||
test("Font settings: PUT then GET returns the user-scoped row roundtrip", async () => {
|
||
const put = await api(adminCookie, "PUT",
|
||
"/api/executive-meetings/font-settings", {
|
||
scope: "user",
|
||
fontFamily: "Tajawal",
|
||
fontSize: 18,
|
||
fontWeight: "bold",
|
||
alignment: "center",
|
||
});
|
||
assert.equal(put.status, 200);
|
||
|
||
const get = await api(adminCookie, "GET",
|
||
"/api/executive-meetings/font-settings");
|
||
assert.equal(get.status, 200);
|
||
const body = await get.json();
|
||
assert.ok(body.user, "GET must include the user-scoped row");
|
||
assert.equal(body.user.scope, "user");
|
||
assert.equal(body.user.fontFamily, "Tajawal");
|
||
assert.equal(body.user.fontSize, 18);
|
||
assert.equal(body.user.fontWeight, "bold");
|
||
assert.equal(body.user.alignment, "center");
|
||
|
||
// PATCH alias must hit the same upsert handler.
|
||
const patch = await api(adminCookie, "PATCH",
|
||
"/api/executive-meetings/font-settings", {
|
||
scope: "user",
|
||
fontFamily: "Cairo",
|
||
fontSize: 14,
|
||
fontWeight: "regular",
|
||
alignment: "start",
|
||
});
|
||
assert.equal(patch.status, 200);
|
||
|
||
const get2 = await api(adminCookie, "GET",
|
||
"/api/executive-meetings/font-settings");
|
||
const body2 = await get2.json();
|
||
assert.equal(body2.user.fontFamily, "Cairo");
|
||
assert.equal(body2.user.fontSize, 14);
|
||
assert.equal(body2.user.fontWeight, "regular");
|
||
assert.equal(body2.user.alignment, "start");
|
||
});
|
||
|
||
test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => {
|
||
// The router.param("id", ...) guard in executive-meetings.ts must reject
|
||
// non-digit ids by calling next("route"), so Express falls through to 404
|
||
// instead of attempting Number("abc") and crashing further down.
|
||
const cases = [
|
||
["GET", "/api/executive-meetings/abc"],
|
||
["PATCH", "/api/executive-meetings/abc"],
|
||
["DELETE", "/api/executive-meetings/abc"],
|
||
["GET", "/api/executive-meetings/123abc"],
|
||
["GET", "/api/executive-meetings/-1"],
|
||
["POST", "/api/executive-meetings/abc/duplicate"],
|
||
["PUT", "/api/executive-meetings/abc/attendees"],
|
||
["POST", "/api/executive-meetings/abc/requests"],
|
||
["PATCH", "/api/executive-meetings/requests/abc"],
|
||
["PATCH", "/api/executive-meetings/tasks/abc"],
|
||
["DELETE", "/api/executive-meetings/tasks/abc"],
|
||
];
|
||
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: in-app fan-out skips users who muted that channel", async () => {
|
||
// request_submitted notifications fan out to APPROVE_ROLES (admin,
|
||
// executive_office_manager). Create two approver users — mute one,
|
||
// leave the other on — then submit a request and verify only the
|
||
// non-muted user receives a new in-app notification row.
|
||
const muted = await createUser("em_pref_muted_appr", "executive_office_manager");
|
||
const control = await createUser("em_pref_control_appr", "executive_office_manager");
|
||
const mutedCookie = await login(muted.username, TEST_PASSWORD);
|
||
|
||
const muteRes = await api(mutedCookie, "PUT",
|
||
"/api/executive-meetings/notification-prefs",
|
||
{ prefs: [{ notificationType: "request_submitted", inApp: false, email: false }] });
|
||
assert.equal(muteRes.status, 200);
|
||
|
||
async function countFor(userId) {
|
||
const r = await pool.query(
|
||
`SELECT COUNT(*)::int AS n
|
||
FROM executive_meeting_notifications
|
||
WHERE user_id = $1
|
||
AND notification_type = 'request_submitted'`,
|
||
[userId],
|
||
);
|
||
return r.rows[0].n;
|
||
}
|
||
|
||
const beforeMuted = await countFor(muted.id);
|
||
const beforeControl = await countFor(control.id);
|
||
|
||
const submit = await api(coordCookie, "POST",
|
||
"/api/executive-meetings/requests",
|
||
{ requestType: "note", requestDetails: { note: "fanout-mute-test" } });
|
||
assert.equal(submit.status, 201);
|
||
const submitted = await submit.json();
|
||
created.requestIds.push(submitted.id);
|
||
|
||
const afterMuted = await countFor(muted.id);
|
||
const afterControl = await countFor(control.id);
|
||
|
||
assert.equal(afterMuted, beforeMuted,
|
||
"muted approver must not receive an in-app notification row");
|
||
assert.ok(afterControl > beforeControl,
|
||
"non-muted approver must still receive an in-app notification row " +
|
||
`(before=${beforeControl}, after=${afterControl})`);
|
||
});
|
||
|
||
test("Notification prefs: channels are independent (email-only mute leaves in-app delivery intact)", async () => {
|
||
// Muting one channel must not affect the other. This complements the
|
||
// both-channels-off test above by exercising the email-channel filter
|
||
// path (sendExecutiveMeetingEmail uses filterRecipientsByNotificationPref
|
||
// with channel="email") while in-app stays on.
|
||
const approver = await createUser("em_pref_email_only", "executive_office_manager");
|
||
const approverCookie = await login(approver.username, TEST_PASSWORD);
|
||
|
||
// Email off, in-app on.
|
||
const muteEmail = await api(approverCookie, "PUT",
|
||
"/api/executive-meetings/notification-prefs",
|
||
{ prefs: [{ notificationType: "request_submitted", inApp: true, email: false }] });
|
||
assert.equal(muteEmail.status, 200);
|
||
|
||
async function inAppCount(userId) {
|
||
const r = await pool.query(
|
||
`SELECT COUNT(*)::int AS n
|
||
FROM executive_meeting_notifications
|
||
WHERE user_id = $1
|
||
AND notification_type = 'request_submitted'`,
|
||
[userId],
|
||
);
|
||
return r.rows[0].n;
|
||
}
|
||
|
||
const before = await inAppCount(approver.id);
|
||
const submit = await api(coordCookie, "POST",
|
||
"/api/executive-meetings/requests",
|
||
{ requestType: "note", requestDetails: { note: "email-mute-only-test" } });
|
||
assert.equal(submit.status, 201);
|
||
const submitted = await submit.json();
|
||
created.requestIds.push(submitted.id);
|
||
|
||
const after = await inAppCount(approver.id);
|
||
assert.ok(after > before,
|
||
"user who muted only the email channel must still receive in-app rows " +
|
||
`(before=${before}, after=${after})`);
|
||
|
||
// Confirm the saved row really is email=false / in-app=true so that
|
||
// sendExecutiveMeetingEmail (which calls the same helper with
|
||
// channel="email") will see the explicit opt-out.
|
||
const { rows } = await pool.query(
|
||
`SELECT in_app, email
|
||
FROM executive_meeting_notification_prefs
|
||
WHERE user_id = $1
|
||
AND notification_type = 'request_submitted'`,
|
||
[approver.id],
|
||
);
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].in_app, true);
|
||
assert.equal(rows[0].email, false);
|
||
});
|