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 }); // #262: requestIds + taskIds dropped with their tables. const created = { userIds: [], groupIds: [], meetingIds: [], }; 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 */ } } // #262: executive_meeting_tasks + executive_meeting_requests cleanup // removed alongside the tables themselves. if (created.meetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_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) { // #262: executive_meeting_tasks + executive_meeting_requests cleanup // removed alongside the tables themselves. 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"); }); // #262: removed test block previously at L522-622 (Requests/Tasks event type). // #262: removed test block previously at L623-626 (Requests/Tasks event type). // #262: removed test block previously at L627-630 (Requests/Tasks event type). // #262: removed test block previously at L631-634 (Requests/Tasks event type). // #262: removed test block previously at L635-674 (Requests/Tasks event type). // #262: removed test block previously at L675-754 (Requests/Tasks event type). // #238: opt-out coverage for filterRecipientsByNotificationPref. The HTTP // surface gives us black-box access — set a pref row, trigger the event, // assert delivery (or non-delivery) at the EMN + notifications + socket // layer. Each test owns its own pref row(s) and cleans them up so the // suite stays order-independent. async function setPref(userId, notificationType, { inApp, email }) { await pool.query( `INSERT INTO executive_meeting_notification_prefs (user_id, notification_type, in_app, email) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, notification_type) DO UPDATE SET in_app = EXCLUDED.in_app, email = EXCLUDED.email`, [userId, notificationType, inApp, email], ); } async function clearPref(userId, notificationType) { await pool.query( `DELETE FROM executive_meeting_notification_prefs WHERE user_id = $1 AND notification_type = $2`, [userId, notificationType], ); } test("pref opt-out: inApp=false drops the user from in-app fan-out (others unaffected)", async () => { await setPref(approver1.id, "meeting_created", { inApp: false, email: true }); try { for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); const before = await snapshotMaxIds(); const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "اختبار تعطيل", titleEn: "Opt-out fan-out", meetingDate: today, attendees: [], }); assert.equal(create.status, 201); const meeting = await create.json(); created.meetingIds.push(meeting.id); // Wait for approver2 (default-on) so we know fan-out completed before // checking that approver1 was dropped. await waitFor( () => 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, }); assert.equal( rowsForUser(diff.emn, approver1.id).length, 0, "opted-out user must NOT get an executive_meeting_notifications row", ); assert.equal( rowsForUser(diff.notifications, approver1.id).length, 0, "opted-out user must NOT get a notifications row", ); assertRecipientGotOneOfEach(diff, approver2.id, { notificationType: "meeting_created", meetingId: meeting.id, relatedType: "executive_meeting", relatedId: meeting.id, }); expectNoSocketEventFor(approver1Sock, "meeting_created"); } finally { await clearPref(approver1.id, "meeting_created"); } }); test("pref opt-out: missing pref row defaults to ON (user receives delivery)", async () => { // No setPref call — approver1 has no row at all for this event type. // This is the implicit default-on case. Assert it explicitly so a // future regression (e.g. flipping the default to off) is caught. await pool.query( `DELETE FROM executive_meeting_notification_prefs WHERE user_id = $1 AND notification_type = $2`, [approver1.id, "meeting_created"], ); for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); const before = await snapshotMaxIds(); const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "افتراضي مفعل", titleEn: "Default-on fan-out", 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", ), { 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, }); }); // #262: removed test block previously at L855-900 (Requests/Tasks event type). test("pref opt-out: email=false does NOT affect the in-app channel", async () => { // Channel independence — muting email must leave in-app delivery alone. await setPref(approver1.id, "meeting_created", { inApp: true, email: false }); try { for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); const before = await snapshotMaxIds(); const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "قناة منفصلة", titleEn: "Channel independence", 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", ), { 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, }); } finally { await clearPref(approver1.id, "meeting_created"); } });