Files
TX/artifacts/api-server/tests/executive-meetings-merge.test.mjs
T
Riyadh a822fb1b4a Test the merge & current-meeting tint feature end-to-end (task #154)
Adds API + UI/e2e coverage for the cell-merge overlay introduced by
task #152 on the executive-meetings schedule. Two new test files,
no production code changes.

API tests (artifacts/api-server/tests/executive-meetings-merge.test.mjs):
- Setting a merge writes mergeStartColumn/mergeEndColumn/mergeText and
  the row round-trips back through GET ?date=.
- Clearing with `merge: null` nulls all three columns while leaving the
  rest of the row intact (combined with a titleEn change in the same
  PATCH to prove only the merge fields move).
- Invalid range start>end is rejected with 400 and the row is unchanged
  (also covers an unknown enum value).
- mergeText is sanitized: <script>, onerror, javascript:, and <img>
  are stripped; visible text, <strong>, and the allowed inline color
  style (rgb(220,38,38)) survive — locks the contract that Tiptap-style
  formatting on the merged label is preserved.
- A non-mutate user (executive_viewer role) gets 403 on PATCH merge
  and a follow-up DB SELECT confirms no fields changed.

UI / e2e tests (artifacts/tx-os/tests/executive-meetings-merge.spec.mjs):
- Two-tab realtime: separate browser contexts both open the same day;
  applying a merge in tab A makes the merged cell appear in tab B
  without a manual reload (relies on the existing
  `executive_meetings_changed` Socket.IO emit + client invalidator).
- Hidden # column: with `em-schedule-cols-v1` localStorage flagging
  number as invisible, the row-actions kebab still mounts on the next
  visible cell and the Merge submenu is reachable.
- Non-contiguous reorder: a row with a stored merge spanning
  meeting+attendees is loaded after reordering columns to
  [number, meeting, time, attendees]. The merged cell is NOT rendered
  (correct fallback) but the kebab still surfaces the Unmerge action,
  and clicking it clears all three merge columns in the DB.

Both files clean up their own meeting rows + audit log entries +
seeded users in afterAll/after. All 5 API tests + 3 Playwright tests
pass against the running dev workflows.

Follow-ups proposed:
- #217 Show merged cells in the meeting PDF & archive views
- #218 Test the merge popover in Arabic / RTL
2026-04-30 13:43:56 +00:00

405 lines
12 KiB
JavaScript

// API contract tests for the cell-merge overlay added by task #152
// to the executive-meetings PATCH endpoint. Covers:
//
// 1. Setting a merge writes mergeStartColumn / mergeEndColumn /
// mergeText to the row and round-trips back through GET.
// 2. Clearing the merge with `merge: null` nulls all three columns
// while leaving the rest of the row (title, attendees, etc.)
// intact.
// 3. Invalid ranges (start column comes after end column) are
// rejected with 400 and don't mutate the row.
// 4. mergeText is sanitized just like titleAr/titleEn — script tags,
// event handlers, and javascript: hrefs are stripped while the
// visible text and an allowed inline color survive.
// 5. A non-mutate user (executive_viewer role) receives 403 when
// attempting to PATCH the merge fields, and the row is unchanged.
//
// Cleanup runs in `after` regardless of which assertions failed so
// repeated runs of this file against the same DB don't leak rows.
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 = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
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) {
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, 'EM Merge Test', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
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 adminCookie = null;
let viewerCookie = null;
let viewerUserId = null;
const today = new Date().toISOString().slice(0, 10);
// Create a single meeting we can mutate across the tests below. Each
// test scopes its assertions to a fresh row id so test ordering doesn't
// leak state between assertions.
async function createMeeting(titleEn, titleAr) {
const res = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr,
titleEn,
meetingDate: today,
startTime: "09:00",
endTime: "10:00",
platform: "none",
status: "scheduled",
isHighlighted: 0,
attendees: [
{ name: "Tester", attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(res.status, 201, "create meeting should succeed");
const meeting = await res.json();
created.meetingIds.push(meeting.id);
return meeting;
}
before(async () => {
adminCookie = await login("admin", "admin123");
// executive_viewer is in READ_ROLES but NOT in MUTATE_ROLES, so
// requireExecutiveAccess passes and we hit requireMutate's 403.
// This isolates the "non-editor" case from the bare "no executive
// access at all" case (which would 403 earlier in the chain).
const viewer = await createUser("em_merge_viewer", "executive_viewer");
viewerUserId = viewer.id;
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_type = 'meeting' AND entity_id = ANY($1::int[])`,
[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 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();
});
test("PATCH merge: setting a merge persists all three columns and round-trips via GET", async () => {
const meeting = await createMeeting(
"Merge set",
"تعيين الدمج",
);
// Sanity: a fresh row has no merge.
assert.equal(meeting.mergeStartColumn, null);
assert.equal(meeting.mergeEndColumn, null);
assert.equal(meeting.mergeText, null);
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "meeting",
mergeEndColumn: "time",
mergeText: "<p>Combined block</p>",
},
},
);
assert.equal(patch.status, 200, "PATCH merge should succeed");
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
assert.equal(day.status, 200);
const body = await day.json();
const row = body.meetings.find((m) => m.id === meeting.id);
assert.ok(row, "patched meeting should appear in GET ?date=");
assert.equal(row.mergeStartColumn, "meeting");
assert.equal(row.mergeEndColumn, "time");
assert.match(
row.mergeText ?? "",
/Combined block/,
"visible text from the merge label survives sanitization",
);
});
test("PATCH merge: { merge: null } clears all three merge columns without touching the rest of the row", async () => {
const meeting = await createMeeting("Merge clear", "مسح الدمج");
// Seed with a merge so we have something to clear.
const setRes = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "number",
mergeEndColumn: "attendees",
mergeText: "to be cleared",
},
},
);
assert.equal(setRes.status, 200);
// Now clear via merge: null in the same patch as a benign title
// change. The title change must persist and the merge must vanish.
const clearRes = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
titleEn: "Merge cleared",
merge: null,
},
);
assert.equal(clearRes.status, 200, "clearing merge should succeed");
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
const body = await day.json();
const row = body.meetings.find((m) => m.id === meeting.id);
assert.ok(row);
assert.equal(row.mergeStartColumn, null, "start column cleared");
assert.equal(row.mergeEndColumn, null, "end column cleared");
assert.equal(row.mergeText, null, "merge text cleared");
// Title change in the same PATCH was applied.
assert.match(row.titleEn ?? "", /Merge cleared/);
// Untouched fields stay intact.
assert.equal(row.startTime, "09:00:00");
assert.equal(row.endTime, "10:00:00");
});
test("PATCH merge: invalid range (start > end) is rejected with 400 and the row is unchanged", async () => {
const meeting = await createMeeting(
"Merge invalid",
"مدى غير صالح",
);
// The schedule columns are ordered: number, meeting, attendees, time.
// start=time, end=meeting reverses the order — must fail validation.
const bad = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "time",
mergeEndColumn: "meeting",
mergeText: "should not be saved",
},
},
);
assert.equal(bad.status, 400, "reversed range must be rejected");
// The row must not have been mutated by the rejected request.
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
const body = await day.json();
const row = body.meetings.find((m) => m.id === meeting.id);
assert.ok(row);
assert.equal(row.mergeStartColumn, null);
assert.equal(row.mergeEndColumn, null);
assert.equal(row.mergeText, null);
// A column id outside the enum is also rejected.
const badEnum = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "meeting",
mergeEndColumn: "notes",
mergeText: "x",
},
},
);
assert.equal(badEnum.status, 400, "unknown column id must be rejected");
});
test("PATCH merge: mergeText is sanitized — script tags and JS handlers are stripped, visible text survives", async () => {
const meeting = await createMeeting(
"Merge sanitize",
"تنقية الدمج",
);
const dirty =
'<p style="color: rgb(220, 38, 38)">Visible <strong>bold</strong></p>' +
'<script>alert(1)</script>' +
'<a href="javascript:hack()" onerror="hack()">link</a>' +
'<img src=x onerror="hack()" />';
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "meeting",
mergeEndColumn: "attendees",
mergeText: dirty,
},
},
);
assert.equal(patch.status, 200);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
const body = await day.json();
const row = body.meetings.find((m) => m.id === meeting.id);
const text = row.mergeText ?? "";
// Visible content survives.
assert.match(text, /Visible/, "visible text preserved");
assert.match(text, /<(strong|b)[\s>]/i, "<strong> bold preserved");
// The allowed inline color style on <p> must survive the sanitizer
// — Tiptap-style formatting (color, bold, etc.) is intentionally
// preserved so the merged label can carry the editor's formatting.
// Different sanitizers normalize whitespace/quotes inside the style
// attribute slightly differently, so the assertion just checks for
// the rgb triple in any spacing.
assert.match(
text.toLowerCase(),
/rgb\(\s*220\s*,\s*38\s*,\s*38\s*\)/,
"allowed inline color style survives sanitization",
);
// Dangerous markup gone.
assert.ok(!/<script/i.test(text), "<script> stripped");
assert.ok(!/onerror/i.test(text), "onerror handler stripped");
assert.ok(!/javascript:/i.test(text), "javascript: scheme stripped");
assert.ok(!/<img/i.test(text), "<img> stripped");
});
test("PATCH merge: a non-editor (executive_viewer) gets 403 and the row is not mutated", async () => {
const meeting = await createMeeting(
"Merge forbidden",
"ممنوع",
);
const forbidden = await api(
viewerCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
merge: {
mergeStartColumn: "meeting",
mergeEndColumn: "time",
mergeText: "viewer attempt",
},
},
);
assert.equal(
forbidden.status,
403,
"viewer without mutate role must be rejected",
);
// Confirm via DB that nothing changed — defense in depth against
// a future regression where the handler updates the row before the
// 403 short-circuits.
const { rows } = await pool.query(
`SELECT merge_start_column, merge_end_column, merge_text
FROM executive_meetings WHERE id = $1`,
[meeting.id],
);
assert.equal(rows.length, 1);
assert.equal(rows[0].merge_start_column, null);
assert.equal(rows[0].merge_end_column, null);
assert.equal(rows[0].merge_text, null);
});