From ffe4400838ef6444db3dbb93cc45218e5ce0caec Mon Sep 17 00:00:00 2001 From: Riyadh Date: Fri, 1 May 2026 11:02:39 +0000 Subject: [PATCH] Add browser test for editing attendee names with formatting (Task #186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: cover the inline attendee-name edit flow in the Executive Meetings schedule. The attendee row uses the same EditableCell + Tiptap toolbar as titles (parent task #122), but until now only the title path had end-to-end coverage. A regression in attendee-name formatting would only have surfaced via manual QA. Changes: - Extended artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs with a new scenario: * seeds a meeting + one plain-text attendee directly in the DB, * logs in as admin, navigates to the schedule, jumps to the seeded date, flips on edit mode, * clicks the inline attendee EditableCell, selects all, applies bold + the red color swatch via the toolbar, and saves via the check button while waiting for PUT /api/executive-meetings/:id/attendees to succeed, * reloads the page, re-navigates to the date, and asserts the rendered HTML in the cell still contains / and the red color (#dc2626 or rgb(220,38,38)), * additionally queries executive_meeting_attendees.name in the DB to confirm the formatted HTML round-tripped through the server, not just survived in the local Tiptap document. - Added a small insertAttendee helper in the same spec to seed initial state without driving the manage-dialog flow. - The existing afterAll already deletes attendee rows for the created meetings (cascade-safe), so no cleanup changes were needed. Verification: ran the new test in isolation (passed in 9.1s) and the full schedule-features spec (5/5 passed in 40.1s). No code changes outside the test file. Follow-up filed: #270 — browser test for clearing an attendee's name to trigger the delete-row gesture (separate documented behavior path with no end-to-end coverage today). --- ...cutive-meetings-schedule-features.spec.mjs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs b/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs index c47540b3..9868f1d2 100644 --- a/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs @@ -76,6 +76,23 @@ async function setLangEn(page) { }); } +// 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], + ); +} + // Insert a meeting row directly. Returns the new id. async function insertMeeting({ meetingDate, @@ -562,3 +579,127 @@ test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no ); expect(reorderResp.status()).toBeGreaterThanOrEqual(400); }); + +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 + // (parent task #122), so applying bold + a color swatch and saving + // 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 + // (StarterKit's default tag) or (some sanitizers + // collapse to ) 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()); +});