16a818b716
Clicking any meeting row on the Executive Meetings schedule (gated only on canMutate, not editMode) opens a small popover with Move up / Move down / Postpone. Move up/down swap only the (startTime, endTime) tuple between the clicked meeting and its chronological neighbour on the same date — the Time column stays visually anchored to its row position. Backend - POST /executive-meetings/swap-times: transactional swap with FOR UPDATE row locking, optimistic-lock conflict shape (stale_meeting + conflict payload), date/time-window guards, audit logging, and renumberDayByStartTime + day-changed broadcast. - Zod schema in lib/api-zod/src/manual.ts. Frontend - Shared lib/api-json.ts JSON helper. - ScheduleSection.swapTimes does an optimistic (startTime, endTime) swap against the day query cache and rolls back on failure (mirrors the existing inline-edit UX). - MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* / em-row-grip / em-row-actions data-testid prefixes do NOT open the popover. - PostponeDialog reused from upcoming-meeting-alert.tsx. Tests - Backend swap-times: happy path, stale_meeting (409), different_dates (400), no_time_window (400), unauth (401), viewer-no-mutate (403), malformed-timestamp (400) — all 7 pass. - Hardened expectedUpdatedAt zod schema to z.string().datetime() so malformed tokens fail at validation with a controlled 400 instead of bubbling up as a 500. - E2E: Move up swap, edge-disable states (solo / first / middle / last), Postpone 5-min chip end-to-end, click-exclusion on grip / time cell / row-actions — all 4 pass. Each test uses its own future date to avoid cross-test pollution. Code review approved on second pass. Pre-existing failures in other suites (executive-meetings reorder, font-settings, notes-share, service-orders) are unrelated to this task and predate it. Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit- mode test gaps).
401 lines
13 KiB
JavaScript
401 lines
13 KiB
JavaScript
// #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;
|
|
let viewerCookie = 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);
|
|
// executive_viewer is the read-only role — passes
|
|
// requireExecutiveAccess but is NOT in MUTATE_ROLES, so it lets us
|
|
// assert that swap-times rejects non-mutators with 403.
|
|
const viewer = await createUser("em_swap_view", "executive_viewer", "Vee Viewer");
|
|
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();
|
|
});
|
|
|
|
// 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: unauthenticated request → 401", async () => {
|
|
const res = await fetch(`${API_BASE}/api/executive-meetings/swap-times`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
aId: 1,
|
|
bId: 2,
|
|
expectedUpdatedAtA: new Date().toISOString(),
|
|
expectedUpdatedAtB: new Date().toISOString(),
|
|
}),
|
|
});
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
test("swap-times: viewer (no mutate role) → 403 forbidden", async () => {
|
|
// Create the pair as a mutator first, then try to swap as a viewer.
|
|
const a = await createMeeting({
|
|
date: futureDate(35),
|
|
titleEn: "Viewer A",
|
|
startTime: "08:00",
|
|
endTime: "08:30",
|
|
});
|
|
const b = await createMeeting({
|
|
date: futureDate(35),
|
|
titleEn: "Viewer B",
|
|
startTime: "09:00",
|
|
endTime: "09:30",
|
|
});
|
|
const aFresh = await fetchMeeting(cookieA, a.id);
|
|
const bFresh = await fetchMeeting(cookieA, b.id);
|
|
const res = await api(
|
|
viewerCookie,
|
|
"POST",
|
|
"/api/executive-meetings/swap-times",
|
|
{
|
|
aId: a.id,
|
|
bId: b.id,
|
|
expectedUpdatedAtA: aFresh.updatedAt,
|
|
expectedUpdatedAtB: bFresh.updatedAt,
|
|
},
|
|
);
|
|
assert.equal(res.status, 403, "viewer must not be able to swap times");
|
|
});
|
|
|
|
test("swap-times: malformed expectedUpdatedAt → 400 from zod, not 500", async () => {
|
|
// The route passes expectedUpdatedAt straight to new Date(...).toISOString().
|
|
// Without a strict zod guard a non-ISO string would throw and surface as a
|
|
// 500. Lock the controlled-failure contract here.
|
|
const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
|
|
aId: 1,
|
|
bId: 2,
|
|
expectedUpdatedAtA: "not-a-real-timestamp",
|
|
expectedUpdatedAtB: new Date().toISOString(),
|
|
});
|
|
assert.equal(res.status, 400, "malformed timestamp must be rejected at validation");
|
|
});
|
|
|
|
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");
|
|
});
|