// #486: Backend test for POST /executive-meetings/swap-times. // // The schedule row quick-actions popover (Move up / Move down) calls // this endpoint to swap just the (startTime, endTime) tuple between // the clicked meeting and its chronological neighbour. The Time // column stays visually anchored — each row keeps its slot and the // meetings rotate through the slots — and daily numbers are // recomputed by start time. // // Cases: // 1. Happy path: A and B on same date with valid updatedAt tokens // → 200, times swapped, dailyNumber re-sorted, both audit rows // written. // 2. Stale token on either row → 409 stale_meeting + conflict // payload that names the offending row + last actor. // 3. Different dates → 400 different_dates. // 4. Missing time window → 400 no_time_window. 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; before(async () => { userA = await createUser("em_swap_a", "executive_coord_lead", "Alice Swap"); cookieA = await login(userA.username, TEST_PASSWORD); userB = await createUser("em_swap_b", "executive_coord_lead", "Bob Swap"); cookieB = await login(userB.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(); }); // Use distinct far-future dates per test so each scenario gets a // clean day — avoids any interference with seeded recurring meetings, // other suites running in the same DB, and the `executive_meetings_ // date_number_unique` constraint that nextDailyNumber races against. function futureDate(offsetDays) { return new Date(Date.now() + offsetDays * 86_400_000) .toISOString() .slice(0, 10); } const DATE_HAPPY = futureDate(30); const DATE_STALE = futureDate(31); const DATE_DIFF_A = futureDate(32); const DATE_DIFF_B = futureDate(33); const DATE_NO_WINDOW = futureDate(34); 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("swap-times: happy path swaps (startTime, endTime) and re-sorts daily numbers", async () => { const a = await createMeeting({ date: DATE_HAPPY, titleEn: "Swap A", startTime: "09:00", endTime: "09:30", }); const b = await createMeeting({ date: DATE_HAPPY, titleEn: "Swap B", startTime: "10:00", endTime: "10:30", }); const aFresh = await fetchMeeting(cookieA, a.id); const bFresh = await fetchMeeting(cookieA, b.id); const beforeNumA = aFresh.dailyNumber; const beforeNumB = bFresh.dailyNumber; assert.notEqual( beforeNumA, beforeNumB, "two meetings on the same day should have distinct daily numbers", ); const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", { aId: a.id, bId: b.id, expectedUpdatedAtA: aFresh.updatedAt, expectedUpdatedAtB: bFresh.updatedAt, }); assert.equal(res.status, 200, "swap should succeed"); const body = await res.json(); assert.equal(body.ok, true); assert.equal(body.meetings.length, 2); const aAfter = await fetchMeeting(cookieA, a.id); const bAfter = await fetchMeeting(cookieA, b.id); // Times swapped — A now holds B's old window and vice versa. assert.equal(aAfter.startTime.slice(0, 5), "10:00"); assert.equal(aAfter.endTime.slice(0, 5), "10:30"); assert.equal(bAfter.startTime.slice(0, 5), "09:00"); assert.equal(bAfter.endTime.slice(0, 5), "09:30"); // Daily numbers re-sorted by start time, so A and B exchanged // positions in the `#` column too. assert.equal(aAfter.dailyNumber, beforeNumB); assert.equal(bAfter.dailyNumber, beforeNumA); // Both rows audited. const { rows } = await pool.query( `SELECT entity_id FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND action = 'meeting_swap_times' AND entity_id = ANY($1::int[])`, [[a.id, b.id]], ); const auditedIds = rows.map((r) => r.entity_id).sort(); assert.deepEqual(auditedIds, [a.id, b.id].sort()); }); test("swap-times: stale updatedAt returns 409 stale_meeting with conflict payload", async () => { const a = await createMeeting({ date: DATE_STALE, titleEn: "Stale A", startTime: "11:00", endTime: "11:30", }); const b = await createMeeting({ date: DATE_STALE, titleEn: "Stale B", startTime: "12:00", endTime: "12:30", }); const aFresh = await fetchMeeting(cookieA, a.id); const bFresh = await fetchMeeting(cookieA, b.id); // Bob mutates B in between A's read and A's swap, invalidating the // updatedAt token Alice captured. const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${b.id}`, { titleEn: "Stale B (renamed)", }); assert.ok( patch.status === 200 || patch.status === 204, `patch should succeed (got ${patch.status})`, ); const swap = await api( cookieA, "POST", "/api/executive-meetings/swap-times", { aId: a.id, bId: b.id, expectedUpdatedAtA: aFresh.updatedAt, expectedUpdatedAtB: bFresh.updatedAt, }, ); assert.equal(swap.status, 409); const body = await swap.json(); assert.equal(body.code, "stale_meeting"); assert.ok(body.conflict, "stale response must include conflict payload"); assert.equal(body.conflict.id, b.id); assert.ok(body.conflict.lastActor, "conflict must name an actor"); assert.equal(body.conflict.lastActor.id, userB.id); }); test("swap-times: different dates → 400 different_dates", async () => { const a = await createMeeting({ date: DATE_DIFF_A, titleEn: "Date A", startTime: "13:00", endTime: "13:30", }); const b = await createMeeting({ date: DATE_DIFF_B, titleEn: "Date B", startTime: "13:00", endTime: "13:30", }); const aFresh = await fetchMeeting(cookieA, a.id); const bFresh = await fetchMeeting(cookieA, b.id); const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", { aId: a.id, bId: b.id, expectedUpdatedAtA: aFresh.updatedAt, expectedUpdatedAtB: bFresh.updatedAt, }); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "different_dates"); }); test("swap-times: meeting without a time window → 400 no_time_window", async () => { // Create A normally, then null out B's start/end directly so we can // exercise the guard without going through PATCH (which validates). const a = await createMeeting({ date: DATE_NO_WINDOW, titleEn: "Window A", startTime: "15:00", endTime: "15:30", }); const b = await createMeeting({ date: DATE_NO_WINDOW, titleEn: "Window B", startTime: "16:00", endTime: "16:30", }); await pool.query( `UPDATE executive_meetings SET start_time = NULL, end_time = NULL WHERE id = $1`, [b.id], ); const aFresh = await fetchMeeting(cookieA, a.id); const bFresh = await fetchMeeting(cookieA, b.id); const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", { aId: a.id, bId: b.id, expectedUpdatedAtA: aFresh.updatedAt, expectedUpdatedAtB: bFresh.updatedAt, }); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "no_time_window"); });