// #489: Backend test for POST /executive-meetings/rotate-content. // // The schedule lets a `canMutate` user drag any meeting from anywhere // on its row to a new position. The (startTime, endTime, dailyNumber) // of each chronological slot stays anchored to its physical row; only // the meeting CONTENT (title, attendees, notes, color, status, merge) // rotates through the slots. This endpoint receives the new content // order plus a per-meeting `expectedUpdatedAt` token map for // optimistic-locking and writes (startTime, endTime) back so that // position i in chronological order ends up holding the i-th slot. // // Cases: // 1. Happy path: 3-row rotation moves row1's content to position 3 // → 200, time/dailyNumber columns unchanged in DB, but the row // that started at position 1 now holds position 3's tuple. // 2. Stale token on any row → 409 stale_meeting + conflict payload. // 3. orderedIds spanning two dates → 400 different_dates. // 4. Unauthenticated → 401. // 5. executive_viewer (no mutate role) → 403. // 6. Missing a visible row from orderedIds → 400 incomplete_day. 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: [] }; function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } async function createUser(prefix, roleName, displayNameEn) { 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, displayNameEn], ); 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 userA = null; let cookieA = null; let userB = null; let cookieB = null; let viewerCookie = null; before(async () => { userA = await createUser("em_rot_a", "executive_coord_lead", "Alice Rotate"); cookieA = await login(userA.username, TEST_PASSWORD); userB = await createUser("em_rot_b", "executive_coord_lead", "Bob Rotate"); cookieB = await login(userB.username, TEST_PASSWORD); const viewer = await createUser( "em_rot_view", "executive_viewer", "Vee Rotate", ); viewerCookie = await login(viewer.username, TEST_PASSWORD); }); after(async () => { 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_meetings WHERE id = ANY($1::int[])`, [created.meetingIds], ); } if (created.userIds.length > 0) { 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(); }); function futureDate(offsetDays) { return new Date(Date.now() + offsetDays * 86_400_000) .toISOString() .slice(0, 10); } const DATE_HAPPY = futureDate(60); const DATE_STALE = futureDate(61); const DATE_DIFF_A = futureDate(62); const DATE_DIFF_B = futureDate(63); const DATE_INCOMPLETE = futureDate(64); const DATE_VIEWER = futureDate(65); async function createMeeting({ date, titleEn, startTime, endTime }) { const res = await api(cookieA, "POST", "/api/executive-meetings", { titleAr: titleEn, titleEn, meetingDate: date, startTime, endTime, status: "scheduled", }); if (res.status !== 200 && res.status !== 201) { const body = await res.text(); assert.fail( `meeting create should succeed (got ${res.status}): ${body.slice(0, 400)}`, ); } const body = await res.json(); const meeting = body.meeting ?? body; created.meetingIds.push(meeting.id); return meeting; } async function fetchMeeting(cookie, id) { const res = await api(cookie, "GET", `/api/executive-meetings/${id}`); assert.equal(res.status, 200, "meeting fetch should succeed"); return res.json(); } test("rotate-content: 3-row rotation keeps time + daily numbers anchored, rotates titles", async () => { // Build three meetings in chronological order: r1 @ 09:00, r2 @ 10:00, // r3 @ 11:00. Then drag r1 to position 3 → new content order is // [r2, r3, r1]. Slot[1]=09:00 stays on the row that now holds r2's // content; slot[3]=11:00 stays on the row that now holds r1. const r1 = await createMeeting({ date: DATE_HAPPY, titleEn: "Rot R1 Alpha", startTime: "09:00", endTime: "09:30", }); const r2 = await createMeeting({ date: DATE_HAPPY, titleEn: "Rot R2 Bravo", startTime: "10:00", endTime: "10:30", }); const r3 = await createMeeting({ date: DATE_HAPPY, titleEn: "Rot R3 Charlie", startTime: "11:00", endTime: "11:30", }); const r1Fresh = await fetchMeeting(cookieA, r1.id); const r2Fresh = await fetchMeeting(cookieA, r2.id); const r3Fresh = await fetchMeeting(cookieA, r3.id); // Snapshot the per-row daily numbers for the post-rotation assertion // — they must NOT change for the rows whose content moved. const beforeNumR1 = r1Fresh.dailyNumber; const beforeNumR2 = r2Fresh.dailyNumber; const beforeNumR3 = r3Fresh.dailyNumber; const res = await api( cookieA, "POST", "/api/executive-meetings/rotate-content", { meetingDate: DATE_HAPPY, orderedIds: [r2.id, r3.id, r1.id], expectedUpdatedAt: { [r1.id]: r1Fresh.updatedAt, [r2.id]: r2Fresh.updatedAt, [r3.id]: r3Fresh.updatedAt, }, }, ); assert.equal(res.status, 200, "rotate should succeed"); const body = await res.json(); assert.equal(body.ok, true); assert.equal(body.meetings.length, 3); const r1After = await fetchMeeting(cookieA, r1.id); const r2After = await fetchMeeting(cookieA, r2.id); const r3After = await fetchMeeting(cookieA, r3.id); // r1 (originally 09:00) was moved to position 3 → it now holds the // 11:00 slot. r2 (originally 10:00) moved to position 1 → 09:00. // r3 (originally 11:00) moved to position 2 → 10:00. assert.equal(r1After.startTime.slice(0, 5), "11:00"); assert.equal(r1After.endTime.slice(0, 5), "11:30"); assert.equal(r2After.startTime.slice(0, 5), "09:00"); assert.equal(r2After.endTime.slice(0, 5), "09:30"); assert.equal(r3After.startTime.slice(0, 5), "10:00"); assert.equal(r3After.endTime.slice(0, 5), "10:30"); // Daily numbers — renumbered by start time, so the meeting that ends // up earliest gets dailyNumber=beforeNumR1 (the smallest of the three). // Specifically r2 should now hold beforeNumR1, r3→beforeNumR2, // r1→beforeNumR3. assert.equal(r2After.dailyNumber, beforeNumR1); assert.equal(r3After.dailyNumber, beforeNumR2); assert.equal(r1After.dailyNumber, beforeNumR3); // Titles stayed glued to their rows — the user dragged content, so // each row carries away its own meeting body (title, attendees, …) // to the new physical position. assert.equal(r1After.titleEn, "Rot R1 Alpha"); assert.equal(r2After.titleEn, "Rot R2 Bravo"); assert.equal(r3After.titleEn, "Rot R3 Charlie"); // Audit rows written for the rows whose times actually changed. const { rows } = await pool.query( `SELECT entity_id FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND action = 'meeting_rotate_content' AND entity_id = ANY($1::int[])`, [[r1.id, r2.id, r3.id]], ); assert.ok(rows.length >= 2, "expected audit rows for rotated meetings"); }); test("rotate-content: stale updatedAt → 409 stale_meeting with conflict payload", async () => { const r1 = await createMeeting({ date: DATE_STALE, titleEn: "Stale R1", startTime: "09:00", endTime: "09:30", }); const r2 = await createMeeting({ date: DATE_STALE, titleEn: "Stale R2", startTime: "10:00", endTime: "10:30", }); const r1Fresh = await fetchMeeting(cookieA, r1.id); const r2Fresh = await fetchMeeting(cookieA, r2.id); // Bob mutates r2 in between Alice's read and Alice's rotate. const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${r2.id}`, { titleEn: "Stale R2 (renamed)", }); assert.ok( patch.status === 200 || patch.status === 204, `patch should succeed (got ${patch.status})`, ); const res = await api( cookieA, "POST", "/api/executive-meetings/rotate-content", { meetingDate: DATE_STALE, orderedIds: [r2.id, r1.id], expectedUpdatedAt: { [r1.id]: r1Fresh.updatedAt, [r2.id]: r2Fresh.updatedAt, }, }, ); assert.equal(res.status, 409); const body = await res.json(); assert.equal(body.code, "stale_meeting"); assert.ok(body.conflict, "stale response must include conflict payload"); assert.equal(body.conflict.id, r2.id); assert.ok(body.conflict.lastActor, "conflict must name an actor"); assert.equal(body.conflict.lastActor.id, userB.id); }); test("rotate-content: orderedIds spanning two dates → 400 different_dates", async () => { const r1 = await createMeeting({ date: DATE_DIFF_A, titleEn: "Diff A", startTime: "13:00", endTime: "13:30", }); const r2 = await createMeeting({ date: DATE_DIFF_B, titleEn: "Diff B", startTime: "13:00", endTime: "13:30", }); const r1Fresh = await fetchMeeting(cookieA, r1.id); const r2Fresh = await fetchMeeting(cookieA, r2.id); const res = await api( cookieA, "POST", "/api/executive-meetings/rotate-content", { meetingDate: DATE_DIFF_A, orderedIds: [r1.id, r2.id], expectedUpdatedAt: { [r1.id]: r1Fresh.updatedAt, [r2.id]: r2Fresh.updatedAt, }, }, ); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "different_dates"); }); test("rotate-content: unauthenticated → 401", async () => { const res = await fetch(`${API_BASE}/api/executive-meetings/rotate-content`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ meetingDate: futureDate(70), orderedIds: [1, 2], expectedUpdatedAt: { 1: new Date().toISOString(), 2: new Date().toISOString(), }, }), }); assert.equal(res.status, 401); }); test("rotate-content: viewer (no mutate role) → 403 forbidden", async () => { const r1 = await createMeeting({ date: DATE_VIEWER, titleEn: "View A", startTime: "08:00", endTime: "08:30", }); const r2 = await createMeeting({ date: DATE_VIEWER, titleEn: "View B", startTime: "09:00", endTime: "09:30", }); const r1Fresh = await fetchMeeting(cookieA, r1.id); const r2Fresh = await fetchMeeting(cookieA, r2.id); const res = await api( viewerCookie, "POST", "/api/executive-meetings/rotate-content", { meetingDate: DATE_VIEWER, orderedIds: [r2.id, r1.id], expectedUpdatedAt: { [r1.id]: r1Fresh.updatedAt, [r2.id]: r2Fresh.updatedAt, }, }, ); assert.equal(res.status, 403, "viewer must not be able to rotate"); }); test("rotate-content: omitting a visible row from orderedIds → 400 incomplete_day", async () => { // Three visible rows on the day, but the client only sends two of // them — the server must refuse rather than silently leaving the // omitted row's tuple stale. const r1 = await createMeeting({ date: DATE_INCOMPLETE, titleEn: "Inc R1", startTime: "09:00", endTime: "09:30", }); const r2 = await createMeeting({ date: DATE_INCOMPLETE, titleEn: "Inc R2", startTime: "10:00", endTime: "10:30", }); const r3 = await createMeeting({ date: DATE_INCOMPLETE, titleEn: "Inc R3", startTime: "11:00", endTime: "11:30", }); const r1Fresh = await fetchMeeting(cookieA, r1.id); const r2Fresh = await fetchMeeting(cookieA, r2.id); const res = await api( cookieA, "POST", "/api/executive-meetings/rotate-content", { meetingDate: DATE_INCOMPLETE, // r3 missing on purpose. orderedIds: [r2.id, r1.id], expectedUpdatedAt: { [r1.id]: r1Fresh.updatedAt, [r2.id]: r2Fresh.updatedAt, }, }, ); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "incomplete_day"); // Sanity: r3 unchanged. const r3After = await fetchMeeting(cookieA, r3.id); assert.equal(r3After.startTime.slice(0, 5), "11:00"); });