diff --git a/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs b/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs new file mode 100644 index 00000000..f27d4cc0 --- /dev/null +++ b/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs @@ -0,0 +1,564 @@ +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 */ + } + }); +} + +// 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 + // (StarterKit's default tag) or (some sanitizers + // collapse to ) 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"); + + const gripA = page.getByTestId(`em-row-grip-${idA}`); + const gripB = page.getByTestId(`em-row-grip-${idB}`); + await expect(gripA).toBeVisible(); + await expect(gripB).toBeVisible(); + + const fromBox = await gripB.boundingBox(); + const toBox = await gripA.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. 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; + const endX = toBox.x + toBox.width / 2; + // 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; + + const reorderPromise = page.waitForResponse( + (resp) => { + const u = new URL(resp.url()); + return ( + u.pathname === "/api/executive-meetings/reorder" && + 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; + + // The reorder API rewrites both daily_number and start_time/end_time + // so that the requested order owns the chronologically-sorted slots. + // After moving B above A, B should have daily_number=1 and the + // earlier 09:00–10:00 slot, and A should fall to daily_number=2 with + // the later 11:00–12:00 slot. + 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 ` via inline box-shadow on the . + 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/); + + // Now open the customize popover and pick the red swatch (#dc2626). + await page.getByTestId("em-customize-columns-trigger").click(); + 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(); + // Close the popover so it doesn't overlap further assertions. + await page.keyboard.press("Escape"); + + // 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); +});