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

Replit-Task-Id: c696223a-209b-4111-9921-e0540e1e16a5
This commit is contained in:
riyadhafraa
2026-04-30 13:43:56 +00:00
parent ff7bd5a4bd
commit 8d998bf8e8
3 changed files with 837 additions and 0 deletions
@@ -0,0 +1,404 @@
// 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);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,433 @@
// E2E + UI coverage for the cell-merge feature added by task #152.
//
// Two scenarios live here:
//
// 1. Two-tab realtime merge propagation. Two browser contexts open
// the same day's schedule. Tab A applies a merge via its own
// MergeMenu; tab B should pick up the new merge (rendered as a
// single colSpan cell) without a manual refresh, driven by the
// `executive_meetings_changed` Socket.IO event.
//
// 2. UI fallbacks for the merge trigger / Unmerge action under
// column-customizer churn:
// a. Hiding the leading "#" column doesn't strip the row-actions
// kebab — the kebab re-anchors to the next visible cell so an
// editor can still merge from a row whose # column is off.
// b. After a column reorder makes a *stored* merge cover
// non-contiguous visible columns (so we degrade to the
// unmerged layout to avoid colSpan-ing the wrong cells), the
// kebab still surfaces a working Unmerge action.
//
// All seeded meetings are inserted directly via pg on a far-future
// date so the tests don't collide with real schedule data, and an
// afterAll DELETE removes everything we created.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings merge UI tests",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
// Wipe the schedule's persisted UI prefs so each test starts from a
// known baseline (view mode off, default columns, default highlight
// color, no per-row tints, etc.). Must be called AFTER login because
// the edit-mode storage key is namespaced by userId.
async function resetSchedulePrefs(page) {
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (
k.startsWith("em-schedule-edit-mode-v1") ||
k === "em-schedule-cols-v1" ||
k === "em-schedule-row-colors-v1" ||
k === "em-current-meeting-highlight-v1"
) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
}
async function setLangEn(page) {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* localStorage may not be ready before navigation */
}
});
}
async function ensureEditModeOn(page) {
const toggle = page.getByTestId("em-edit-mode-toggle");
await expect(toggle).toBeVisible();
if ((await toggle.getAttribute("aria-pressed")) === "false") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-pressed", "true");
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
mergeStartColumn = null,
mergeEndColumn = null,
mergeText = null,
}) {
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time,
status, merge_start_column, merge_end_column, merge_text)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled', $7, $8, $9)
RETURNING id`,
[
dailyNumber,
titleAr,
titleEn,
meetingDate,
startTime,
endTime,
mergeStartColumn,
mergeEndColumn,
mergeText,
],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
// Pick a meeting date well past any seeded data and jitter the base
// per-process so concurrent reruns don't trip the daily_number unique
// index.
const RANDOM_DATE_BASE_DAYS =
365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("Schedule: a merge applied in one tab propagates to a second tab via realtime without reload", async ({
browser,
}) => {
// Two isolated browser contexts so each tab gets its own session
// cookie + Socket.IO connection. Sharing a context would multiplex
// the same socket for both tabs and defeat the purpose.
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const tabA = await ctxA.newPage();
const tabB = await ctxB.newPage();
const date = uniqueFutureDate(5);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Realtime Merge ${uniq}`,
titleAr: `دمج حي ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
try {
await setLangEn(tabA);
await setLangEn(tabB);
await loginViaUi(tabA, "admin", "admin123");
await loginViaUi(tabB, "admin", "admin123");
await tabA.goto("/executive-meetings");
await tabB.goto("/executive-meetings");
await resetSchedulePrefs(tabA);
await resetSchedulePrefs(tabB);
await tabA.reload();
await tabB.reload();
// Both tabs jump to the seeded date.
await tabA.locator('input[type="date"]').first().fill(date);
await tabB.locator('input[type="date"]').first().fill(date);
const rowA = tabA.getByTestId(`em-row-${meetingId}`);
const rowB = tabB.getByTestId(`em-row-${meetingId}`);
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowB).toBeVisible({ timeout: 10_000 });
// Neither tab shows a merged cell yet.
await expect(
tabA.getByTestId(`em-merge-cell-${meetingId}`),
).toHaveCount(0);
await expect(
tabB.getByTestId(`em-merge-cell-${meetingId}`),
).toHaveCount(0);
// Apply a merge in tab A through the row-actions kebab → Merge
// submenu → "Merge meeting + attendees + time" option.
await ensureEditModeOn(tabA);
const kebabA = rowA.locator('[data-testid^="em-row-actions-"]').first();
await kebabA.click({ force: true });
await tabA.getByTestId(`em-merge-trigger-${meetingId}`).click();
// Wait for the PATCH to land before relying on the socket fan-out.
const savePromise = tabA.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await tabA.getByTestId(`em-merge-opt-meeting-time-${meetingId}`).click();
await savePromise;
// Tab A renders the merged cell immediately (optimistic update).
await expect(
tabA.getByTestId(`em-merge-cell-${meetingId}`),
).toBeVisible({ timeout: 10_000 });
// Tab B should pick up the new merge via the realtime
// `executive_meetings_changed` event invalidating its cached
// query for the same date — no manual reload here.
await expect(
tabB.getByTestId(`em-merge-cell-${meetingId}`),
).toBeVisible({ timeout: 15_000 });
} finally {
await ctxA.close();
await ctxB.close();
}
});
test("Schedule: hiding the # column still exposes the merge trigger on an unmerged row", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(7);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `NumHidden ${uniq}`,
titleAr: `بدون رقم ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
// Hide the # column directly via the persisted prefs so we don't
// depend on the customize popover's exact UX for this regression.
// Order is preserved (number stays at index 0), only `visible` flips.
await page.evaluate(() => {
const cols = [
{ id: "number", visible: false, width: 56 },
{ id: "meeting", visible: true, width: 360 },
{ id: "attendees", visible: true, width: 280 },
{ id: "time", visible: true, width: 200 },
];
window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
});
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// Sanity: the # column header must actually be gone — otherwise the
// test isn't exercising the regression.
await expect(page.getByTestId("em-col-header-number")).toHaveCount(0);
// Edit mode is required for the row-actions kebab to render at all.
await ensureEditModeOn(page);
// The kebab anchors to the FIRST VISIBLE non-merged cell, so it
// should still be present on the row even though the # cell is
// hidden. Open it and confirm the Merge submenu is reachable.
const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
await expect(kebab).toHaveCount(1);
await kebab.click({ force: true });
await expect(
page.getByTestId(`em-merge-trigger-${meetingId}`),
).toBeVisible();
// Drilling into the Merge submenu surfaces the three "Merge ..."
// options just like on a row whose # column is visible.
await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
await expect(
page.getByTestId(`em-merge-opt-meeting-attendees-${meetingId}`),
).toBeVisible();
await expect(
page.getByTestId(`em-merge-opt-meeting-time-${meetingId}`),
).toBeVisible();
await expect(
page.getByTestId(`em-merge-opt-all-${meetingId}`),
).toBeVisible();
});
test("Schedule: a stored merge that becomes non-contiguous after a column reorder still exposes Unmerge", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(9);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
// Seed a row that already has a stored merge spanning meeting →
// attendees (canonical indices 1 → 2). Once we reorder the visible
// columns to [number, meeting, time, attendees], "meeting" sits at
// visible index 1 and "attendees" at visible index 3 — that gap of
// one (the "time" column dropped between them) is exactly the
// non-contiguous case that forces the schedule to fall back to
// rendering the cells unmerged.
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `NonContig ${uniq}`,
titleAr: `غير متلاصق ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
mergeStartColumn: "meeting",
mergeEndColumn: "attendees",
mergeText: "<p>spanning meeting + attendees</p>",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
// Reorder the schedule columns so "time" sits between meeting and
// attendees, breaking the contiguity of the stored merge.
await page.evaluate(() => {
const cols = [
{ id: "number", visible: true, width: 56 },
{ id: "meeting", visible: true, width: 360 },
{ id: "time", visible: true, width: 200 },
{ id: "attendees", visible: true, width: 280 },
];
window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
});
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// Confirm the column header order matches what we wrote — order
// matters for the contiguity calculation, so a misread here would
// give a misleading green.
const headerIds = await page
.locator('[data-testid^="em-col-header-"]')
.evaluateAll((els) =>
els
.map((el) => el.getAttribute("data-testid"))
.filter((id) => id && id.startsWith("em-col-header-"))
.map((id) => id.replace("em-col-header-", "")),
);
expect(headerIds).toEqual(["number", "meeting", "time", "attendees"]);
// Because the stored merge no longer maps to contiguous visible
// indices, the schedule should NOT render a single colSpan cell —
// the original cells stay intact.
await expect(
page.getByTestId(`em-merge-cell-${meetingId}`),
).toHaveCount(0);
// Edit mode is required for the kebab to mount.
await ensureEditModeOn(page);
// The kebab is still anchored to the first visible cell of the
// unmerged-rendering row, and opening Merge → … must surface the
// Unmerge option (because hasStoredMerge is true even though the
// current visible layout can't render the merge).
const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
await expect(kebab).toHaveCount(1);
await kebab.click({ force: true });
await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
const unmerge = page.getByTestId(`em-merge-opt-unmerge-${meetingId}`);
await expect(unmerge).toBeVisible();
// Clicking Unmerge actually clears the stored merge — confirm the
// PATCH lands and the DB row is wiped clean. Without this assertion
// a regression that stops sending `merge: null` (e.g. wires the
// button up but forgets to invoke onChangeMerge(null)) would slip.
const clearPromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await unmerge.click();
await clearPromise;
const { rows } = await pool.query(
`SELECT merge_start_column, merge_end_column, merge_text
FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(rows[0].merge_start_column).toBeNull();
expect(rows[0].merge_end_column).toBeNull();
expect(rows[0].merge_text).toBeNull();
});