Files
TX/artifacts/api-server/tests/executive-meetings-rotate-content.test.mjs
T
Riyadh 48aee459c8 Task #489: row-wide drag rotates meeting content; time + daily numbers anchored
Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
  ONLY meeting content through fixed (start_time, end_time, daily_number)
  slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
  incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
  incomplete_day. Existing /swap-times tests still pass.

Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
  retired). useSortable is gated on canMutate; safeRowDragListeners
  filters drags whose target is an interactive descendant (button, input,
  edit/time cells, row-actions, bulk-select). useSortable `attributes`
  are spread only when canMutate so view-mode rows stay clickable
  (otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
  each chronological slot's tuple to the new occupant; rolls back + toast
  on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
  buttons removed).

Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
  position by # cell, asserts rotate-content fires and slots stay
  anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
  cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
  drag tests rewritten to drag the row body and target /rotate-content
  (the legacy /reorder route + tests are intentionally untouched).

Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
  FOR UPDATE locks orderedIds but not the day-scope completeness query.
  Out of #489 scope; no follow-up created (proposeFollowUpTasks was
  already consumed on #486).
2026-05-11 12:14:08 +00:00

442 lines
14 KiB
JavaScript

// #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");
});