// 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 // as an
    containing one
  1. 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); }); });