// #283: Concurrent-postpone race test. // When two users postpone the same meeting at roughly the same moment, // the second writer must NOT silently stack their N minutes on top of // the first writer's already-shifted time. Instead, the server should // return 409 stale_meeting with a `conflict` payload identifying who // won the race so the UI can surface a confirmation prompt. // // We exercise three scenarios: // 1. A → 200 (first wins). // 2. B with the *original* updatedAt → 409 stale_meeting + conflict // payload that names A as the lastActor. // 3. B with the *fresh* updatedAt (re-fetched after losing the race) // → 200; the meeting now reflects A's + B's shifts in order, and // we can also confirm the "force apply anyway" path by sending // no expectedUpdatedAt at all. 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 () => { // executive_coord_lead carries the mutate role required by the // postpone/reschedule endpoints — see MUTATE_ROLES in the route. userA = await createUser("em_race_a", "executive_coord_lead", "Alice"); cookieA = await login(userA.username, TEST_PASSWORD); userB = await createUser("em_race_b", "executive_coord_lead", "Bob"); 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(); }); const today = new Date().toISOString().slice(0, 10); async function createMeeting() { // Create through the API so updatedAt and updatedBy are set the same // way real meetings are. Use a fixed afternoon window so we don't // race the alert loop or the noTimeWindow guard. const res = await api(cookieA, "POST", "/api/executive-meetings", { titleAr: "اجتماع التزامن", titleEn: "Concurrency meeting", meetingDate: today, startTime: "14:00", endTime: "14:30", status: "scheduled", }); assert.ok( res.status === 200 || res.status === 201, `meeting create should succeed (got ${res.status})`, ); const body = await res.json(); const meeting = body.meeting ?? body; created.meetingIds.push(meeting.id); return meeting; } async function fetchMeeting(cookie, id) { // /api/executive-meetings/days serves the same shape the alert UI // sees, but the simpler GET-by-id endpoint is enough here. const res = await api(cookie, "GET", `/api/executive-meetings/${id}`); assert.equal(res.status, 200, "meeting fetch should succeed"); return res.json(); } test("postpone-minutes: second concurrent writer hits 409 stale_meeting", async () => { const meeting = await createMeeting(); // Both clients capture the same updatedAt — this is the race window. const initial = await fetchMeeting(cookieA, meeting.id); const staleUpdatedAt = initial.updatedAt; assert.ok(staleUpdatedAt, "meeting payload must include updatedAt"); // A wins. const aRes = await api( cookieA, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: staleUpdatedAt }, ); assert.equal(aRes.status, 200, "first postpone should succeed"); const aBody = await aRes.json(); assert.equal(aBody.meeting.startTime.slice(0, 5), "14:05"); // B loses — sends the now-stale token. const bRes = await api( cookieB, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: staleUpdatedAt }, ); assert.equal(bRes.status, 409, "second postpone should be rejected as stale"); const bBody = await bRes.json(); assert.equal(bBody.code, "stale_meeting"); assert.ok(bBody.conflict, "stale response must include conflict payload"); assert.equal(bBody.conflict.currentStartTime?.slice(0, 5), "14:05"); assert.ok(bBody.conflict.lastActor, "conflict must name the last actor"); assert.equal(bBody.conflict.lastActor.username, userA.username); assert.equal(bBody.conflict.lastActor.displayNameEn, "Alice"); // Confirm the start time did NOT silently shift again. const afterB = await fetchMeeting(cookieA, meeting.id); assert.equal( afterB.startTime.slice(0, 5), "14:05", "B's stale call must not have stacked another shift", ); }); test("postpone-minutes: 'apply anyway' uses freshly-seen token from conflict", async () => { const meeting = await createMeeting(); // A wins. const aRes = await api( cookieA, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 10, expectedUpdatedAt: meeting.updatedAt }, ); assert.equal(aRes.status, 200); const aBody = await aRes.json(); assert.equal(aBody.meeting.startTime.slice(0, 5), "14:10"); // B sees a 409 with conflict.lastModifiedAt naming the new state… const conflictRes = await api( cookieB, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: meeting.updatedAt }, ); assert.equal(conflictRes.status, 409); const conflictBody = await conflictRes.json(); const freshToken = conflictBody.conflict.lastModifiedAt; assert.ok(freshToken, "conflict must include lastModifiedAt for retry"); // …and "apply anyway" mirrors the client by resubmitting with that // freshly-seen token. A third concurrent mutation between conflict // display and click would return 409 again — proving no silent stack. const retryRes = await api( cookieB, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: freshToken }, ); assert.equal(retryRes.status, 200, "apply-anyway with fresh token succeeds"); const retryBody = await retryRes.json(); assert.equal( retryBody.meeting.startTime.slice(0, 5), "14:15", "apply-anyway shifts on top of A's already-shifted state (10 + 5)", ); }); test("postpone-minutes: missing expectedUpdatedAt is a 400 (required by design)", async () => { const meeting = await createMeeting(); const res = await api( cookieA, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5 }, ); assert.equal( res.status, 400, "the optimistic-locking token is mandatory — no opt-out path", ); }); test("postpone-minutes: B refetches and retries cleanly with fresh updatedAt", async () => { const meeting = await createMeeting(); // A shifts by 5. await api( cookieA, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: meeting.updatedAt }, ); // B refetches → gets the new updatedAt → retries → 200. const refreshed = await fetchMeeting(cookieB, meeting.id); const retryRes = await api( cookieB, "POST", `/api/executive-meetings/${meeting.id}/postpone-minutes`, { minutes: 5, expectedUpdatedAt: refreshed.updatedAt }, ); assert.equal(retryRes.status, 200); const retryBody = await retryRes.json(); assert.equal(retryBody.meeting.startTime.slice(0, 5), "14:10"); });