diff --git a/artifacts/api-server/tests/executive-meetings-notifications.test.mjs b/artifacts/api-server/tests/executive-meetings-notifications.test.mjs new file mode 100644 index 00000000..9fdf9a0f --- /dev/null +++ b/artifacts/api-server/tests/executive-meetings-notifications.test.mjs @@ -0,0 +1,729 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; +import { io as ioClient } from "socket.io-client"; + +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: [], + groupIds: [], + meetingIds: [], + requestIds: [], + taskIds: [], +}; + +function uniqueName(prefix) { + return `${prefix}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +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); +} + +async function makeUser(prefix) { + 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, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], + ); + const id = rows[0].id; + created.userIds.push(id); + return { id, username }; +} + +async function grantRoleDirect(userId, roleName) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = $2 + ON CONFLICT DO NOTHING`, + [userId, roleName], + ); +} + +async function grantRoleViaGroup(userId, roleName, groupName) { + const g = await pool.query( + `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, + [groupName, "notif fan-out test group"], + ); + const groupId = g.rows[0].id; + created.groupIds.push(groupId); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, + [userId, groupId], + ); + await pool.query( + `INSERT INTO group_roles (group_id, role_id) + SELECT $1, id FROM roles WHERE name = $2`, + [groupId, roleName], + ); + return groupId; +} + +function connectSocket(cookie) { + return new Promise((resolve, reject) => { + const events = { created: [], changed: [] }; + const socket = ioClient(API_BASE, { + path: "/api/socket.io", + transports: ["websocket"], + forceNew: true, + reconnection: false, + extraHeaders: { Cookie: cookie }, + }); + socket.on("notification_created", (payload) => { + events.created.push(payload); + }); + socket.on("executive_meeting_notifications_changed", (payload) => { + events.changed.push(payload); + }); + socket.on("connect", () => resolve({ socket, events })); + socket.on("connect_error", (err) => reject(err)); + }); +} + +function clearSocketEvents(s) { + s.events.created.length = 0; + s.events.changed.length = 0; +} + +function waitMs(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// Broadcasts fire via `void broadcast...()` after the HTTP response, so +// poll briefly until the captured events satisfy the predicate. +async function waitFor(predicate, { timeoutMs = 1500, intervalMs = 25 } = {}) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (predicate()) return; + await waitMs(intervalMs); + } + if (!predicate()) { + throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`); + } +} + +async function snapshotMaxIds() { + const en = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM executive_meeting_notifications`, + ); + const n = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM notifications`, + ); + return { emnMax: Number(en.rows[0].m), nMax: Number(n.rows[0].m) }; +} + +async function newRowsAfter(snap) { + const emn = await pool.query( + `SELECT id, + meeting_id AS "meetingId", + user_id AS "userId", + notification_type AS "notificationType", + status, + sent_at AS "sentAt" + FROM executive_meeting_notifications + WHERE id > $1 + ORDER BY id`, + [snap.emnMax], + ); + const n = await pool.query( + `SELECT id, + user_id AS "userId", + title_en AS "titleEn", + title_ar AS "titleAr", + type, + related_type AS "relatedType", + related_id AS "relatedId" + FROM notifications + WHERE id > $1 + ORDER BY id`, + [snap.nMax], + ); + return { emn: emn.rows, notifications: n.rows }; +} + +function rowsForUser(rows, userId) { + return rows.filter((r) => Number(r.userId) === Number(userId)); +} + +// Scope a snapshot diff to a single trigger so parallel test files +// writing notifications for the seeded admin actor can't trip our checks. +function scopeDiff(diff, { notificationType, meetingId, relatedType, relatedId }) { + const emn = diff.emn.filter((r) => { + if (notificationType && r.notificationType !== notificationType) return false; + if (meetingId !== undefined && meetingId !== null) { + if (Number(r.meetingId) !== Number(meetingId)) return false; + } + return true; + }); + const notifications = diff.notifications.filter((r) => { + if (relatedType && r.relatedType !== relatedType) return false; + if (relatedId !== undefined && relatedId !== null) { + if (Number(r.relatedId) !== Number(relatedId)) return false; + } + return true; + }); + return { emn, notifications }; +} + +let adminCookie = null; +let adminUserId = null; + +let approver1 = null; +let approver2 = null; +let coord1 = null; +let coord2 = null; + +let approver1Sock = null; +let approver2Sock = null; +let coord1Sock = null; +let coord2Sock = 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; + + approver1 = await makeUser("notif_appr1"); + await grantRoleDirect(approver1.id, "user"); + await grantRoleDirect(approver1.id, "executive_office_manager"); + approver1.cookie = await login(approver1.username, TEST_PASSWORD); + + approver2 = await makeUser("notif_appr2"); + await grantRoleDirect(approver2.id, "user"); + await grantRoleDirect(approver2.id, "executive_office_manager"); + // approver2 also has the role via a group, so getUserIdsForRoleNames + // sees them on both the direct and group-derived paths — dedupe must + // collapse this to a single row. + await grantRoleViaGroup( + approver2.id, + "executive_office_manager", + uniqueName("notif_grp"), + ); + approver2.cookie = await login(approver2.username, TEST_PASSWORD); + + coord1 = await makeUser("notif_coord1"); + await grantRoleDirect(coord1.id, "user"); + await grantRoleDirect(coord1.id, "executive_coordinator"); + coord1.cookie = await login(coord1.username, TEST_PASSWORD); + + coord2 = await makeUser("notif_coord2"); + await grantRoleDirect(coord2.id, "user"); + await grantRoleDirect(coord2.id, "executive_coordinator"); + coord2.cookie = await login(coord2.username, TEST_PASSWORD); + + approver1Sock = await connectSocket(approver1.cookie); + approver2Sock = await connectSocket(approver2.cookie); + coord1Sock = await connectSocket(coord1.cookie); + coord2Sock = await connectSocket(coord2.cookie); +}); + +after(async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + try { + s?.socket?.disconnect(); + } catch { + /* ignore */ + } + } + 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_meeting_notifications WHERE meeting_id = ANY($1::int[])`, + [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 executive_meeting_notifications WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); + await pool.query( + `DELETE FROM notifications WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); + 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 user_groups WHERE user_id = ANY($1::int[])`, [ + created.userIds, + ]); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + created.userIds, + ]); + } + if (created.groupIds.length > 0) { + await pool.query( + `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, + [created.groupIds], + ); + await pool.query( + `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, + [created.groupIds], + ); + await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ + created.groupIds, + ]); + } + await pool.end(); +}); + +const today = new Date().toISOString().slice(0, 10); + +function assertRecipientGotOneOfEach(diff, userId, expected) { + const emnRows = rowsForUser(diff.emn, userId); + assert.equal( + emnRows.length, + 1, + `user ${userId} must have exactly 1 executive_meeting_notifications row, got ${emnRows.length}`, + ); + const emn = emnRows[0]; + assert.equal(emn.notificationType, expected.notificationType); + assert.equal(emn.status, "sent"); + assert.ok(emn.sentAt, "sent_at must be populated"); + if (expected.meetingId !== undefined) { + assert.equal(emn.meetingId, expected.meetingId); + } + + const nRows = rowsForUser(diff.notifications, userId); + assert.equal( + nRows.length, + 1, + `user ${userId} must have exactly 1 notifications row, got ${nRows.length}`, + ); + const n = nRows[0]; + assert.equal(n.type, "executive_meeting"); + if (expected.relatedType !== undefined) { + assert.equal(n.relatedType, expected.relatedType); + } + if (expected.relatedId !== undefined) { + assert.equal(Number(n.relatedId), Number(expected.relatedId)); + } +} + +function assertActorExcluded(diff, actorId) { + assert.equal( + rowsForUser(diff.emn, actorId).length, + 0, + `actor ${actorId} must NOT receive an executive_meeting_notifications row`, + ); + assert.equal( + rowsForUser(diff.notifications, actorId).length, + 0, + `actor ${actorId} must NOT receive a notifications row`, + ); +} + +async function expectSocketEventsFor( + userSocks, + notificationType, + meetingId, + { expectExactlyOne = false } = {}, +) { + await waitFor( + () => + userSocks.every((s) => + s.events.created.some((p) => p.notificationType === notificationType), + ) && + userSocks.some((s) => + s.events.changed.some( + (p) => p.notificationType === notificationType, + ), + ), + { timeoutMs: 2000 }, + ); + // Give a brief grace window so any duplicate emission would also land + // before we count, otherwise the strict count check could pass by + // racing the second event. + if (expectExactlyOne) await waitMs(150); + for (const s of userSocks) { + const matches = s.events.created.filter( + (p) => p.notificationType === notificationType, + ); + assert.ok( + matches.length >= 1, + `socket should have received notification_created for ${notificationType}`, + ); + if (expectExactlyOne) { + assert.equal( + matches.length, + 1, + `socket should have received exactly 1 notification_created for ${notificationType}, got ${matches.length}`, + ); + } + const got = matches[0]; + assert.equal(got.type, "executive_meeting"); + if (meetingId !== undefined) { + assert.equal(got.meetingId, meetingId); + } + } + const anyChanged = userSocks + .flatMap((s) => s.events.changed) + .find((p) => p.notificationType === notificationType); + assert.ok( + anyChanged, + `executive_meeting_notifications_changed must fire for ${notificationType}`, + ); + if (meetingId !== undefined) { + assert.equal(anyChanged.meetingId, meetingId); + } +} + +function expectNoSocketEventFor(sock, notificationType) { + const offending = sock.events.created.find( + (p) => p.notificationType === notificationType, + ); + assert.equal( + offending, + undefined, + `socket should NOT have received notification_created for ${notificationType}`, + ); +} + +test("meeting_created: fan-out excludes actor, dedupes direct+group, writes both tables, emits sockets", async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع إخطارات", + titleEn: "Notif fan-out meeting", + meetingDate: today, + attendees: [], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + await waitFor( + () => + approver1Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ) && + approver2Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + + assertRecipientGotOneOfEach(diff, approver1.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertRecipientGotOneOfEach(diff, approver2.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertActorExcluded(diff, adminUserId); + + await expectSocketEventsFor( + [approver1Sock, approver2Sock], + "meeting_created", + meeting.id, + { expectExactlyOne: true }, + ); + expectNoSocketEventFor(coord1Sock, "meeting_created"); + expectNoSocketEventFor(coord2Sock, "meeting_created"); +}); + +test("request_submitted: notifies approvers (deduped + actor excluded), writes both tables, emits sockets", async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const create = await api(coord1.cookie, "POST", "/api/executive-meetings/requests", { + requestType: "note", + requestDetails: { note: "fan-out test" }, + }); + assert.equal(create.status, 201); + const request = await create.json(); + created.requestIds.push(request.id); + + await waitFor( + () => + approver1Sock.events.created.some( + (p) => p.notificationType === "request_submitted", + ) && + approver2Sock.events.created.some( + (p) => p.notificationType === "request_submitted", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "request_submitted", + relatedType: "executive_meeting_request", + relatedId: request.id, + }); + assertRecipientGotOneOfEach(diff, approver1.id, { + notificationType: "request_submitted", + relatedType: "executive_meeting_request", + relatedId: request.id, + }); + assertRecipientGotOneOfEach(diff, approver2.id, { + notificationType: "request_submitted", + relatedType: "executive_meeting_request", + relatedId: request.id, + }); + assertActorExcluded(diff, coord1.id); + + await expectSocketEventsFor( + [approver1Sock, approver2Sock], + "request_submitted", + null, + { expectExactlyOne: true }, + ); + expectNoSocketEventFor(coord1Sock, "request_submitted"); +}); + +// Each review variant needs its own request still in 'new' status — the +// route refuses to re-review. Submit as coord1, review as admin. +async function runReviewTest(reviewStatus) { + const submit = await api(coord1.cookie, "POST", "/api/executive-meetings/requests", { + requestType: "note", + requestDetails: { note: `review-test-${reviewStatus}` }, + }); + assert.equal(submit.status, 201); + const request = await submit.json(); + created.requestIds.push(request.id); + + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const review = await api( + adminCookie, + "PATCH", + `/api/executive-meetings/requests/${request.id}`, + { status: reviewStatus, reviewNotes: "fan-out test" }, + ); + assert.equal(review.status, 200, `review ${reviewStatus} should succeed`); + + const expectedType = `request_${reviewStatus}`; + await waitFor( + () => + coord1Sock.events.created.some( + (p) => p.notificationType === expectedType, + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: expectedType, + relatedType: "executive_meeting_request", + relatedId: request.id, + }); + assertRecipientGotOneOfEach(diff, coord1.id, { + notificationType: expectedType, + relatedType: "executive_meeting_request", + relatedId: request.id, + }); + assertActorExcluded(diff, adminUserId); + assert.equal(rowsForUser(diff.emn, approver1.id).length, 0); + assert.equal(rowsForUser(diff.emn, approver2.id).length, 0); + + await expectSocketEventsFor([coord1Sock], expectedType, null); +} + +test("request_approved: notifies the requester only, writes both tables, emits sockets", async () => { + await runReviewTest("approved"); +}); + +test("request_rejected: notifies the requester only, writes both tables, emits sockets", async () => { + await runReviewTest("rejected"); +}); + +test("request_needs_edit: notifies the requester only, writes both tables, emits sockets", async () => { + await runReviewTest("needs_edit"); +}); + +test("task_assigned: notifies the assignee, excludes actor, writes both tables, emits sockets", async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { + taskType: "follow_up", + assignedTo: coord2.id, + notes: "fan-out task", + }); + assert.equal(create.status, 201); + const task = await create.json(); + created.taskIds.push(task.id); + + await waitFor( + () => + coord2Sock.events.created.some( + (p) => p.notificationType === "task_assigned", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "task_assigned", + relatedType: "executive_meeting_task", + relatedId: task.id, + }); + assertRecipientGotOneOfEach(diff, coord2.id, { + notificationType: "task_assigned", + relatedType: "executive_meeting_task", + relatedId: task.id, + }); + assertActorExcluded(diff, adminUserId); + assert.equal(rowsForUser(diff.emn, coord1.id).length, 0); + + await expectSocketEventsFor([coord2Sock], "task_assigned", null); + expectNoSocketEventFor(coord1Sock, "task_assigned"); +}); + +test("task_completed: notifies the prior assignee, excludes actor, writes both tables, emits sockets", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { + taskType: "follow_up", + assignedTo: coord2.id, + notes: "for completion fan-out", + }); + assert.equal(create.status, 201); + const task = await create.json(); + created.taskIds.push(task.id); + + // Drain the task_assigned events from setup before snapshotting. + await waitFor( + () => + coord2Sock.events.created.some( + (p) => p.notificationType === "task_assigned", + ), + { timeoutMs: 2000 }, + ); + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const complete = await api( + adminCookie, + "PATCH", + `/api/executive-meetings/tasks/${task.id}`, + { status: "completed" }, + ); + assert.equal(complete.status, 200); + + await waitFor( + () => + coord2Sock.events.created.some( + (p) => p.notificationType === "task_completed", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "task_completed", + relatedType: "executive_meeting_task", + relatedId: task.id, + }); + assertRecipientGotOneOfEach(diff, coord2.id, { + notificationType: "task_completed", + relatedType: "executive_meeting_task", + relatedId: task.id, + }); + assertActorExcluded(diff, adminUserId); + assert.equal(rowsForUser(diff.emn, coord1.id).length, 0); + assert.equal(rowsForUser(diff.emn, approver1.id).length, 0); + + await expectSocketEventsFor([coord2Sock], "task_completed", null); +});