Files
TX/artifacts/tx-os/tests/order-clear-finished-undo.spec.mjs
T
riyadhafraa c53641c721 #245: narrow umbrella subset — toast polish, opt-out tests, Restore defaults
Picked the 3 most isolated items from the 7-item umbrella; deferred the rest
as #259/#260/#261.

#223 + #224 — singular toast + summary on partial failure (T001):
- my-orders.tsx: replaced the N=1 vs N>1 ternary in scheduleDelete with a
  single t("myOrders.clearedCount", { count }) so i18next picks _one /
  _other automatically. Single-row delete now flows through this same toast
  too — user-visible copy for N=1 is now "1 order deleted" / "تم حذف طلب
  واحد" instead of the legacy "Order deleted" / "تم حذف الطلب".
- my-orders.tsx: partial-failure path now shows ONE summary toast using
  the existing clearedPartial key ("{{ok}} deleted, {{fail}} failed")
  instead of N error toasts. Total failure (okCount===0) keeps deleteFailed.
- Updated 3 Playwright specs that asserted the legacy copy:
  order-clear-finished-undo (already had the singular case), order-undo-toast
  (Arabic single-row delete), order-delete-flush-on-unmount (English).
  Note: the legacy "myOrders.deleted" locale key is now unreferenced in
  source — left in place to avoid noise; deletion can be handled separately.

#238 — opt-out coverage in executive-meetings-notifications.test.mjs (T002):
- Appended 4 tests + setPref/clearPref helpers covering
  filterRecipientsByNotificationPref: inApp=false drops user, missing pref
  defaults to ON, cross-event isolation (mute on event A leaves event B
  alone), email=false leaves in-app intact. Helpers use ON CONFLICT on the
  verified unique index. Some scenarios overlap existing tests in
  executive-meetings.test.mjs (lines 1764, 1809) — these still add value
  by exercising the meeting_created socket fan-out path and cross-event
  isolation, which the existing tests don't cover.

#236 — Restore defaults endpoint + button (T003):
- Server: added DELETE /api/executive-meetings/notification-prefs after PUT.
  Scoped strictly to req.session.userId, returns {ok, count}. Reuses
  requireExecutiveAccess guard. Architect confirmed no cross-user leakage.
- Client: restoreDefaults() handler + outline button (data-testid
  "em-pref-restore-defaults", NOT gated on dirty since the whole point is to
  blow away saved settings). New i18n keys restoreDefaults / restored in
  both locales.
- Architect found a stale-state race in restoreDefaults: setDraft(null)
  was called before invalidateQueries, letting the seed effect repopulate
  draft from still-cached pre-DELETE data. Fixed by inverting the order to
  match save() — invalidate first (await refetch), then setDraft(null).
- Tests: appended 2 integration tests to executive-meetings.test.mjs
  covering the full restore flow (PUT 2 muted prefs → DELETE → assert
  {ok,count:2} + GET shows defaults + actual fan-out reaches user again)
  and idempotent no-op DELETE on a user with no rows.

Test results:
- executive-meetings.test.mjs: 47/47 pass (incl. 2 new DELETE tests)
- executive-meetings-notifications.test.mjs: 11/11 pass (incl. 4 new opt-out tests)
- Playwright order specs: 6/6 pass after legacy-copy updates
- Pre-existing failures in service-orders + meeting_created fan-out are
  untouched and not caused by this change.

Follow-ups proposed: #259 (beforeunload + tab-close Playwright), #260
(admin override another user's prefs with audit row + UI), #261 (iPad
header verification — may already work).
2026-05-01 07:30:00 +00:00

414 lines
13 KiB
JavaScript

// UI tests for the bulk "Clear N finished" button on /my-orders.
//
// Covers the bulk-delete + undo path, which is significant because
// scheduleDelete in artifacts/tx-os/src/pages/my-orders.tsx uses a single
// shared setTimeout that owns multiple order IDs at once. A regression in
// the multi-id timer logic (e.g. Undo only cancelling one ID, or the timer
// firing for some IDs but not others) would only show up when a user
// complains, since the existing order-undo-toast.spec.mjs only exercises
// the single-id path.
//
// Three flows are exercised end-to-end:
//
// 1. Bulk Undo (plural toast, English):
// - Seed 3 finished orders for one user directly in the DB.
// - Click "Clear 3 finished" + confirm "Yes, clear".
// - Verify the plural toast "3 orders deleted" appears
// (myOrders.clearedCount_other).
// - Click Undo inside the 7s window.
// - Verify NO DELETE /api/orders/:id requests are ever made.
// - Verify all 3 rows still exist in the DB.
// - Verify all 3 cards are visible again on /my-orders.
//
// 2. Bulk timer expiry (plural toast, English):
// - Seed 2 finished orders.
// - Click "Clear 2 finished" + confirm.
// - Verify the plural toast "2 orders deleted" appears.
// - Do NOT click Undo. Wait past UNDO_WINDOW_MS for the deletes to
// actually fire.
// - Verify both rows have been deleted from the DB.
//
// 3. Bulk singular toast (Arabic):
// - Seed exactly 1 finished order.
// - Click the "مسح طلب منتهٍ" (Clear 1 finished) button + confirm.
// - #223: scheduleDelete() now always routes through clearedCount, so
// N=1 picks the i18next _one form. In Arabic that is
// "تم حذف طلب واحد" (singular plural form), not the generic
// "تم حذف الطلب".
// - Click Undo and verify the row remains in the DB and the card is
// visible again.
//
// Run with:
// DATABASE_URL=... pnpm --filter @workspace/tx-os test:e2e
//
// Browser binaries can be installed once with:
// pnpm --filter @workspace/tx-os exec playwright install chromium
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 bulk clear-finished UI test",
);
}
// Same precomputed bcrypt hash used by the other UI specs ("TestPass123!").
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
// UNDO_WINDOW_MS in artifacts/tx-os/src/pages/my-orders.tsx
const UNDO_WINDOW_MS = 7000;
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdOrderIds = [];
function uniqueSuffix() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, preferredLanguage = "en") {
const username = `${prefix}_${uniqueSuffix()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Bulk Clear UI', $4, true) RETURNING id`,
[
username,
`${username}@example.com`,
TEST_PASSWORD_HASH,
preferredLanguage,
],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
async function getAvailableService() {
const { rows } = await pool.query(
`SELECT id,
name_en AS "nameEn",
name_ar AS "nameAr"
FROM services
WHERE is_available = true
ORDER BY id ASC
LIMIT 1`,
);
if (rows.length === 0) {
throw new Error("No available service found in DB to test ordering against");
}
return rows[0];
}
async function insertOrder(userId, serviceId, status) {
const { rows } = await pool.query(
`INSERT INTO service_orders (user_id, service_id, status)
VALUES ($1, $2, $3) RETURNING id`,
[userId, serviceId, status],
);
const id = rows[0].id;
createdOrderIds.push(id);
return id;
}
async function insertFinishedOrders(userId, serviceId, count, status = "completed") {
const ids = [];
for (let i = 0; i < count; i += 1) {
ids.push(await insertOrder(userId, serviceId, status));
}
return ids;
}
async function countExisting(ids) {
if (ids.length === 0) return 0;
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM service_orders WHERE id = ANY($1::int[])`,
[ids],
);
return rows[0].n;
}
test.afterAll(async () => {
if (createdOrderIds.length > 0) {
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
createdOrderIds,
]);
}
if (createdUserIds.length > 0) {
// Defensive: also wipe anything else attached to the test users.
await pool.query(
`DELETE FROM service_orders
WHERE user_id = ANY($1::int[]) OR assigned_to = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
async function setLanguage(page, lang) {
// i18n.ts reads `localStorage.getItem("tx-lang")` at module-init time, so
// seed it before any page script runs.
await page.addInitScript((l) => {
try {
window.localStorage.setItem("tx-lang", l);
} catch {
/* localStorage may not be available before navigation; safe to ignore */
}
}, lang);
}
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(),
]);
}
// Locate the live toast root. The shadcn Toaster renders Radix's
// <ToastViewport> as an <ol> containing one <li data-state="open"> per active
// toast. We pick the most recently opened one.
function toastRegion(page) {
return page.locator('li[data-state="open"]').last();
}
// Track DELETE /api/orders/:id calls so we can assert on them.
function trackDeletes(page, ids) {
const seen = new Set();
const expected = new Set(ids.map((id) => `/api/orders/${id}`));
page.on("request", (req) => {
const path = new URL(req.url()).pathname;
if (req.method() === "DELETE" && expected.has(path)) {
seen.add(path);
}
});
return {
seen,
sawAnyDelete: () => seen.size > 0,
sawAllDeletes: () => seen.size === ids.length,
};
}
test.describe("My Orders bulk Clear-finished + undo flow", () => {
test("English plural: Clear 3 finished + Undo restores all rows", async ({
page,
}) => {
const user = await createUser("clear_undo_en", "en");
const service = await getAvailableService();
const serviceName = service.nameEn;
const ids = await insertFinishedOrders(user.id, service.id, 3, "completed");
await setLanguage(page, "en");
await loginViaUi(page, user.username, TEST_PASSWORD);
await page.goto("/my-orders");
// All 3 cards should be visible up front.
const cards = page
.locator(".glass-panel.rounded-xl")
.filter({ hasText: serviceName });
await expect(cards).toHaveCount(3);
// The "Clear N finished" button uses myOrders.clearFinished_other for N>=2.
const clearBtn = page.getByRole("button", {
name: "Clear 3 finished",
exact: true,
});
await expect(clearBtn).toBeVisible();
// Fail the test if any DELETE goes out — Undo must cancel the timer.
const tracker = trackDeletes(page, ids);
await clearBtn.click();
const confirmDialog = page.getByRole("alertdialog");
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole("button", { name: "Yes, clear", exact: true })
.click();
// Cards are filtered out of the list while delete is pending.
await expect(cards).toHaveCount(0);
// Plural toast: myOrders.clearedCount_other => "{count} orders deleted".
const toast = toastRegion(page);
await expect(toast).toContainText("3 orders deleted");
// Click Undo well inside the 7s window.
await toast.getByRole("button", { name: "Undo" }).click();
// Cards reappear immediately.
await expect(cards).toHaveCount(3);
// Wait past the original delete window to make sure the scheduled
// shared-timer DELETE never fires for any of the IDs.
await page.waitForTimeout(UNDO_WINDOW_MS + 1500);
expect(tracker.sawAnyDelete()).toBe(false);
// All 3 rows still exist in the DB.
expect(await countExisting(ids)).toBe(3);
// Cards still visible after the wait.
await expect(cards).toHaveCount(3);
});
test("English plural: Clear 2 finished + timer expiry deletes all rows", async ({
page,
}) => {
const user = await createUser("clear_expire_en", "en");
const service = await getAvailableService();
const serviceName = service.nameEn;
// Mix completed + cancelled to confirm both finished states are swept.
const completedIds = await insertFinishedOrders(
user.id,
service.id,
1,
"completed",
);
const cancelledIds = await insertFinishedOrders(
user.id,
service.id,
1,
"cancelled",
);
const ids = [...completedIds, ...cancelledIds];
await setLanguage(page, "en");
await loginViaUi(page, user.username, TEST_PASSWORD);
await page.goto("/my-orders");
const cards = page
.locator(".glass-panel.rounded-xl")
.filter({ hasText: serviceName });
await expect(cards).toHaveCount(2);
const tracker = trackDeletes(page, ids);
const clearBtn = page.getByRole("button", {
name: "Clear 2 finished",
exact: true,
});
await clearBtn.click();
const confirmDialog = page.getByRole("alertdialog");
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole("button", { name: "Yes, clear", exact: true })
.click();
// Plural toast appears with "2 orders deleted".
const toast = toastRegion(page);
await expect(toast).toContainText("2 orders deleted");
// Cards are gone from the list while delete is pending.
await expect(cards).toHaveCount(0);
// Wait for the shared timer to fire and BOTH DELETE calls to complete.
await expect
.poll(() => tracker.seen.size, {
timeout: UNDO_WINDOW_MS + 10_000,
intervals: [250, 500, 1000],
})
.toBe(2);
// Both rows are removed from the DB.
await expect
.poll(() => countExisting(ids), {
timeout: 5000,
intervals: [250, 500],
})
.toBe(0);
// Cards stay gone.
await expect(cards).toHaveCount(0);
});
test("Arabic singular: Clear 1 finished uses the singular toast title", async ({
page,
}) => {
const user = await createUser("clear_singular_ar", "ar");
const service = await getAvailableService();
const serviceName = service.nameAr;
const [orderId] = await insertFinishedOrders(
user.id,
service.id,
1,
"completed",
);
await setLanguage(page, "ar");
await loginViaUi(page, user.username, TEST_PASSWORD);
await page.goto("/my-orders");
const cards = page
.locator(".glass-panel.rounded-xl")
.filter({ hasText: serviceName });
await expect(cards).toHaveCount(1);
// Fail the test if a DELETE actually goes out — Undo must cancel the timer.
const tracker = trackDeletes(page, [orderId]);
// Arabic clearFinished_one = "مسح طلب منتهٍ".
const clearBtn = page.getByRole("button", {
name: "مسح طلب منتهٍ",
exact: true,
});
await expect(clearBtn).toBeVisible();
await clearBtn.click();
const confirmDialog = page.getByRole("alertdialog");
await expect(confirmDialog).toBeVisible();
// Arabic clearConfirm = "نعم، امسح".
await confirmDialog
.getByRole("button", { name: "نعم، امسح", exact: true })
.click();
// Card filtered out while pending.
await expect(cards).toHaveCount(0);
// #223: singular branch now uses clearedCount_one (the same key as the
// plural branch, with i18next pluralization picking the right form).
// In Arabic the _one form is "تم حذف طلب واحد".
const toast = toastRegion(page);
await expect(toast).toContainText("تم حذف طلب واحد");
// Undo within the window. Arabic undo label = "تراجع".
await toast.getByRole("button", { name: "تراجع" }).click();
// Card returns immediately.
await expect(cards).toHaveCount(1);
// Wait past the original delete window to confirm the timer was cancelled.
await page.waitForTimeout(UNDO_WINDOW_MS + 1500);
expect(tracker.sawAnyDelete()).toBe(false);
expect(await countExisting([orderId])).toBe(1);
await expect(cards).toHaveCount(1);
});
});