2026-04-30 05:31:34 +00:00
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
|
|
import pg from "pg";
|
|
|
|
|
|
|
|
|
|
|
|
// E2E coverage for the four "interactive schedule" features that
|
|
|
|
|
|
// previously only had backend integration tests:
|
|
|
|
|
|
// 1. Tiptap rich-text title editing (bold + color, persisted as HTML)
|
|
|
|
|
|
// 2. Drag-to-reorder rows (dnd-kit) — daily numbers + start times swap
|
|
|
|
|
|
// 3. Custom highlight color from the customize popover — applied as
|
|
|
|
|
|
// an inset box-shadow ring on the current meeting row
|
|
|
|
|
|
// 4. Non-mutate user (executive_viewer role) sees no grip handle
|
|
|
|
|
|
//
|
|
|
|
|
|
// Each scenario seeds its own meetings via direct DB inserts on a
|
|
|
|
|
|
// far-future date (or today, for the highlight scenario), so we never
|
|
|
|
|
|
// collide with real schedule data and afterAll cleanup leaves the DB
|
|
|
|
|
|
// untouched.
|
|
|
|
|
|
|
|
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
|
|
|
|
if (!DATABASE_URL) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
"DATABASE_URL must be set to run the executive-meetings schedule UI tests",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
|
|
|
|
|
|
|
|
|
|
// IDs we created so the afterAll cleanup can DELETE them.
|
|
|
|
|
|
const createdMeetingIds = [];
|
|
|
|
|
|
// Track temporary executive_viewer role assignments so we can revoke
|
|
|
|
|
|
// them on cleanup (the "ahmed" demo user must end the run with the
|
|
|
|
|
|
// same role set he started with).
|
|
|
|
|
|
const grantedRoleAssignments = []; // [{ userId, roleId }]
|
|
|
|
|
|
|
|
|
|
|
|
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 highlight color, default
|
|
|
|
|
|
// columns, no per-row tints). 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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 11:02:39 +00:00
|
|
|
|
// Insert a single attendee row directly. Used by the attendee-name
|
|
|
|
|
|
// editing scenario so we don't have to drive the manage-dialog flow
|
|
|
|
|
|
// just to seed initial state.
|
|
|
|
|
|
async function insertAttendee({
|
|
|
|
|
|
meetingId,
|
|
|
|
|
|
name,
|
|
|
|
|
|
attendanceType,
|
|
|
|
|
|
sortOrder,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`INSERT INTO executive_meeting_attendees
|
|
|
|
|
|
(meeting_id, name, attendance_type, sort_order)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4)`,
|
|
|
|
|
|
[meetingId, name, attendanceType, sortOrder],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 05:31:34 +00:00
|
|
|
|
// Insert a meeting row directly. Returns the new id.
|
|
|
|
|
|
async function insertMeeting({
|
|
|
|
|
|
meetingDate,
|
|
|
|
|
|
dailyNumber,
|
|
|
|
|
|
titleEn,
|
|
|
|
|
|
titleAr,
|
|
|
|
|
|
startTime,
|
|
|
|
|
|
endTime,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`INSERT INTO executive_meetings
|
|
|
|
|
|
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
|
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
|
[
|
|
|
|
|
|
dailyNumber,
|
|
|
|
|
|
titleAr,
|
|
|
|
|
|
titleEn,
|
|
|
|
|
|
meetingDate,
|
|
|
|
|
|
startTime,
|
|
|
|
|
|
endTime,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
const id = rows[0].id;
|
|
|
|
|
|
createdMeetingIds.push(id);
|
|
|
|
|
|
return id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pick a meeting date that won't collide with any real schedule data
|
|
|
|
|
|
// OR with stale rows leftover from a previously-failed run. We jitter
|
|
|
|
|
|
// the year-out offset by a per-process random base so reruns within
|
|
|
|
|
|
// the same minute land on different days.
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function todayIso() {
|
|
|
|
|
|
return new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HH:MM:SS string for a wall-clock time `minutesOffset` minutes from
|
|
|
|
|
|
// the local "now". Used to seed a meeting whose [startTime, endTime)
|
|
|
|
|
|
// window contains the moment the highlight test runs.
|
|
|
|
|
|
function localTimeWithOffset(minutesOffset) {
|
|
|
|
|
|
const d = new Date();
|
|
|
|
|
|
d.setMinutes(d.getMinutes() + minutesOffset);
|
|
|
|
|
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
|
|
|
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
|
|
|
|
return `${hh}:${mm}:00`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const { userId, roleId } of grantedRoleAssignments) {
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`,
|
|
|
|
|
|
[userId, roleId],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
await pool.end();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Schedule: editing a title with bold + color via the Tiptap toolbar persists after reload", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
const date = uniqueFutureDate(1);
|
|
|
|
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
|
|
|
|
.toString(36)
|
|
|
|
|
|
.slice(2, 6)}`;
|
|
|
|
|
|
const originalTitle = `RichEdit Original ${uniq}`;
|
|
|
|
|
|
const meetingId = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 1,
|
|
|
|
|
|
titleEn: originalTitle,
|
|
|
|
|
|
titleAr: originalTitle,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
// Jump to the seeded date.
|
|
|
|
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
|
|
|
|
await dateInput.fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for the row we just inserted.
|
|
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Flip on edit mode so the EditableCell becomes interactive.
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
|
|
|
|
|
// Open the title cell editor and apply bold + a red color to ALL of
|
|
|
|
|
|
// the existing text, then save via the toolbar's check button. We
|
|
|
|
|
|
// deliberately keep the original text — the assertion is that the
|
|
|
|
|
|
// toolbar persists *formatting*, not that typing also works (typing
|
|
|
|
|
|
// is covered by the existing "edit toggle" tests).
|
|
|
|
|
|
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
|
|
|
|
|
|
await titleCell.click();
|
|
|
|
|
|
|
|
|
|
|
|
const toolbar = page.getByTestId("em-edit-toolbar");
|
|
|
|
|
|
await expect(toolbar).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for the contenteditable inside the editor to be ready, then
|
|
|
|
|
|
// select the entire title so the bold + color marks apply to every
|
|
|
|
|
|
// character. The editor itself opens with the cursor at the end of
|
|
|
|
|
|
// the existing text, so a simple ControlOrMeta+a is enough.
|
|
|
|
|
|
const editorContent = titleCell.locator('[contenteditable="true"]');
|
|
|
|
|
|
await expect(editorContent).toBeVisible();
|
|
|
|
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
|
|
|
|
await page.getByTestId("em-edit-bold").click();
|
|
|
|
|
|
await page.getByTestId("em-edit-color-red").click();
|
|
|
|
|
|
|
|
|
|
|
|
// Watch for the PATCH that saves the title before clicking save.
|
|
|
|
|
|
const savePromise = 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 page.getByTestId("em-edit-save").click();
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
await expect(toolbar).toBeHidden();
|
|
|
|
|
|
|
|
|
|
|
|
// Reload the page to confirm the formatting wasn't just sitting in
|
|
|
|
|
|
// a Tiptap document — it must round-trip via the server.
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
await dateInput.fill(date);
|
|
|
|
|
|
const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
const titleAfterReload = page.getByTestId(`em-edit-title-${meetingId}`);
|
|
|
|
|
|
await expect(titleAfterReload).toContainText(originalTitle);
|
|
|
|
|
|
|
|
|
|
|
|
// Inspect the rendered HTML in the cell. The server-sanitized output
|
|
|
|
|
|
// must keep the bold tag and the inline color span. We accept either
|
|
|
|
|
|
// <strong> (StarterKit's default tag) or <b> (some sanitizers
|
|
|
|
|
|
// collapse to <b>) so the assertion stays robust against the
|
|
|
|
|
|
// server's exact allowlist serialization.
|
|
|
|
|
|
const html = await titleAfterReload.innerHTML();
|
|
|
|
|
|
expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
|
|
|
|
|
|
// The Tiptap "red" swatch is #dc2626. Browsers serialize inline
|
|
|
|
|
|
// styles in either hex or rgb form; accept both.
|
|
|
|
|
|
expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
|
|
|
|
|
|
|
|
|
|
|
// And confirm the DB itself stored the formatted HTML, not the plain
|
|
|
|
|
|
// text — this catches any frontend-only "looks formatted" regression.
|
|
|
|
|
|
// The page may render in either Arabic or English depending on the
|
|
|
|
|
|
// signed-in user's preferredLanguage, so we check whichever column
|
|
|
|
|
|
// the editor wrote to (the EditableCell saves to title_ar when RTL,
|
|
|
|
|
|
// title_en otherwise).
|
|
|
|
|
|
const { rows: dbRows } = await pool.query(
|
|
|
|
|
|
`SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
|
|
|
|
|
|
[meetingId],
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(dbRows.length).toBe(1);
|
|
|
|
|
|
const editedColumn = String(
|
|
|
|
|
|
/<(strong|b)[\s>]/i.test(String(dbRows[0].title_ar))
|
|
|
|
|
|
? dbRows[0].title_ar
|
|
|
|
|
|
: dbRows[0].title_en,
|
|
|
|
|
|
).toLowerCase();
|
|
|
|
|
|
expect(editedColumn).toMatch(/<(strong|b)[\s>]/);
|
|
|
|
|
|
expect(editedColumn).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
const date = uniqueFutureDate(2);
|
|
|
|
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
|
|
|
|
.toString(36)
|
|
|
|
|
|
.slice(2, 6)}`;
|
|
|
|
|
|
const idA = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 1,
|
|
|
|
|
|
titleEn: `Drag A ${uniq}`,
|
|
|
|
|
|
titleAr: `سحب أ ${uniq}`,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
const idB = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 2,
|
|
|
|
|
|
titleEn: `Drag B ${uniq}`,
|
|
|
|
|
|
titleAr: `سحب ب ${uniq}`,
|
|
|
|
|
|
startTime: "11:00:00",
|
|
|
|
|
|
endTime: "12:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
|
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Edit mode must be on for the grip handle to render.
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
// #489: the dedicated GripVertical handle was retired. The whole
|
|
|
|
|
|
// <tr> is now the drag handle, gated by `effectiveCanMutate =
|
|
|
|
|
|
// canMutate && editMode`. We drag from the # cell (the first <td>)
|
|
|
|
|
|
// because it contains only the daily-number <span> — no skip-listed
|
|
|
|
|
|
// descendants (buttons, inputs, em-edit-* / em-time-*) that
|
|
|
|
|
|
// safeRowDragListeners filters out.
|
|
|
|
|
|
const numberCellB = page
|
|
|
|
|
|
.locator(`[data-testid="em-row-${idB}"] td`)
|
|
|
|
|
|
.first();
|
|
|
|
|
|
const fromBox = await numberCellB.boundingBox();
|
|
|
|
|
|
const toBox = await page
|
|
|
|
|
|
.locator(`[data-testid="em-row-${idA}"]`)
|
|
|
|
|
|
.boundingBox();
|
2026-04-30 05:31:34 +00:00
|
|
|
|
expect(fromBox).not.toBeNull();
|
|
|
|
|
|
expect(toBox).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
// dnd-kit's PointerSensor activates only after a 6px move from the
|
|
|
|
|
|
// pointerdown position, so we issue a small "warm-up" move before
|
|
|
|
|
|
// the long move to the target. Using `steps` keeps the synthetic
|
|
|
|
|
|
// pointer events spread across enough frames for dnd-kit to pick
|
|
|
|
|
|
// them up reliably.
|
|
|
|
|
|
const startX = fromBox.x + fromBox.width / 2;
|
|
|
|
|
|
const startY = fromBox.y + fromBox.height / 2;
|
2026-05-11 12:14:08 +00:00
|
|
|
|
const endX = startX;
|
2026-04-30 05:31:34 +00:00
|
|
|
|
// Aim slightly ABOVE the target row's vertical center so dnd-kit
|
|
|
|
|
|
// inserts the dragged row before the target instead of after.
|
|
|
|
|
|
const endY = toBox.y + 4;
|
|
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
// Drag-row reorder now goes through /rotate-content (#489).
|
2026-04-30 05:31:34 +00:00
|
|
|
|
const reorderPromise = page.waitForResponse(
|
|
|
|
|
|
(resp) => {
|
|
|
|
|
|
const u = new URL(resp.url());
|
|
|
|
|
|
return (
|
2026-05-11 12:14:08 +00:00
|
|
|
|
u.pathname === "/api/executive-meetings/rotate-content" &&
|
2026-04-30 05:31:34 +00:00
|
|
|
|
resp.request().method() === "POST" &&
|
|
|
|
|
|
resp.ok()
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 15_000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await page.mouse.move(startX, startY);
|
|
|
|
|
|
await page.mouse.down();
|
|
|
|
|
|
await page.mouse.move(startX, startY + 12, { steps: 5 });
|
|
|
|
|
|
await page.mouse.move(endX, endY, { steps: 15 });
|
|
|
|
|
|
await page.mouse.up();
|
|
|
|
|
|
|
|
|
|
|
|
await reorderPromise;
|
|
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
// rotate-content keeps each chronological slot's (start_time,
|
|
|
|
|
|
// end_time, daily_number) anchored and rotates only the meeting
|
|
|
|
|
|
// CONTENT into the new positions. After moving B above A, B
|
|
|
|
|
|
// inherits slot 1 (09:00–10:00, dn=1) and A inherits slot 2
|
|
|
|
|
|
// (11:00–12:00, dn=2) — same observable end-state as the legacy
|
|
|
|
|
|
// /reorder API for this two-row case.
|
2026-04-30 05:31:34 +00:00
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`SELECT id, daily_number, start_time, end_time
|
|
|
|
|
|
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
|
|
|
|
|
|
[[idA, idB]],
|
|
|
|
|
|
);
|
|
|
|
|
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
|
|
|
|
|
expect(byId[idB].daily_number).toBe(1);
|
|
|
|
|
|
expect(String(byId[idB].start_time)).toBe("09:00:00");
|
|
|
|
|
|
expect(String(byId[idB].end_time)).toBe("10:00:00");
|
|
|
|
|
|
expect(byId[idA].daily_number).toBe(2);
|
|
|
|
|
|
expect(String(byId[idA].start_time)).toBe("11:00:00");
|
|
|
|
|
|
expect(String(byId[idA].end_time)).toBe("12:00:00");
|
|
|
|
|
|
|
|
|
|
|
|
// A reload should reflect the new visual order in the table.
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
|
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
const rowsInOrder = await page
|
|
|
|
|
|
.locator('tr[data-testid^="em-row-"]')
|
|
|
|
|
|
.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
|
|
|
|
|
|
// Drop any rows from other dates (defensive — the schedule is
|
|
|
|
|
|
// date-scoped so there shouldn't be any, but just in case).
|
|
|
|
|
|
const orderedIds = rowsInOrder
|
|
|
|
|
|
.map((tid) => Number(tid?.replace("em-row-", "")))
|
|
|
|
|
|
.filter((n) => n === idA || n === idB);
|
|
|
|
|
|
expect(orderedIds).toEqual([idB, idA]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Schedule: choosing a custom highlight color paints the current meeting's box-shadow ring", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
// The "current meeting" detection requires the row to be on TODAY
|
|
|
|
|
|
// and for now() to fall inside [startTime, endTime). Build a 60-min
|
|
|
|
|
|
// window centered on now so the test is robust to a few seconds of
|
|
|
|
|
|
// drift between page load and the highlight tick.
|
|
|
|
|
|
const date = todayIso();
|
|
|
|
|
|
const startTime = localTimeWithOffset(-30);
|
|
|
|
|
|
const endTime = localTimeWithOffset(+30);
|
|
|
|
|
|
|
|
|
|
|
|
// Use a dailyNumber high enough not to collide with anything seeded
|
|
|
|
|
|
// elsewhere in the test suite for today. The DB has a UNIQUE
|
|
|
|
|
|
// (meeting_date, daily_number) index so we pick something out of
|
|
|
|
|
|
// the way and recover with a single retry on collision.
|
|
|
|
|
|
let meetingId = null;
|
|
|
|
|
|
for (const dailyNumber of [991, 992, 993, 994, 995]) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
meetingId = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber,
|
|
|
|
|
|
titleEn: `Highlight Now ${dailyNumber}`,
|
|
|
|
|
|
titleAr: `إبراز الآن ${dailyNumber}`,
|
|
|
|
|
|
startTime,
|
|
|
|
|
|
endTime,
|
|
|
|
|
|
});
|
|
|
|
|
|
break;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!/duplicate/i.test(String(err.message))) throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(meetingId).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
// Today is the default date, but we set it explicitly to make the
|
|
|
|
|
|
// test robust to any future change of the page's initial state.
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// The seeded row should be flagged as the current meeting via the
|
|
|
|
|
|
// data attribute. Wait for it — the highlight scheduling tick can
|
|
|
|
|
|
// need a frame to land after the data arrives.
|
|
|
|
|
|
await expect(row).toHaveAttribute("data-current-meeting", "true", {
|
|
|
|
|
|
timeout: 5_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Default highlight color is green (#16a34a). The ring is rendered
|
|
|
|
|
|
// as `inset 0 0 0 2px <color>` via inline box-shadow on the <tr>.
|
|
|
|
|
|
const initialShadow = await row.evaluate(
|
|
|
|
|
|
(el) => getComputedStyle(el).boxShadow,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74).
|
|
|
|
|
|
expect(initialShadow).toMatch(/22,\s*163,\s*74/);
|
|
|
|
|
|
|
2026-05-01 08:52:33 +00:00
|
|
|
|
// #265: Highlight controls now live in the unified Settings tab.
|
|
|
|
|
|
// Hop over, flip the swatch, hop back to the schedule.
|
|
|
|
|
|
await page.getByTestId("em-nav-settings").click();
|
2026-04-30 05:31:34 +00:00
|
|
|
|
const highlightToggle = page.getByTestId("em-highlight-toggle");
|
|
|
|
|
|
await expect(highlightToggle).toBeVisible();
|
|
|
|
|
|
// Make sure the highlight is enabled (the default is on, but assert
|
|
|
|
|
|
// it explicitly so the test isn't sensitive to future default flips).
|
|
|
|
|
|
const isOn = await highlightToggle.getAttribute("aria-checked");
|
|
|
|
|
|
if (isOn !== "true") {
|
|
|
|
|
|
await highlightToggle.click();
|
|
|
|
|
|
await expect(highlightToggle).toHaveAttribute("aria-checked", "true");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByTestId("em-highlight-color-#dc2626").click();
|
2026-05-01 08:52:33 +00:00
|
|
|
|
await page.getByTestId("em-nav-schedule").click();
|
2026-04-30 05:31:34 +00:00
|
|
|
|
|
|
|
|
|
|
// The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38).
|
|
|
|
|
|
await expect
|
|
|
|
|
|
.poll(
|
|
|
|
|
|
async () => row.evaluate((el) => getComputedStyle(el).boxShadow),
|
|
|
|
|
|
{ timeout: 5_000 },
|
|
|
|
|
|
)
|
|
|
|
|
|
.toMatch(/220,\s*38,\s*38/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no edit toggle", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
// Seed a meeting so there's something to (try to) drag.
|
|
|
|
|
|
const date = uniqueFutureDate(3);
|
|
|
|
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
|
|
|
|
.toString(36)
|
|
|
|
|
|
.slice(2, 6)}`;
|
|
|
|
|
|
const meetingId = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 1,
|
|
|
|
|
|
titleEn: `Viewer Only ${uniq}`,
|
|
|
|
|
|
titleAr: `مشاهد فقط ${uniq}`,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Grant the demo "ahmed" user the executive_viewer role (read-only).
|
|
|
|
|
|
// We track the assignment so afterAll can revoke it. The role is
|
|
|
|
|
|
// already seeded by scripts/src/seed.ts.
|
|
|
|
|
|
const { rows: ahmedRows } = await pool.query(
|
|
|
|
|
|
`SELECT id FROM users WHERE username = 'ahmed'`,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(ahmedRows.length).toBe(1);
|
|
|
|
|
|
const ahmedId = ahmedRows[0].id;
|
|
|
|
|
|
|
|
|
|
|
|
const { rows: roleRows } = await pool.query(
|
|
|
|
|
|
`SELECT id FROM roles WHERE name = 'executive_viewer'`,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(roleRows.length).toBe(1);
|
|
|
|
|
|
const viewerRoleId = roleRows[0].id;
|
|
|
|
|
|
|
|
|
|
|
|
const { rowCount } = await pool.query(
|
|
|
|
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
|
|
|
|
VALUES ($1, $2)
|
|
|
|
|
|
ON CONFLICT DO NOTHING`,
|
|
|
|
|
|
[ahmedId, viewerRoleId],
|
|
|
|
|
|
);
|
|
|
|
|
|
// Only schedule cleanup if WE were the ones who added the row —
|
|
|
|
|
|
// otherwise we'd revoke a role ahmed had legitimately.
|
|
|
|
|
|
if (rowCount === 1) {
|
|
|
|
|
|
grantedRoleAssignments.push({ userId: ahmedId, roleId: viewerRoleId });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "ahmed", "user123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
// The seeded row must be visible (read access works).
|
|
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// No grip handles anywhere — they only render when the row prop
|
|
|
|
|
|
// canMutate is true, which equals me.canMutate && editMode.
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
page.locator('[data-testid^="em-row-grip-"]'),
|
|
|
|
|
|
).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
|
|
// The view/edit toggle is rendered ONLY for users who have the
|
|
|
|
|
|
// mutate capability, so a viewer should never see it.
|
|
|
|
|
|
await expect(page.getByTestId("em-edit-mode-toggle")).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
|
|
// Sanity: the customize popover is still available (it's purely a
|
|
|
|
|
|
// presentation control and does not require mutate access), but the
|
|
|
|
|
|
// row-color trigger and merge trigger — both edit-mode-only — must
|
|
|
|
|
|
// not be present.
|
|
|
|
|
|
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
page.locator('[data-testid^="em-merge-trigger-"]'),
|
|
|
|
|
|
).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
|
|
// Reorder API must reject a viewer's POST as well — defense in depth.
|
|
|
|
|
|
const reorderResp = await page.request.post(
|
|
|
|
|
|
"/api/executive-meetings/reorder",
|
|
|
|
|
|
{
|
|
|
|
|
|
data: { meetingDate: date, orderedIds: [meetingId] },
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
|
|
|
|
|
|
});
|
2026-05-01 11:02:39 +00:00
|
|
|
|
|
|
|
|
|
|
test("Schedule: editing an attendee name with bold + color via the Tiptap toolbar persists after reload", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
// Seed a meeting with one attendee whose name is plain text. The
|
|
|
|
|
|
// attendee row uses the SAME EditableCell + Tiptap toolbar as titles
|
2026-05-14 06:23:49 +00:00
|
|
|
|
// (parent), so applying bold + a color swatch and saving
|
2026-05-01 11:02:39 +00:00
|
|
|
|
// via the toolbar's check button must round-trip through the PUT
|
|
|
|
|
|
// attendees endpoint and survive a full reload, just like titles do.
|
|
|
|
|
|
const date = uniqueFutureDate(4);
|
|
|
|
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
|
|
|
|
.toString(36)
|
|
|
|
|
|
.slice(2, 6)}`;
|
|
|
|
|
|
const originalName = `RichAttendee Original ${uniq}`;
|
|
|
|
|
|
const meetingId = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 1,
|
|
|
|
|
|
titleEn: `Attendee Host ${uniq}`,
|
|
|
|
|
|
titleAr: `مضيف ${uniq}`,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
await insertAttendee({
|
|
|
|
|
|
meetingId,
|
|
|
|
|
|
name: originalName,
|
|
|
|
|
|
attendanceType: "internal",
|
|
|
|
|
|
sortOrder: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
// Jump to the seeded date and wait for the row.
|
|
|
|
|
|
const dateInput = page.locator('input[type="date"]').first();
|
|
|
|
|
|
await dateInput.fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Flip on edit mode so the attendee EditableCell becomes interactive.
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
|
|
|
|
|
// The testid uses the original index in meeting.attendees (0). Scope
|
|
|
|
|
|
// under the seeded row so the selector stays unambiguous.
|
|
|
|
|
|
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
|
|
|
|
|
|
await expect(attendeeCell).toBeVisible();
|
|
|
|
|
|
await expect(attendeeCell).toContainText(originalName);
|
|
|
|
|
|
|
|
|
|
|
|
// Click to enter edit mode, then select the entire name and apply
|
|
|
|
|
|
// bold + the red color swatch. Same flow as the title test above —
|
|
|
|
|
|
// we keep the original text so the assertion is purely about the
|
|
|
|
|
|
// toolbar persisting *formatting*, not typing.
|
|
|
|
|
|
await attendeeCell.click();
|
|
|
|
|
|
|
|
|
|
|
|
const toolbar = page.getByTestId("em-edit-toolbar");
|
|
|
|
|
|
await expect(toolbar).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
const editorContent = attendeeCell.locator('[contenteditable="true"]');
|
|
|
|
|
|
await expect(editorContent).toBeVisible();
|
|
|
|
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
|
|
|
|
await page.getByTestId("em-edit-bold").click();
|
|
|
|
|
|
await page.getByTestId("em-edit-color-red").click();
|
|
|
|
|
|
|
|
|
|
|
|
// Watch for the PUT that saves the attendees array before clicking
|
|
|
|
|
|
// save. Inline attendee edits go through PUT /attendees, NOT the
|
|
|
|
|
|
// meeting-level PATCH used by titles.
|
|
|
|
|
|
const savePromise = page.waitForResponse(
|
|
|
|
|
|
(resp) => {
|
|
|
|
|
|
const u = new URL(resp.url());
|
|
|
|
|
|
return (
|
|
|
|
|
|
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
|
|
|
|
|
|
resp.request().method() === "PUT" &&
|
|
|
|
|
|
resp.ok()
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 15_000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
await page.getByTestId("em-edit-save").click();
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
await expect(toolbar).toBeHidden();
|
|
|
|
|
|
|
|
|
|
|
|
// Reload to confirm the formatting wasn't just sitting in the local
|
|
|
|
|
|
// Tiptap document — it must round-trip via the server.
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
await dateInput.fill(date);
|
|
|
|
|
|
const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
|
|
|
|
|
|
await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });
|
|
|
|
|
|
|
|
|
|
|
|
const attendeeAfterReload = rowAfterReload.getByTestId(`em-edit-attendee-0`);
|
|
|
|
|
|
await expect(attendeeAfterReload).toContainText(originalName);
|
|
|
|
|
|
|
|
|
|
|
|
// Inspect the rendered HTML in the cell. The server-sanitized output
|
|
|
|
|
|
// must keep the bold tag and the inline color span. Accept either
|
|
|
|
|
|
// <strong> (StarterKit's default tag) or <b> (some sanitizers
|
|
|
|
|
|
// collapse to <b>) so the assertion stays robust against the
|
|
|
|
|
|
// server's exact allowlist serialization.
|
|
|
|
|
|
const html = await attendeeAfterReload.innerHTML();
|
|
|
|
|
|
expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
|
|
|
|
|
|
// The Tiptap "red" swatch is #dc2626. Browsers serialize inline
|
|
|
|
|
|
// styles in either hex or rgb form; accept both.
|
|
|
|
|
|
expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
|
|
|
|
|
|
|
|
|
|
|
// And confirm the DB itself stored the formatted HTML, not the plain
|
|
|
|
|
|
// text — this catches any frontend-only "looks formatted" regression.
|
|
|
|
|
|
// Attendees have a single `name` column (no AR/EN split), so the
|
|
|
|
|
|
// assertion is simpler than the title case.
|
|
|
|
|
|
const { rows: dbRows } = await pool.query(
|
|
|
|
|
|
`SELECT name FROM executive_meeting_attendees
|
|
|
|
|
|
WHERE meeting_id = $1 ORDER BY sort_order`,
|
|
|
|
|
|
[meetingId],
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(dbRows.length).toBe(1);
|
|
|
|
|
|
const storedName = String(dbRows[0].name).toLowerCase();
|
|
|
|
|
|
expect(storedName).toMatch(/<(strong|b)[\s>]/);
|
|
|
|
|
|
expect(storedName).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
|
|
|
|
|
// The original plain text is still readable inside the formatted markup.
|
|
|
|
|
|
expect(storedName).toContain(originalName.toLowerCase());
|
|
|
|
|
|
});
|
2026-05-01 11:18:40 +00:00
|
|
|
|
|
|
|
|
|
|
test("Schedule: dragging the attendees column header above the meeting column reorders headers and persists across reload", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
// Seed a single meeting so the schedule actually renders rows. The
|
|
|
|
|
|
// floating sticky thead (which carries the interactive
|
|
|
|
|
|
// SortableHeader cells) is rendered regardless, but dragging into
|
|
|
|
|
|
// an empty grid is brittle — having a row keeps the layout stable.
|
|
|
|
|
|
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: `ColReorder ${uniq}`,
|
|
|
|
|
|
titleAr: `إعادة ترتيب ${uniq}`,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({
|
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Sanity: the column customizer panel is still surfaced from the
|
|
|
|
|
|
// Settings tab (#265 moved it out of a popover into Settings). We
|
|
|
|
|
|
// hop over to confirm it exists, then bounce back to the schedule
|
|
|
|
|
|
// where the actual header drag happens — column reordering is wired
|
|
|
|
|
|
// to the SortableHeader cells inside the schedule's floating thead,
|
|
|
|
|
|
// not to chips inside the customizer panel.
|
|
|
|
|
|
await page.getByTestId("em-nav-settings").click();
|
|
|
|
|
|
await expect(page.getByTestId("em-customize-columns-panel")).toBeVisible();
|
|
|
|
|
|
await page.getByTestId("em-nav-schedule").click();
|
|
|
|
|
|
|
|
|
|
|
|
// Edit mode must be on for SortableHeader to register dnd-kit
|
|
|
|
|
|
// attributes/listeners (dragEnabled === effectiveCanMutate).
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
|
|
|
|
|
// Read the initial header order. DEFAULT_COLUMNS is
|
|
|
|
|
|
// [number, meeting, attendees, time].
|
|
|
|
|
|
const readHeaderOrder = () =>
|
|
|
|
|
|
page
|
|
|
|
|
|
.locator('th[data-testid^="em-col-header-"]')
|
|
|
|
|
|
.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
|
|
|
|
|
|
|
|
|
|
|
|
expect(await readHeaderOrder()).toEqual([
|
|
|
|
|
|
"em-col-header-number",
|
|
|
|
|
|
"em-col-header-meeting",
|
|
|
|
|
|
"em-col-header-attendees",
|
|
|
|
|
|
"em-col-header-time",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const attendeesHeader = page.getByTestId("em-col-header-attendees");
|
|
|
|
|
|
const meetingHeader = page.getByTestId("em-col-header-meeting");
|
|
|
|
|
|
await expect(attendeesHeader).toBeVisible();
|
|
|
|
|
|
await expect(meetingHeader).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
const fromBox = await attendeesHeader.boundingBox();
|
|
|
|
|
|
const toBox = await meetingHeader.boundingBox();
|
|
|
|
|
|
expect(fromBox).not.toBeNull();
|
|
|
|
|
|
expect(toBox).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
// dnd-kit's PointerSensor activates only after a 6px move from the
|
|
|
|
|
|
// pointerdown position, so we issue a small "warm-up" move before
|
|
|
|
|
|
// the long move to the target. SortableContext uses
|
|
|
|
|
|
// horizontalListSortingStrategy here, so we drop the pointer
|
|
|
|
|
|
// slightly LEFT of the target's horizontal center to insert the
|
|
|
|
|
|
// dragged header BEFORE the target instead of after.
|
|
|
|
|
|
const startX = fromBox.x + fromBox.width / 2;
|
|
|
|
|
|
const startY = fromBox.y + fromBox.height / 2;
|
|
|
|
|
|
const endX = toBox.x + 4;
|
|
|
|
|
|
const endY = toBox.y + toBox.height / 2;
|
|
|
|
|
|
|
|
|
|
|
|
await page.mouse.move(startX, startY);
|
|
|
|
|
|
await page.mouse.down();
|
|
|
|
|
|
await page.mouse.move(startX - 12, startY, { steps: 5 });
|
|
|
|
|
|
await page.mouse.move(endX, endY, { steps: 15 });
|
|
|
|
|
|
await page.mouse.up();
|
|
|
|
|
|
|
|
|
|
|
|
// After the drag, the headers should reflect the new order:
|
|
|
|
|
|
// attendees moves above meeting.
|
|
|
|
|
|
const expectedOrder = [
|
|
|
|
|
|
"em-col-header-number",
|
|
|
|
|
|
"em-col-header-attendees",
|
|
|
|
|
|
"em-col-header-meeting",
|
|
|
|
|
|
"em-col-header-time",
|
|
|
|
|
|
];
|
|
|
|
|
|
await expect.poll(readHeaderOrder, { timeout: 5_000 }).toEqual(expectedOrder);
|
|
|
|
|
|
|
|
|
|
|
|
// The new order must be persisted to localStorage under the key
|
|
|
|
|
|
// documented in the task (em-schedule-cols-v1).
|
|
|
|
|
|
const stored = await page.evaluate(() =>
|
|
|
|
|
|
window.localStorage.getItem("em-schedule-cols-v1"),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(stored).not.toBeNull();
|
|
|
|
|
|
const storedIds = JSON.parse(stored).map((c) => c.id);
|
|
|
|
|
|
expect(storedIds).toEqual(["number", "attendees", "meeting", "time"]);
|
|
|
|
|
|
|
|
|
|
|
|
// Reload to confirm persistence — the order must restore from
|
|
|
|
|
|
// localStorage rather than reverting to DEFAULT_COLUMNS.
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({
|
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(await readHeaderOrder()).toEqual(expectedOrder);
|
|
|
|
|
|
});
|
2026-05-01 12:53:59 +00:00
|
|
|
|
|
|
|
|
|
|
test("Schedule: when the reorder API rejects a row drag, the UI rolls back to the original order and surfaces an error toast", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await setLangEn(page);
|
|
|
|
|
|
|
|
|
|
|
|
// Seed two meetings on a unique future date so we own the order
|
|
|
|
|
|
// entirely. The optimistic UI flow we exercise here:
|
|
|
|
|
|
// 1. Drag row B above row A → setOptimisticOrder([B, A])
|
|
|
|
|
|
// 2. POST /api/executive-meetings/reorder
|
|
|
|
|
|
// 3. Server responds 500 → setOptimisticOrder(null) + error toast
|
|
|
|
|
|
// 4. DOM order returns to [A, B]
|
|
|
|
|
|
// We assert both the rollback and the user-facing error toast.
|
|
|
|
|
|
// Use offset 6 — offsets 1..5 are already claimed by other tests in
|
|
|
|
|
|
// this file. The afterAll cleanup is file-level, so two tests sharing
|
|
|
|
|
|
// a date + daily_number would hit the UNIQUE (meeting_date,
|
|
|
|
|
|
// daily_number) index. Picking a free offset keeps the seed
|
|
|
|
|
|
// collision-proof without needing duplicate-retry logic.
|
|
|
|
|
|
const date = uniqueFutureDate(6);
|
|
|
|
|
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
|
|
|
|
|
.toString(36)
|
|
|
|
|
|
.slice(2, 6)}`;
|
|
|
|
|
|
const idA = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 1,
|
|
|
|
|
|
titleEn: `Rollback A ${uniq}`,
|
|
|
|
|
|
titleAr: `تراجع أ ${uniq}`,
|
|
|
|
|
|
startTime: "09:00:00",
|
|
|
|
|
|
endTime: "10:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
const idB = await insertMeeting({
|
|
|
|
|
|
meetingDate: date,
|
|
|
|
|
|
dailyNumber: 2,
|
|
|
|
|
|
titleEn: `Rollback B ${uniq}`,
|
|
|
|
|
|
titleAr: `تراجع ب ${uniq}`,
|
|
|
|
|
|
startTime: "11:00:00",
|
|
|
|
|
|
endTime: "12:00:00",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
|
|
|
|
await page.goto("/executive-meetings");
|
|
|
|
|
|
await resetSchedulePrefs(page);
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
|
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Capture the DOM order BEFORE we install the route override. This
|
|
|
|
|
|
// is the order we expect to see again after the rollback.
|
|
|
|
|
|
const readDomOrder = async () => {
|
|
|
|
|
|
const rowsInOrder = await page
|
|
|
|
|
|
.locator('tr[data-testid^="em-row-"]')
|
|
|
|
|
|
.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
|
|
|
|
|
|
return rowsInOrder
|
|
|
|
|
|
.map((tid) => Number(tid?.replace("em-row-", "")))
|
|
|
|
|
|
.filter((n) => n === idA || n === idB);
|
|
|
|
|
|
};
|
|
|
|
|
|
expect(await readDomOrder()).toEqual([idA, idB]);
|
|
|
|
|
|
|
|
|
|
|
|
// Edit mode must be on for the grip handle to render.
|
|
|
|
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
// Intercept the rotate-content POST (#489 replaced /reorder for
|
|
|
|
|
|
// drag-driven reorders) and respond with a 500 so the mutation
|
|
|
|
|
|
// throws and triggers the onError rollback branch (re-set query
|
|
|
|
|
|
// cache + destructive toast). We track the hits so the assertion
|
|
|
|
|
|
// below can prove the request really fired (otherwise a missed
|
|
|
|
|
|
// drag would silently pass the rollback check).
|
2026-05-01 12:53:59 +00:00
|
|
|
|
let reorderHits = 0;
|
2026-05-11 12:14:08 +00:00
|
|
|
|
await page.route(
|
|
|
|
|
|
"**/api/executive-meetings/rotate-content",
|
|
|
|
|
|
async (route) => {
|
|
|
|
|
|
reorderHits += 1;
|
|
|
|
|
|
await route.fulfill({
|
|
|
|
|
|
status: 500,
|
|
|
|
|
|
contentType: "application/json",
|
|
|
|
|
|
// apiJson() reads `data.error` for the thrown message, so use
|
|
|
|
|
|
// `error` (not `message`) to make the description deterministic.
|
|
|
|
|
|
body: JSON.stringify({ error: "Simulated reorder failure" }),
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-05-01 12:53:59 +00:00
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
// #489: drag from the # cell of row B (the dedicated grip is gone).
|
|
|
|
|
|
const numberCellB = page
|
|
|
|
|
|
.locator(`[data-testid="em-row-${idB}"] td`)
|
|
|
|
|
|
.first();
|
|
|
|
|
|
const fromBox = await numberCellB.boundingBox();
|
|
|
|
|
|
const toBox = await page
|
|
|
|
|
|
.locator(`[data-testid="em-row-${idA}"]`)
|
|
|
|
|
|
.boundingBox();
|
2026-05-01 12:53:59 +00:00
|
|
|
|
expect(fromBox).not.toBeNull();
|
|
|
|
|
|
expect(toBox).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
// Same drag mechanics as the success-path reorder test above:
|
|
|
|
|
|
// dnd-kit's PointerSensor needs a small warm-up move past its 6px
|
|
|
|
|
|
// activation distance, then a stepped move to the target. Aim
|
|
|
|
|
|
// slightly above the target row's vertical center so dnd-kit
|
|
|
|
|
|
// inserts B before A.
|
|
|
|
|
|
const startX = fromBox.x + fromBox.width / 2;
|
|
|
|
|
|
const startY = fromBox.y + fromBox.height / 2;
|
2026-05-11 12:14:08 +00:00
|
|
|
|
const endX = startX;
|
2026-05-01 12:53:59 +00:00
|
|
|
|
const endY = toBox.y + 4;
|
|
|
|
|
|
|
|
|
|
|
|
const failedReorderPromise = page.waitForResponse(
|
|
|
|
|
|
(resp) => {
|
|
|
|
|
|
const u = new URL(resp.url());
|
|
|
|
|
|
return (
|
2026-05-11 12:14:08 +00:00
|
|
|
|
u.pathname === "/api/executive-meetings/rotate-content" &&
|
2026-05-01 12:53:59 +00:00
|
|
|
|
resp.status() === 500
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 15_000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await page.mouse.move(startX, startY);
|
|
|
|
|
|
await page.mouse.down();
|
|
|
|
|
|
await page.mouse.move(startX, startY + 12, { steps: 5 });
|
|
|
|
|
|
await page.mouse.move(endX, endY, { steps: 15 });
|
|
|
|
|
|
await page.mouse.up();
|
|
|
|
|
|
|
|
|
|
|
|
await failedReorderPromise;
|
|
|
|
|
|
expect(reorderHits).toBeGreaterThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
|
|
// The page renders a destructive toast with title = t("common.error")
|
|
|
|
|
|
// ("An error occurred" / "حدث خطأ" depending on the signed-in user's
|
|
|
|
|
|
// preferred language) and description = the thrown error message.
|
|
|
|
|
|
// We assert against the description because it comes straight from
|
|
|
|
|
|
// our route override's response body and is therefore independent of
|
|
|
|
|
|
// the UI language. Wait for it before asserting the rollback, since
|
|
|
|
|
|
// the toast and the optimistic-order reset both happen synchronously
|
|
|
|
|
|
// in the onError branch — once the toast is visible we know the
|
|
|
|
|
|
// rollback has already run.
|
|
|
|
|
|
const errorToastDesc = page.getByText("Simulated reorder failure", {
|
|
|
|
|
|
exact: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(errorToastDesc).toBeVisible({ timeout: 5_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Also verify the title is one of the localized "error" strings so
|
|
|
|
|
|
// we catch a regression that drops the title entirely.
|
|
|
|
|
|
const errorToastTitle = page.getByText(/^(An error occurred|حدث خطأ)$/);
|
|
|
|
|
|
await expect(errorToastTitle.first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// The DOM order must have returned to the original [A, B]. Use
|
|
|
|
|
|
// expect.poll so we tolerate a render frame between the
|
|
|
|
|
|
// setOptimisticOrder(null) call and React flushing the new order.
|
|
|
|
|
|
await expect
|
|
|
|
|
|
.poll(readDomOrder, { timeout: 5_000 })
|
|
|
|
|
|
.toEqual([idA, idB]);
|
|
|
|
|
|
|
|
|
|
|
|
// Defense in depth: the database must be untouched — neither row's
|
|
|
|
|
|
// daily_number, start_time, nor end_time should have moved, since
|
|
|
|
|
|
// the server "rejected" the request and the optimistic UI was rolled
|
|
|
|
|
|
// back. (The route override prevented any real PATCH from running,
|
|
|
|
|
|
// but we assert against the DB anyway so a future regression that
|
|
|
|
|
|
// somehow fires the request through a different code path can't
|
|
|
|
|
|
// sneak past.)
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`SELECT id, daily_number, start_time, end_time
|
|
|
|
|
|
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
|
|
|
|
|
|
[[idA, idB]],
|
|
|
|
|
|
);
|
|
|
|
|
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
|
|
|
|
|
expect(byId[idA].daily_number).toBe(1);
|
|
|
|
|
|
expect(String(byId[idA].start_time)).toBe("09:00:00");
|
|
|
|
|
|
expect(String(byId[idA].end_time)).toBe("10:00:00");
|
|
|
|
|
|
expect(byId[idB].daily_number).toBe(2);
|
|
|
|
|
|
expect(String(byId[idB].start_time)).toBe("11:00:00");
|
|
|
|
|
|
expect(String(byId[idB].end_time)).toBe("12:00:00");
|
|
|
|
|
|
|
2026-05-11 12:14:08 +00:00
|
|
|
|
await page.unroute("**/api/executive-meetings/rotate-content");
|
2026-05-01 12:53:59 +00:00
|
|
|
|
});
|