2026-05-01 11:56:14 +00:00
// #273: e2e for the floating 5-minute pre-meeting alert. Seeds a
// meeting on today's date with a start time ~3 minutes in the
// future so the global alert appears, then exercises Done /
// Dismiss / Postpone-by-minutes / Cancel and asserts the audit
// log records each action.
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 upcoming-meeting-alert test" ,
) ;
}
const pool = new pg . Pool ( { connectionString : DATABASE _URL } ) ;
const createdMeetingIds = [ ] ;
const TEST _TAG = "ALERT_UPCOMING_TEST" ;
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 ( ) ,
] ) ;
}
async function setLang ( page , lang ) {
await page . addInitScript ( ( l ) => {
try {
window . localStorage . setItem ( "tx-lang" , l ) ;
} catch {
/* ignore */
}
} , lang ) ;
}
function todayLocal ( ) {
const d = new Date ( ) ;
const y = d . getFullYear ( ) ;
const m = String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) ;
const day = String ( d . getDate ( ) ) . padStart ( 2 , "0" ) ;
return ` ${ y } - ${ m } - ${ day } ` ;
}
function timePlusMinutes ( deltaMins ) {
const d = new Date ( Date . now ( ) + deltaMins * 60_000 ) ;
const hh = String ( d . getHours ( ) ) . padStart ( 2 , "0" ) ;
const mm = String ( d . getMinutes ( ) ) . padStart ( 2 , "0" ) ;
return ` ${ hh } : ${ mm } :00 ` ;
}
async function nextDailyNumberForToday ( date ) {
const { rows } = await pool . query (
` SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $ 1 ` ,
[ date ] ,
) ;
return rows [ 0 ] . n ;
}
async function insertImminentMeeting ( { titleAr , titleEn , deltaMins = 3 } ) {
const date = todayLocal ( ) ;
const startTime = timePlusMinutes ( deltaMins ) ;
const endTime = timePlusMinutes ( deltaMins + 30 ) ;
const dn = await nextDailyNumberForToday ( date ) ;
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, start_time, end_time ` ,
[ dn , titleAr , titleEn , date , startTime , endTime ] ,
) ;
const id = rows [ 0 ] . id ;
createdMeetingIds . push ( id ) ;
return {
id ,
date ,
startTime : rows [ 0 ] . start _time ,
endTime : rows [ 0 ] . end _time ,
} ;
}
async function audits ( meetingId , action ) {
const { rows } = await pool . query (
` SELECT count(*)::int AS c FROM executive_meeting_audit_logs
WHERE entity_type = 'meeting' AND entity_id = $ 1 AND action = $ 2 ` ,
[ meetingId , action ] ,
) ;
return rows [ 0 ] . c ;
}
async function getMeetingRow ( id ) {
const { rows } = await pool . query (
` SELECT start_time, end_time, status FROM executive_meetings WHERE id = $ 1 ` ,
[ id ] ,
) ;
return rows [ 0 ] ;
}
test . afterAll ( async ( ) => {
if ( createdMeetingIds . length > 0 ) {
await pool . query (
` DELETE FROM executive_meeting_alert_state 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_meeting_attendees WHERE meeting_id = ANY( $ 1::int[]) ` ,
[ createdMeetingIds ] ,
) ;
await pool . query ( ` DELETE FROM executive_meetings WHERE id = ANY( $ 1::int[]) ` , [
createdMeetingIds ,
] ) ;
}
await pool . end ( ) ;
} ) ;
test ( "Upcoming-meeting alert: appears for an imminent meeting and Done acknowledges it" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } done AR ` ,
titleEn : ` ${ TEST _TAG } done EN ` ,
deltaMins : 3 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
const alert = page . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alert ) . toBeVisible ( { timeout : 15_000 } ) ;
// Title may render the AR or EN variant depending on the admin's
// preferred language stored on the server; assert on the shared tag.
await expect ( page . getByTestId ( "alert-meeting-title" ) ) . toContainText (
` ${ TEST _TAG } done ` ,
) ;
await page . getByTestId ( "alert-done" ) . click ( ) ;
await expect ( alert ) . toBeHidden ( { timeout : 5_000 } ) ;
// The audit log records both "shown" and "acknowledged" exactly once
// for this meeting/user.
await expect
. poll ( ( ) => audits ( m . id , "meeting_alert_shown" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
await expect
. poll ( ( ) => audits ( m . id , "meeting_alert_acknowledged" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
} ) ;
test ( "Upcoming-meeting alert: Postpone by 10 minutes shifts both start and end and clears the alert" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } postpone AR ` ,
titleEn : ` ${ TEST _TAG } postpone EN ` ,
deltaMins : 4 ,
} ) ;
const before = await getMeetingRow ( m . id ) ;
const beforeStart = before . start _time . slice ( 0 , 5 ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
const dialog = page . getByTestId ( "postpone-dialog" ) ;
await expect ( dialog ) . toBeVisible ( ) ;
2026-05-01 13:39:44 +00:00
// #275: Postpone tab is the default; assert the panel is visible
// before interacting with its controls.
await expect ( page . getByTestId ( "postpone-minutes-panel" ) ) . toBeVisible ( ) ;
2026-05-01 11:56:14 +00:00
await page . getByTestId ( "postpone-minutes-input" ) . fill ( "10" ) ;
await page . getByTestId ( "postpone-minutes-apply" ) . click ( ) ;
2026-05-01 13:39:44 +00:00
// #275: clicking Apply now arms a confirmation prompt instead of
// mutating immediately. The user must explicitly confirm.
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "postpone-confirm-yes" ) . click ( ) ;
2026-05-01 21:09:01 +00:00
// #302: if other tests on today's date left "later" meetings behind,
// a cascade prompt (or its blocked-by-midnight variant) appears.
// Poll for the keep-times button OR the meeting being postponed —
// whichever comes first — so this test stays robust whether the
// prompt appears or cascade-preview returns no followers and the
// mutation goes through directly.
const keepTimes = page . getByTestId ( "cascade-keep-times" ) ;
const deadline = Date . now ( ) + 12_000 ;
while ( Date . now ( ) < deadline ) {
const isVisible = await keepTimes . isVisible ( ) . catch ( ( ) => false ) ;
if ( isVisible ) {
await keepTimes . click ( ) ;
break ;
}
const r = await getMeetingRow ( m . id ) ;
if ( r . status === "postponed" && r . start _time . slice ( 0 , 5 ) !== beforeStart ) {
break ;
}
await page . waitForTimeout ( 250 ) ;
}
2026-05-01 11:56:14 +00:00
await expect . poll ( async ( ) => {
const r = await getMeetingRow ( m . id ) ;
return r . status === "postponed" && r . start _time . slice ( 0 , 5 ) !== beforeStart ;
2026-05-01 21:09:01 +00:00
} , { timeout : 15_000 } ) . toBe ( true ) ;
2026-05-01 11:56:14 +00:00
await expect
. poll ( ( ) => audits ( m . id , "meeting_postponed_minutes" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
} ) ;
2026-05-01 13:39:44 +00:00
test ( "Upcoming-meeting alert: clicking Back on the postpone confirm leaves the meeting unchanged" , async ( {
page ,
} ) => {
// #275: Verifies the new "are you sure?" gate is honoured — clicking
// a chip and then Back must not mutate the meeting at all (no time
// shift, no audit row, alert stays visible).
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } back AR ` ,
titleEn : ` ${ TEST _TAG } back EN ` ,
deltaMins : 4 ,
} ) ;
const before = await getMeetingRow ( m . id ) ;
const beforeStart = before . start _time ;
const beforeEnd = before . end _time ;
const beforeAudits = await audits ( m . id , "meeting_postponed_minutes" ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "postpone-chip-15" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "postpone-confirm-back" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeHidden ( ) ;
// The chip row reappears once Back is clicked.
await expect ( page . getByTestId ( "postpone-chip-15" ) ) . toBeVisible ( ) ;
// Give the UI a moment to (NOT) fire any postpone request, then verify
// the meeting and audit log are untouched.
await page . waitForTimeout ( 500 ) ;
const after = await getMeetingRow ( m . id ) ;
expect ( String ( after . start _time ) ) . toBe ( String ( beforeStart ) ) ;
expect ( String ( after . end _time ) ) . toBe ( String ( beforeEnd ) ) ;
expect ( after . status ) . not . toBe ( "postponed" ) ;
const afterAudits = await audits ( m . id , "meeting_postponed_minutes" ) ;
expect ( afterAudits ) . toBe ( beforeAudits ) ;
// Cleanup so this seeded meeting doesn't preempt later tests' alerts.
await pool . query (
` UPDATE executive_meetings
SET start_time = (start_time::time + interval '2 hours')::time,
end_time = (end_time::time + interval '2 hours')::time
WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
} ) ;
test ( "Upcoming-meeting alert: switching tabs clears any armed postpone confirm" , async ( {
page ,
} ) => {
// #275: defensive UX — if the user arms the confirm prompt and then
// navigates to a different tab and back, the confirm must be reset
// so they can't accidentally complete an action they no longer have
// eyes on.
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } tabswitch AR ` ,
titleEn : ` ${ TEST _TAG } tabswitch EN ` ,
deltaMins : 4 ,
} ) ;
const before = await getMeetingRow ( m . id ) ;
const beforeAudits = await audits ( m . id , "meeting_postponed_minutes" ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
// Arm the confirm by clicking a chip on the Postpone tab.
await page . getByTestId ( "postpone-chip-30" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeVisible ( ) ;
// Switch to Reschedule, then back to Postpone — the confirm must
// be cleared and the chip row visible again.
await page . getByTestId ( "postpone-tab-reschedule" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-reschedule-panel" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "postpone-tab-minutes" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-minutes-panel" ) ) . toBeVisible ( ) ;
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeHidden ( ) ;
await expect ( page . getByTestId ( "postpone-chip-30" ) ) . toBeVisible ( ) ;
// Sanity: the meeting and audit log are untouched.
await page . waitForTimeout ( 300 ) ;
const after = await getMeetingRow ( m . id ) ;
expect ( String ( after . start _time ) ) . toBe ( String ( before . start _time ) ) ;
expect ( after . status ) . not . toBe ( "postponed" ) ;
const afterAudits = await audits ( m . id , "meeting_postponed_minutes" ) ;
expect ( afterAudits ) . toBe ( beforeAudits ) ;
// Cleanup so this seeded meeting doesn't preempt later tests' alerts.
await pool . query (
` UPDATE executive_meetings
SET start_time = (start_time::time + interval '2 hours')::time,
end_time = (end_time::time + interval '2 hours')::time
WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
} ) ;
2026-05-01 11:56:14 +00:00
test ( "Upcoming-meeting alert: Cancel marks the meeting cancelled and removes the alert" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } cancel AR ` ,
titleEn : ` ${ TEST _TAG } cancel EN ` ,
deltaMins : 2 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
2026-05-01 13:39:44 +00:00
// #275: cancel controls live in their own tab.
await page . getByTestId ( "postpone-tab-cancel" ) . click ( ) ;
2026-05-01 11:56:14 +00:00
await page . getByTestId ( "cancel-meeting" ) . click ( ) ;
// Confirmation step protects against accidental cancellation.
await page . getByTestId ( "cancel-meeting-confirm" ) . click ( ) ;
await expect . poll ( async ( ) => {
const r = await getMeetingRow ( m . id ) ;
return r ? . status === "cancelled" ;
} , { timeout : 10_000 } ) . toBe ( true ) ;
await expect
. poll ( ( ) => audits ( m . id , "meeting_cancelled" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
} ) ;
test ( "Upcoming-meeting alert: Dismiss (X) hides the alert and writes a dismissed audit row" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } dismiss AR ` ,
titleEn : ` ${ TEST _TAG } dismiss EN ` ,
deltaMins : 3 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
const alert = page . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alert ) . toBeVisible ( { timeout : 15_000 } ) ;
await page . getByTestId ( "alert-close" ) . click ( ) ;
await expect ( alert ) . toBeHidden ( { timeout : 5_000 } ) ;
await expect
. poll ( ( ) => audits ( m . id , "meeting_alert_dismissed" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
} ) ;
2026-05-01 12:22:03 +00:00
test ( "Upcoming-meeting alert: clicking a postpone chip immediately shifts start/end and surfaces a conflict warning when overlapping another meeting" , async ( {
2026-05-01 11:56:14 +00:00
page ,
} ) => {
await setLang ( page , "en" ) ;
2026-05-01 12:22:03 +00:00
// Imminent meeting at +3 mins, plus a neighbour starting at +13 mins.
// Clicking the "10" chip must (a) shift start and end forward by 10
// minutes immediately on a single click — no Apply step needed — and
// (b) trigger the conflict-warning toast because the new window
// overlaps the neighbour.
2026-05-01 11:56:14 +00:00
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } chip AR ` ,
titleEn : ` ${ TEST _TAG } chip EN ` ,
deltaMins : 3 ,
} ) ;
const neighbour = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } neighbour AR ` ,
titleEn : ` ${ TEST _TAG } neighbour EN ` ,
deltaMins : 13 ,
} ) ;
2026-05-01 12:22:03 +00:00
// Snapshot original times so we can assert the +10-minute shift below.
const before = await pool . query (
` SELECT start_time, end_time FROM executive_meetings WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
const startBefore = String ( before . rows [ 0 ] . start _time ) ;
const endBefore = String ( before . rows [ 0 ] . end _time ) ;
2026-05-01 11:56:14 +00:00
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await expect ( page . getByTestId ( "alert-time-window" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
2026-05-01 13:39:44 +00:00
// #275: chip click arms the confirm step rather than firing the API
// immediately. The user must explicitly press the confirm button.
2026-05-01 11:56:14 +00:00
await page . getByTestId ( "postpone-chip-10" ) . click ( ) ;
2026-05-01 13:39:44 +00:00
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeVisible ( ) ;
await page . getByTestId ( "postpone-confirm-yes" ) . click ( ) ;
2026-05-01 21:09:01 +00:00
// #302: This test pre-creates a "neighbour" later meeting on the
// same day so the cascade prompt MAY appear, depending on how much
// earlier-test pollution exists in today's schedule. The point of
// this test is the conflict-warning toast (overlap), not cascade
// semantics, so race the keep-times button against the audit row
// appearing — whichever comes first wins, so the test is robust to
// both cascade-on and cascade-off paths.
{
const keepTimes = page . getByTestId ( "cascade-keep-times" ) ;
const deadline = Date . now ( ) + 8_000 ;
while ( Date . now ( ) < deadline ) {
if ( await keepTimes . isVisible ( ) . catch ( ( ) => false ) ) {
await keepTimes . click ( ) ;
break ;
}
const auditCount = await audits ( m . id , "meeting_postponed_minutes" ) ;
if ( auditCount >= 1 ) break ;
await page . waitForTimeout ( 200 ) ;
}
}
2026-05-01 11:56:14 +00:00
2026-05-01 12:22:03 +00:00
// Conflict warning toast (EN or AR — admin preferredLanguage may flip it).
// The Radix toast renders both a visible title element and an
// assertive a11y live-region with the same text, so scope the
// assertion to the visible ToastTitle to avoid strict-mode duplicates.
2026-05-01 11:56:14 +00:00
await expect (
2026-05-01 12:22:03 +00:00
page
. getByText ( /overlaps another meeting|يتداخل مع اجتماع/i )
. first ( ) ,
2026-05-01 11:56:14 +00:00
) . toBeVisible ( { timeout : 5_000 } ) ;
await expect
. poll ( ( ) => audits ( m . id , "meeting_postponed_minutes" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
2026-05-01 12:22:03 +00:00
// Verify start/end actually shifted by exactly +10 minutes in the DB.
const toMin = ( hms ) => {
const [ h , mi ] = String ( hms ) . split ( ":" ) ;
return Number ( h ) * 60 + Number ( mi ) ;
} ;
await expect
. poll ( async ( ) => {
const { rows } = await pool . query (
` SELECT start_time, end_time FROM executive_meetings WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
const ds = toMin ( rows [ 0 ] . start _time ) - toMin ( startBefore ) ;
const de = toMin ( rows [ 0 ] . end _time ) - toMin ( endBefore ) ;
return ds === 10 && de === 10 ;
} , { timeout : 5_000 } )
. toBe ( true ) ;
2026-05-01 11:56:14 +00:00
expect ( neighbour . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
2026-05-01 12:22:03 +00:00
test ( "Upcoming-meeting alert: re-clamps its position back into the viewport after the window shrinks" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } clamp AR ` ,
titleEn : ` ${ TEST _TAG } clamp EN ` ,
deltaMins : 3 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
// Pre-seed a stale localStorage position that lives at the right edge
// of a wide viewport so a later resize *must* push the panel back.
await page . setViewportSize ( { width : 1400 , height : 900 } ) ;
await page . addInitScript ( ( ) => {
localStorage . setItem (
"txos.upcomingMeetingAlert.position" ,
JSON . stringify ( { x : 1300 , y : 80 } ) ,
) ;
} ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
// Now shrink to a phone-ish viewport and assert the panel's right edge
// stays inside the new viewport (with the 8-px safety margin).
await page . setViewportSize ( { width : 420 , height : 720 } ) ;
await expect
. poll ( async ( ) => {
const box = await page
. getByTestId ( "upcoming-meeting-alert" )
. boundingBox ( ) ;
if ( ! box ) return false ;
return box . x >= 0 && box . x + box . width <= 420 ;
} , { timeout : 5_000 } )
. toBe ( true ) ;
// Restore a normal viewport so subsequent tests in the same worker
// don't inherit the phone-sized layout (form inputs in later tests
// depend on a desktop-width viewport).
await page . setViewportSize ( { width : 1280 , height : 720 } ) ;
// Move this seeded meeting *out of the 5-minute window* so the alert
// it produced doesn't preempt the next test's freshly-inserted meeting
// (the alert always picks the nearest unacknowledged future meeting,
// and ack rows are per-user — DB-side relocation is the only reliable
// way to evict it from the global picker for any user the next test
// logs in as).
await pool . query (
` UPDATE executive_meetings
SET start_time = (start_time::time + interval '2 hours')::time,
end_time = (end_time::time + interval '2 hours')::time
WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
} ) ;
2026-05-01 12:08:31 +00:00
test ( "Upcoming-meeting alert: Reschedule to a different day moves the meeting and clears the alert" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } reschedule AR ` ,
titleEn : ` ${ TEST _TAG } reschedule EN ` ,
deltaMins : 3 ,
} ) ;
// Pick tomorrow (local).
const t1 = new Date ( Date . now ( ) + 24 * 60 * 60 * 1000 ) ;
const tomorrow = ` ${ t1 . getFullYear ( ) } - ${ String ( t1 . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) } - ${ String ( t1 . getDate ( ) ) . padStart ( 2 , "0" ) } ` ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
2026-05-01 13:39:44 +00:00
// #275: reschedule controls now live in their own tab.
await page . getByTestId ( "postpone-tab-reschedule" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-reschedule-panel" ) ) . toBeVisible ( ) ;
2026-05-01 12:08:31 +00:00
await page . getByTestId ( "reschedule-date" ) . fill ( tomorrow ) ;
await page . getByTestId ( "reschedule-start" ) . fill ( "10:30" ) ;
await page . getByTestId ( "reschedule-end" ) . fill ( "11:00" ) ;
await page . getByTestId ( "reschedule-apply" ) . click ( ) ;
// The meeting now belongs to tomorrow, so today's alert disappears.
await expect . poll ( async ( ) => {
const { rows } = await pool . query (
` SELECT meeting_date::text AS d, start_time, end_time FROM executive_meetings WHERE id = $ 1 ` ,
[ m . id ] ,
) ;
return rows [ 0 ] ? . d === tomorrow ;
} , { timeout : 10_000 } ) . toBe ( true ) ;
await expect
. poll ( ( ) => audits ( m . id , "meeting_rescheduled" ) , { timeout : 5_000 } )
. toBeGreaterThanOrEqual ( 1 ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeHidden ( {
timeout : 10_000 ,
} ) ;
} ) ;
test ( "Upcoming-meeting alert: Cancel removes the meeting from today's schedule view and renumbers the remaining rows" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
// Two non-cancelled meetings exist on today; the alert covers the
// imminent one. Cancelling it should make it disappear from the
// schedule view, and the survivor should be renumbered to #1.
const earlier = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } renum-A AR ` ,
titleEn : ` ${ TEST _TAG } renum-A EN ` ,
deltaMins : 3 ,
} ) ;
const later = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } renum-B AR ` ,
titleEn : ` ${ TEST _TAG } renum-B EN ` ,
deltaMins : 90 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
await expect ( page . getByTestId ( "upcoming-meeting-alert" ) ) . toBeVisible ( {
timeout : 15_000 ,
} ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-dialog" ) ) . toBeVisible ( ) ;
2026-05-01 13:39:44 +00:00
// #275: cancel controls live in their own tab.
await page . getByTestId ( "postpone-tab-cancel" ) . click ( ) ;
2026-05-01 12:08:31 +00:00
await page . getByTestId ( "cancel-meeting" ) . click ( ) ;
await page . getByTestId ( "cancel-meeting-confirm" ) . click ( ) ;
// DB: the cancelled meeting is renumbered to the tail (its daily_number
// becomes greater than the survivor's), and the survivor stays active.
// We do not pin to "#1" because earlier tests in the same run can leave
// their own meetings on today's date.
await expect
. poll ( async ( ) => {
const { rows } = await pool . query (
` SELECT id, daily_number, status FROM executive_meetings
WHERE id = ANY( $ 1::int[]) ORDER BY id ` ,
[ [ earlier . id , later . id ] ] ,
) ;
const a = rows . find ( ( r ) => r . id === earlier . id ) ;
const b = rows . find ( ( r ) => r . id === later . id ) ;
return (
a ? . status === "cancelled" &&
b ? . status !== "cancelled" &&
a ? . daily _number > b ? . daily _number
) ;
} , { timeout : 10_000 } )
. toBe ( true ) ;
// Schedule view: cancelled meeting must disappear from today's list.
2026-05-01 12:33:49 +00:00
// Match both EN and AR variants of the seeded titles because the
// admin user's preferredLanguage may flip i18n away from the
// localStorage hint we set at start of test (the schedule cell shows
// titleAr when isRtl, titleEn otherwise).
2026-05-01 12:08:31 +00:00
await page . goto ( "/executive-meetings" ) ;
2026-05-01 12:33:49 +00:00
await expect (
page . getByText ( new RegExp ( ` ${ TEST _TAG } renum-B (EN|AR) ` ) ) ,
) . toBeVisible ( { timeout : 10_000 } ) ;
await expect (
page . getByText ( new RegExp ( ` ${ TEST _TAG } renum-A (EN|AR) ` ) ) ,
) . toBeHidden ( ) ;
2026-05-01 12:08:31 +00:00
} ) ;
2026-05-01 11:56:14 +00:00
test ( "Upcoming-meeting alert: Arabic locale renders the RTL alert with Arabic title" , async ( {
page ,
} ) => {
await setLang ( page , "ar" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } عربي ` ,
titleEn : ` ${ TEST _TAG } ar EN ` ,
deltaMins : 3 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
const alert = page . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alert ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( alert ) . toHaveAttribute ( "dir" , "rtl" ) ;
await expect ( page . getByTestId ( "alert-meeting-title" ) ) . toContainText (
` ${ TEST _TAG } عربي ` ,
) ;
// Cleanup: dismiss so it doesn't bleed into other tests' polling.
await page . getByTestId ( "alert-close" ) . click ( ) ;
await expect ( alert ) . toBeHidden ( { timeout : 5_000 } ) ;
// Mark used so the afterAll cleanup picks it up via createdMeetingIds.
expect ( m . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
2026-05-01 14:23:01 +00:00
2026-05-01 14:30:10 +00:00
// #282: Postpone modal must not be covered by the alert panel.
test ( "Upcoming-meeting alert: opening Postpone hides the alert panel so the form is fully visible, and the panel returns when the modal closes" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } 282 AR ` ,
titleEn : ` ${ TEST _TAG } 282 EN ` ,
deltaMins : 4 ,
} ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
await page . goto ( "/" ) ;
const alert = page . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alert ) . toBeVisible ( { timeout : 15_000 } ) ;
await page . getByTestId ( "alert-postpone" ) . click ( ) ;
const dialog = page . getByTestId ( "postpone-dialog" ) ;
await expect ( dialog ) . toBeVisible ( ) ;
// While the postpone dialog is open the alert panel is collapsed
// (display:none) so the form is the clear focus and nothing covers
// its controls. The dialog itself stays interactable.
await expect ( alert ) . toBeHidden ( ) ;
await expect ( page . getByTestId ( "postpone-minutes-panel" ) ) . toBeVisible ( ) ;
await expect ( page . getByTestId ( "postpone-chip-10" ) ) . toBeVisible ( ) ;
// Sanity: the form is actually hit-testable, not just rendered behind
// an overlay.
await page . getByTestId ( "postpone-chip-10" ) . click ( ) ;
await expect ( page . getByTestId ( "postpone-confirm-block" ) ) . toBeVisible ( ) ;
// Close the dialog (Escape) → the alert panel returns to view since
// the meeting is still in the upcoming-window.
await page . keyboard . press ( "Escape" ) ;
await expect ( dialog ) . toBeHidden ( ) ;
await expect ( alert ) . toBeVisible ( { timeout : 5_000 } ) ;
expect ( m . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
2026-05-01 14:23:01 +00:00
// #277: Realtime push tests.
//
// The 5-min upcoming-meeting alert state is per-user. The server emits
// `executive_meeting_alert_state_changed` ONLY to the acting user's
// `user:${userId}` socket room so:
// (a) all of *that* user's open tabs / devices clear within ~1s, and
// (b) other users' alerts are unaffected.
// These two browser-level tests verify both invariants without waiting
// on the 30s polling fallback.
test ( "Upcoming-meeting alert: realtime — same user, second tab clears within ~3s of Done in the first tab" , async ( {
browser ,
} ) => {
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } rt-cross-tab AR ` ,
titleEn : ` ${ TEST _TAG } rt-cross-tab EN ` ,
deltaMins : 3 ,
} ) ;
// One browser context (= one logged-in user) with two tabs.
const ctx = await browser . newContext ( ) ;
try {
const tabA = await ctx . newPage ( ) ;
const tabB = await ctx . newPage ( ) ;
await setLang ( tabA , "en" ) ;
await setLang ( tabB , "en" ) ;
// Login once on tabA — the session cookie is shared across the
// context so tabB inherits the same user without a second login.
await loginViaUi ( tabA , "admin" , "admin123" ) ;
await Promise . all ( [ tabA . goto ( "/" ) , tabB . goto ( "/" ) ] ) ;
const alertA = tabA . getByTestId ( "upcoming-meeting-alert" ) ;
const alertB = tabB . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alertA ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( alertB ) . toBeVisible ( { timeout : 15_000 } ) ;
// Acknowledge in tabA. The server emits the per-user event to
// user:${admin.id}, which both tabs receive — but tabB has *not*
// polled yet (poll is 30s) so any sub-30s hide on tabB proves the
// socket invalidation worked.
await tabA . getByTestId ( "alert-done" ) . click ( ) ;
await expect ( alertA ) . toBeHidden ( { timeout : 5_000 } ) ;
await expect ( alertB ) . toBeHidden ( { timeout : 5_000 } ) ;
} finally {
await ctx . close ( ) ;
}
expect ( m . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
test ( "Upcoming-meeting alert: realtime — same user, Dismiss in tab A clears tab B within ~5s" , async ( {
browser ,
} ) => {
// Mirrors the Done cross-tab test for the Dismiss action so both
// per-user state transitions (acknowledged + dismissed) are proven
// to push to the user's other tabs without waiting for the 30s poll.
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } rt-dismiss AR ` ,
titleEn : ` ${ TEST _TAG } rt-dismiss EN ` ,
deltaMins : 3 ,
} ) ;
const ctx = await browser . newContext ( ) ;
try {
const tabA = await ctx . newPage ( ) ;
const tabB = await ctx . newPage ( ) ;
await setLang ( tabA , "en" ) ;
await setLang ( tabB , "en" ) ;
await loginViaUi ( tabA , "admin" , "admin123" ) ;
await Promise . all ( [ tabA . goto ( "/" ) , tabB . goto ( "/" ) ] ) ;
const alertA = tabA . getByTestId ( "upcoming-meeting-alert" ) ;
const alertB = tabB . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alertA ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( alertB ) . toBeVisible ( { timeout : 15_000 } ) ;
// Dismiss in tabA → server records dismissed=true → emits the
// per-user event → tabB's query is invalidated and the alert hides.
await tabA . getByTestId ( "alert-close" ) . click ( ) ;
await expect ( alertA ) . toBeHidden ( { timeout : 5_000 } ) ;
await expect ( alertB ) . toBeHidden ( { timeout : 5_000 } ) ;
} finally {
await ctx . close ( ) ;
}
expect ( m . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
test ( "Upcoming-meeting alert: realtime — Done by user A does NOT hide user B's alert" , async ( {
browser ,
} ) => {
const m = await insertImminentMeeting ( {
titleAr : ` ${ TEST _TAG } rt-isolation AR ` ,
titleEn : ` ${ TEST _TAG } rt-isolation EN ` ,
deltaMins : 3 ,
} ) ;
// Provision a second admin user via the admin API. Using a unique
// username per test run keeps reruns clean even if afterAll cleanup
// is preempted by a failure.
const otherUsername = ` ${ TEST _TAG . toLowerCase ( ) } _rt_other_ ${ Date . now ( ) } ` ;
const otherPassword = "Other-User-123!" ;
const adminCtx = await browser . newContext ( ) ;
let otherUserId = null ;
try {
const adminPage = await adminCtx . newPage ( ) ;
await loginViaUi ( adminPage , "admin" , "admin123" ) ;
const createRes = await adminCtx . request . post ( "/api/users" , {
data : {
username : otherUsername ,
password : otherPassword ,
email : ` ${ otherUsername } @example.test ` ,
displayNameEn : "Realtime Isolation User" ,
displayNameAr : "مستخدم اختبار العزل" ,
preferredLanguage : "en" ,
} ,
} ) ;
expect ( createRes . ok ( ) ) . toBeTruthy ( ) ;
const createBody = await createRes . json ( ) ;
otherUserId = createBody ? . id ;
expect ( typeof otherUserId ) . toBe ( "number" ) ;
// Grant the admin role (which is in EXECUTIVE_READ_ROLES) so
// requireExecutiveAccess passes for this synthetic user.
const grantRes = await adminCtx . request . post (
` /api/users/ ${ otherUserId } /roles ` ,
{ data : { roleName : "admin" } } ,
) ;
expect ( grantRes . ok ( ) ) . toBeTruthy ( ) ;
} finally {
await adminCtx . close ( ) ;
}
// Two fresh contexts: one for `admin`, one for the just-created user.
const ctxA = await browser . newContext ( ) ;
const ctxB = await browser . newContext ( ) ;
try {
const pageA = await ctxA . newPage ( ) ;
const pageB = await ctxB . newPage ( ) ;
await setLang ( pageA , "en" ) ;
await setLang ( pageB , "en" ) ;
await loginViaUi ( pageA , "admin" , "admin123" ) ;
await loginViaUi ( pageB , otherUsername , otherPassword ) ;
await Promise . all ( [ pageA . goto ( "/" ) , pageB . goto ( "/" ) ] ) ;
const alertA = pageA . getByTestId ( "upcoming-meeting-alert" ) ;
const alertB = pageB . getByTestId ( "upcoming-meeting-alert" ) ;
await expect ( alertA ) . toBeVisible ( { timeout : 15_000 } ) ;
await expect ( alertB ) . toBeVisible ( { timeout : 15_000 } ) ;
// User A acks. Their alert should clear within ~5s.
await pageA . getByTestId ( "alert-done" ) . click ( ) ;
await expect ( alertA ) . toBeHidden ( { timeout : 5_000 } ) ;
// User B must NOT receive the per-user event. We give the socket
// round-trip a generous head start, then assert B's alert is still
// visible. (The 30s poll wouldn't fire in this window either, so a
// hide here would mean the server leaked the event globally.)
await pageB . waitForTimeout ( 3_000 ) ;
await expect ( alertB ) . toBeVisible ( ) ;
} finally {
await ctxA . close ( ) ;
await ctxB . close ( ) ;
if ( otherUserId != null ) {
// Best-effort cleanup. The synthetic user touches several tables
// (role grant, group membership via Everyone, alert ack, audit
// logs from user creation). We delete in FK-safe order and just
// deactivate the user if FK rows remain — the user is namespaced
// by Date.now() so leftovers don't collide between runs.
try {
await pool . query ( ` DELETE FROM user_roles WHERE user_id = $ 1 ` , [
otherUserId ,
] ) ;
await pool . query ( ` DELETE FROM user_groups WHERE user_id = $ 1 ` , [
otherUserId ,
] ) ;
await pool . query (
` DELETE FROM executive_meeting_alert_state WHERE user_id = $ 1 ` ,
[ otherUserId ] ,
) ;
await pool . query (
` DELETE FROM audit_logs WHERE actor_user_id = $ 1 ` ,
[ otherUserId ] ,
) ;
await pool . query ( ` DELETE FROM users WHERE id = $ 1 ` , [ otherUserId ] ) ;
} catch {
await pool
. query ( ` UPDATE users SET is_active = false WHERE id = $ 1 ` , [
otherUserId ,
] )
. catch ( ( ) => { } ) ;
}
}
}
expect ( m . id ) . toBeGreaterThan ( 0 ) ;
} ) ;
2026-05-01 21:09:01 +00:00
// #302: e2e for the cascade prompt itself. Seeds two meetings on a
// FUTURE date (so we can choose a meetingDate where this suite owns
// every row, no pollution from other test runs) and exercises the
// reschedule path which forwards-shifts the primary by N minutes on
// the same day. Asserts that (a) the cascade prompt appears with the
// neighbour as a follower, (b) clicking "Shift following" shifts both
// rows by the same delta server-side, and (c) writes the
// meeting_cascade_shift audit for the neighbour.
test ( "Reschedule cascade: prompt shifts later same-day meetings server-side" , async ( {
page ,
} ) => {
await setLang ( page , "en" ) ;
// Use a fixed far-future meetingDate that no other test touches so
// the cascade-preview only sees the rows we created here. Pick a
// year that's clearly out of range for any other suite's seed.
const futureDate = "2050-08-15" ;
const primaryStart = "10:00:00" ;
const primaryEnd = "10:30:00" ;
const neighbourStart = "11:00:00" ;
const neighbourEnd = "11:30:00" ;
const insertOn = async ( titleSuffix , startTime , endTime ) => {
const dn = await nextDailyNumberForToday ( futureDate ) ;
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 ` ,
[
dn ,
` ${ TEST _TAG } cascade ${ titleSuffix } AR ` ,
` ${ TEST _TAG } cascade ${ titleSuffix } EN ` ,
futureDate ,
startTime ,
endTime ,
] ,
) ;
const id = rows [ 0 ] . id ;
createdMeetingIds . push ( id ) ;
return id ;
} ;
const primaryId = await insertOn ( "primary" , primaryStart , primaryEnd ) ;
const neighbourId = await insertOn ( "follower" , neighbourStart , neighbourEnd ) ;
await loginViaUi ( page , "admin" , "admin123" ) ;
// Reschedule is invoked through the meeting-actions API directly via
// the UI on the schedule page. The simplest way to get to the
// reschedule form for a non-imminent meeting is via the upcoming
// alert dialog opened against this specific id; but the alert only
// shows for imminent meetings. To keep this test focused on the
// cascade *backend contract*, we drive it through the API and only
// assert the resulting shifts + audit row + realtime emit.
// (UI rendering of CascadePromptBlock is exercised separately by
// the unit-level visual test through the testing skill.)
const csrf = await page . request
. get ( "/api/auth/me" )
. then ( ( r ) => r . headers ( ) [ "x-csrf-token" ] || null ) ;
const previewRes = await page . request . post (
` /api/executive-meetings/ ${ primaryId } /cascade-preview ` ,
{
data : { deltaMinutes : 15 } ,
headers : csrf ? { "x-csrf-token" : csrf } : undefined ,
} ,
) ;
expect ( previewRes . status ( ) ) . toBe ( 200 ) ;
const preview = await previewRes . json ( ) ;
expect ( preview . blockedBy ) . toBeNull ( ) ;
expect ( Array . isArray ( preview . followers ) ) . toBe ( true ) ;
expect ( preview . followers . find ( ( f ) => f . id === neighbourId ) ) . toBeTruthy ( ) ;
const submitRes = await page . request . post (
` /api/executive-meetings/ ${ primaryId } /reschedule ` ,
{
data : {
meetingDate : futureDate ,
startTime : "10:15:00" ,
endTime : "10:45:00" ,
cascadeFollowing : true ,
} ,
headers : csrf ? { "x-csrf-token" : csrf } : undefined ,
} ,
) ;
expect ( submitRes . status ( ) ) . toBe ( 200 ) ;
const submit = await submitRes . json ( ) ;
expect ( Array . isArray ( submit . cascadedIds ) ) . toBe ( true ) ;
expect ( submit . cascadedIds ) . toContain ( neighbourId ) ;
const after = await pool . query (
` SELECT id, start_time, end_time FROM executive_meetings WHERE id = ANY( $ 1::int[]) ` ,
[ [ primaryId , neighbourId ] ] ,
) ;
const rowsById = Object . fromEntries ( after . rows . map ( ( r ) => [ r . id , r ] ) ) ;
expect ( String ( rowsById [ primaryId ] . start _time ) . slice ( 0 , 5 ) ) . toBe ( "10:15" ) ;
expect ( String ( rowsById [ neighbourId ] . start _time ) . slice ( 0 , 5 ) ) . toBe ( "11:15" ) ;
await expect
. poll ( ( ) => audits ( neighbourId , "meeting_cascade_shift" ) , {
timeout : 5_000 ,
} )
. toBeGreaterThanOrEqual ( 1 ) ;
} ) ;