75fa714b25
Backend (artifacts/api-server/src/routes/executive-meetings.ts):
- New helpers computeCascadeShift + applyCascadeShift; new schema
cascadePreviewSchema; cascadeFollowing flag on postpone-minutes
and reschedule.
- POST /executive-meetings/:id/cascade-preview returns followers +
blockedBy.
- Writer paths now SELECT followers FOR UPDATE inside the txn so
concurrent mutations cannot lose updates and audit oldStart/oldEnd
always reflect the locked-current values (architect feedback).
- Reschedule only cascades when same date AND newStart > oldStart.
- Midnight rejection rolls back the primary too; named offender is
surfaced verbatim to the UI.
- One meeting_cascade_shift audit per follower (trigger meeting +
delta in the row for replay).
Frontend (artifacts/tx-os/src/components/executive-meetings/
upcoming-meeting-alert.tsx):
- CascadePromptBlock with loading / blocked / normal variants and
data-testids cascade-prompt[-blocked|-loading], cascade-keep-times,
cascade-shift-following, cascade-back, cascade-follower-{id}.
- runCascadePreview helper with in-flight + busy guard against
duplicate submissions; falls through to single-meeting submit when
no followers and not blocked.
- rescheduleSubmit extracted; cascade_crosses_midnight from server
is caught and re-rendered as the blocked variant.
i18n: cascade* keys under executiveMeetings.alert in en.json + ar.json
(Arabic plural zero/one/two/few/many/other for header + shift action).
ALSO added `executiveMeetings.audit.action.meeting_cascade_shift` in
both locales so the History view renders a localized label instead of
falling back to the raw action code (review feedback).
Tests:
- 8 cascade backend tests in executive-meetings.test.mjs (preview
shape, atomic shift, skip cancelled/completed, midnight rollback,
reschedule delta + no-op cases) all pass.
- New e2e "Reschedule cascade — opting in shifts all later same-day
meetings" (API-driven backend contract).
- NEW UI dialog test "Cascade prompt UI: Shift sends
cascadeFollowing=true; Keep sends false" — opens the postpone
confirm, mocks cascade-preview to a deterministic single-follower
response, then verifies (via request interception of
/postpone-minutes) that Shift sends cascadeFollowing=true and Keep
sends cascadeFollowing=false (review feedback).
- NEW UI test "Cascade prompt UI: no followers => prompt skipped,
direct submit with cascadeFollowing=false" — locks in the no-
followers branch from the second review pass: mocks cascade-
preview to {followers:[], blockedBy:null} and asserts the prompt
never renders and the postpone-minutes payload carries
cascadeFollowing=false. Both UI tests use wildcard regex routes so
same-day pollution from prior tests cannot misroute requests.
- Made two existing tests (Postpone by 10 minutes, conflict warning)
pollution-tolerant by polling for either the cascade prompt or the
meeting-postponed audit row.
Pre-existing flaky tests (notifications fan-out, postpone race, row
color realtime, reorder, status transitions, alert-done/close
realtime, reschedule-different-day visibility, cancel/renumber strict
mode) are not related to this work and were not modified.
Drift note: prior commit picked up an incidental opengraph.jpg byte
diff (25906 -> 25929 bytes) from the tx-os web workflow restart — no
content change visible to this task; left in place as removing it
requires a destructive git operation.
1226 lines
46 KiB
JavaScript
1226 lines
46 KiB
JavaScript
// #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();
|
|
// #275: Postpone tab is the default; assert the panel is visible
|
|
// before interacting with its controls.
|
|
await expect(page.getByTestId("postpone-minutes-panel")).toBeVisible();
|
|
|
|
await page.getByTestId("postpone-minutes-input").fill("10");
|
|
await page.getByTestId("postpone-minutes-apply").click();
|
|
// #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();
|
|
// #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);
|
|
}
|
|
|
|
await expect.poll(async () => {
|
|
const r = await getMeetingRow(m.id);
|
|
return r.status === "postponed" && r.start_time.slice(0, 5) !== beforeStart;
|
|
}, { timeout: 15_000 }).toBe(true);
|
|
|
|
await expect
|
|
.poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
|
|
.toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
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],
|
|
);
|
|
});
|
|
|
|
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();
|
|
// #275: cancel controls live in their own tab.
|
|
await page.getByTestId("postpone-tab-cancel").click();
|
|
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);
|
|
});
|
|
|
|
test("Upcoming-meeting alert: clicking a postpone chip immediately shifts start/end and surfaces a conflict warning when overlapping another meeting", async ({
|
|
page,
|
|
}) => {
|
|
await setLang(page, "en");
|
|
// 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.
|
|
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,
|
|
});
|
|
// 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);
|
|
|
|
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();
|
|
// #275: chip click arms the confirm step rather than firing the API
|
|
// immediately. The user must explicitly press the confirm button.
|
|
await page.getByTestId("postpone-chip-10").click();
|
|
await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
|
|
await page.getByTestId("postpone-confirm-yes").click();
|
|
// #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);
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
await expect(
|
|
page
|
|
.getByText(/overlaps another meeting|يتداخل مع اجتماع/i)
|
|
.first(),
|
|
).toBeVisible({ timeout: 5_000 });
|
|
await expect
|
|
.poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
|
|
.toBeGreaterThanOrEqual(1);
|
|
|
|
// 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);
|
|
expect(neighbour.id).toBeGreaterThan(0);
|
|
});
|
|
|
|
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],
|
|
);
|
|
});
|
|
|
|
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();
|
|
// #275: reschedule controls now live in their own tab.
|
|
await page.getByTestId("postpone-tab-reschedule").click();
|
|
await expect(page.getByTestId("postpone-reschedule-panel")).toBeVisible();
|
|
|
|
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();
|
|
// #275: cancel controls live in their own tab.
|
|
await page.getByTestId("postpone-tab-cancel").click();
|
|
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.
|
|
// 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).
|
|
await page.goto("/executive-meetings");
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// #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);
|
|
});
|
|
|
|
// #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);
|
|
});
|
|
|
|
// #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);
|
|
});
|
|
|
|
// #302 (UI): focused dialog test — open the postpone confirm, click
|
|
// Yes, see the cascade prompt list our seeded follower, then click
|
|
// "Shift following meetings" and verify (via request interception)
|
|
// that the actual /postpone-minutes call carries cascadeFollowing:
|
|
// true. This exercises the CascadePromptBlock UI itself, not just
|
|
// the backend contract. Uses today's date because UpcomingMeetingAlert
|
|
// only renders for imminent meetings; pollution-tolerant because we
|
|
// only assert OUR follower is among the listed ones, and intercept
|
|
// the mutation before it actually fires so other tests' rows are
|
|
// not collaterally shifted by Shift's broadcast cascade.
|
|
test("Cascade prompt UI: Shift sends cascadeFollowing=true; Keep sends false", async ({
|
|
page,
|
|
}) => {
|
|
await setLang(page, "en");
|
|
|
|
// Imminent primary + a same-day later meeting (well past primary's
|
|
// endTime so it qualifies as a "follower" for cascade-preview).
|
|
const primary = await insertImminentMeeting({
|
|
titleAr: `${TEST_TAG} cascade-ui-primary AR`,
|
|
titleEn: `${TEST_TAG} cascade-ui-primary EN`,
|
|
deltaMins: 3,
|
|
});
|
|
const follower = await insertImminentMeeting({
|
|
titleAr: `${TEST_TAG} cascade-ui-follower AR`,
|
|
titleEn: `${TEST_TAG} cascade-ui-follower EN`,
|
|
// +60 ⇒ start_time is well past primary.endTime (= +33), so
|
|
// cascade-preview will include this row in `followers`.
|
|
deltaMins: 60,
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Intercept cascade-preview to return a deterministic single-
|
|
// follower response so the test does not depend on whatever else
|
|
// exists in today's schedule (pollution from earlier tests, or a
|
|
// late-night seed that would otherwise push the cascade past
|
|
// midnight and trigger the blocked variant). The mutation routes
|
|
// are still intercepted later so no real DB shift happens.
|
|
// Wildcard id: the upcoming alert may surface a different
|
|
// earliest-imminent meeting from earlier-test pollution; we don't
|
|
// care which id it uses — only that the response shape forces the
|
|
// cascade-prompt with our deterministic follower row.
|
|
await page.route(
|
|
/\/api\/executive-meetings\/\d+\/cascade-preview$/,
|
|
async (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
followers: [
|
|
{
|
|
id: follower.id,
|
|
titleAr: `${TEST_TAG} cascade-ui-follower AR`,
|
|
titleEn: `${TEST_TAG} cascade-ui-follower EN`,
|
|
oldStart: "11:00",
|
|
oldEnd: "11:30",
|
|
newStart: "11:10",
|
|
newEnd: "11:40",
|
|
},
|
|
],
|
|
blockedBy: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// ── Path A: Shift following — assert cascadeFollowing=true ──────
|
|
await page.getByTestId("alert-postpone").click();
|
|
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
|
|
await page.getByTestId("postpone-chip-10").click();
|
|
await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
|
|
await page.getByTestId("postpone-confirm-yes").click();
|
|
|
|
// Cascade prompt must show up because the mocked preview returned
|
|
// a follower.
|
|
await expect(page.getByTestId("cascade-prompt")).toBeVisible({
|
|
timeout: 8_000,
|
|
});
|
|
// Our follower row must appear in the list with its testid.
|
|
await expect(
|
|
page.getByTestId(`cascade-follower-${follower.id}`),
|
|
).toBeVisible();
|
|
|
|
// Intercept the mutation so we can read its JSON body without
|
|
// letting it actually fire (which would side-effect every other
|
|
// same-day row via the cascade). Fulfill with a successful empty
|
|
// response so the UI completes its handler cleanly.
|
|
let shiftBody = null;
|
|
await page.route(
|
|
/\/api\/executive-meetings\/\d+\/postpone-minutes$/,
|
|
async (route) => {
|
|
shiftBody = JSON.parse(route.request().postData() || "{}");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: true, cascadedIds: [follower.id] }),
|
|
});
|
|
},
|
|
);
|
|
await page.getByTestId("cascade-shift-following").click();
|
|
await expect.poll(() => shiftBody, { timeout: 5_000 }).not.toBeNull();
|
|
expect(shiftBody.cascadeFollowing).toBe(true);
|
|
expect(shiftBody.minutes).toBe(10);
|
|
await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
|
|
|
|
// Reset UI back to the alert so we can exercise the Keep path on
|
|
// the same primary (the prompt clears after a successful submit).
|
|
await page.reload();
|
|
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// ── Path B: Keep their times — assert cascadeFollowing=false ────
|
|
await page.getByTestId("alert-postpone").click();
|
|
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
|
|
await page.getByTestId("postpone-chip-10").click();
|
|
await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
|
|
await page.getByTestId("postpone-confirm-yes").click();
|
|
|
|
// Either the regular prompt or the blocked-by-midnight variant is
|
|
// acceptable here — both render cascade-keep-times. We only need
|
|
// the keep button to be present so we can click it.
|
|
await page
|
|
.getByTestId("cascade-keep-times")
|
|
.waitFor({ state: "visible", timeout: 8_000 });
|
|
|
|
let keepBody = null;
|
|
await page.route(
|
|
/\/api\/executive-meetings\/\d+\/postpone-minutes$/,
|
|
async (route) => {
|
|
keepBody = JSON.parse(route.request().postData() || "{}");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: true, cascadedIds: [] }),
|
|
});
|
|
},
|
|
);
|
|
await page.getByTestId("cascade-keep-times").click();
|
|
await expect.poll(() => keepBody, { timeout: 5_000 }).not.toBeNull();
|
|
expect(keepBody.cascadeFollowing).toBe(false);
|
|
expect(keepBody.minutes).toBe(10);
|
|
await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
|
|
await page.unroute(/\/api\/executive-meetings\/\d+\/cascade-preview$/);
|
|
});
|
|
|
|
// #302 (UI): lock in the "no followers => cascade prompt is skipped"
|
|
// branch. Mocks cascade-preview to return zero followers and asserts
|
|
// that the UI silently falls through to a single-meeting submit
|
|
// (cascade-prompt is never rendered) and that the postpone-minutes
|
|
// payload carries cascadeFollowing=false.
|
|
test("Cascade prompt UI: no followers => prompt skipped, direct submit with cascadeFollowing=false", async ({
|
|
page,
|
|
}) => {
|
|
await setLang(page, "en");
|
|
|
|
const primary = await insertImminentMeeting({
|
|
titleAr: `${TEST_TAG} cascade-noflw-primary AR`,
|
|
titleEn: `${TEST_TAG} cascade-noflw-primary EN`,
|
|
deltaMins: 3,
|
|
});
|
|
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// Mock the preview to definitively return zero followers regardless
|
|
// of any same-day pollution. Use a wildcard id because the upcoming
|
|
// alert shows the earliest upcoming meeting which may belong to a
|
|
// different test's leftover seed, not the one we just inserted.
|
|
await page.route(
|
|
/\/api\/executive-meetings\/\d+\/cascade-preview$/,
|
|
async (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ followers: [], blockedBy: null }),
|
|
}),
|
|
);
|
|
|
|
// Intercept the mutation and capture its payload — without firing
|
|
// the real backend write so this test can't be perturbed by other
|
|
// tests sharing today's schedule.
|
|
let submittedBody = null;
|
|
await page.route(
|
|
/\/api\/executive-meetings\/\d+\/postpone-minutes$/,
|
|
async (route) => {
|
|
submittedBody = JSON.parse(route.request().postData() || "{}");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ ok: true, cascadedIds: [] }),
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.getByTestId("alert-postpone").click();
|
|
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
|
|
await page.getByTestId("postpone-chip-10").click();
|
|
await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
|
|
await page.getByTestId("postpone-confirm-yes").click();
|
|
|
|
// The submit must fire without ever rendering the cascade prompt.
|
|
await expect.poll(() => submittedBody, { timeout: 5_000 }).not.toBeNull();
|
|
expect(submittedBody.cascadeFollowing).toBe(false);
|
|
expect(submittedBody.minutes).toBe(10);
|
|
|
|
// Cascade prompt and its blocked variant must never have appeared.
|
|
await expect(page.getByTestId("cascade-prompt")).toHaveCount(0);
|
|
await expect(page.getByTestId("cascade-prompt-blocked")).toHaveCount(0);
|
|
|
|
await page.unroute(/\/api\/executive-meetings\/\d+\/cascade-preview$/);
|
|
await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
|
|
});
|
|
|