// UI tests for the 7-second "Undo" toast that appears after cancelling or // deleting an order on /my-orders. // // Covers two regression-prone paths: // // 1. Cancel undo: // - Place a pending order via the UI (so /my-orders has a fresh card). // - Cancel it through the in-card Cancel + confirm dialog. // - Click "Undo" inside the toast within UNDO_WINDOW_MS (7s). // - Wait for the restore PATCH /api/orders/:id/status -> "pending". // - Verify the card is back to the pending state (timeline reappears, // status pill is "Awaiting receiver") and the DB row is also pending. // // 2. Delete undo: // - Seed a `completed` order directly in the DB so the trash button is // available. // - Click the trash icon + confirm "Yes, delete". // - Click "Undo" inside the toast within the 7s window. // - Wait long enough for the original delete window to pass and verify: // * NO DELETE /api/orders/:id request is ever made. // * The order row still exists in the DB. // * The card is still visible on /my-orders. // // Both English and Arabic label paths are exercised at least once: the // cancel-undo flow runs in English and the delete-undo flow runs in Arabic. // // 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 order undo-toast 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, 'Order Undo 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 getOrderStatus(id) { const { rows } = await pool.query( `SELECT status FROM service_orders WHERE id = $1`, [id], ); return rows[0]?.status ?? null; } async function orderExists(id) { const { rows } = await pool.query( `SELECT 1 FROM service_orders WHERE id = $1`, [id], ); return rows.length > 0; } 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(); } test.describe("My Orders undo-toast flow", () => { test("English: cancel an order and Undo restores it to pending", async ({ page, }) => { const user = await createUser("undo_cancel_en", "en"); const service = await getAvailableService(); const serviceName = service.nameEn; await setLanguage(page, "en"); await loginViaUi(page, user.username, TEST_PASSWORD); // Place a fresh pending order via the services page. await page.goto("/services"); await page .getByRole("button", { name: serviceName, exact: true }) .first() .click(); const orderModal = page.getByRole("dialog"); await expect(orderModal).toBeVisible(); const placePromise = page.waitForResponse( (resp) => { const path = new URL(resp.url()).pathname; return path === "/api/orders" && resp.request().method() === "POST"; }, { timeout: 15_000 }, ); await orderModal .getByRole("button", { name: "Send", exact: true }) .click(); const placeResp = await placePromise; expect(placeResp.status()).toBe(201); const placedOrder = await placeResp.json(); createdOrderIds.push(placedOrder.id); expect(placedOrder.status).toBe("pending"); // Go to /my-orders and cancel via the in-card confirm dialog. await page.goto("/my-orders"); const firstCard = page.locator(".glass-panel.rounded-xl").first(); await expect(firstCard).toContainText(serviceName); await expect(firstCard).toContainText("Awaiting receiver"); const statusUrl = `/api/orders/${placedOrder.id}/status`; const cancelPatch = page.waitForResponse( (r) => new URL(r.url()).pathname === statusUrl && r.request().method() === "PATCH", { timeout: 15_000 }, ); await firstCard .getByRole("button", { name: "Cancel order", exact: true }) .click(); const confirmDialog = page.getByRole("alertdialog"); await expect(confirmDialog).toBeVisible(); await confirmDialog .getByRole("button", { name: "Yes, cancel", exact: true }) .click(); const cancelResp = await cancelPatch; expect(cancelResp.status()).toBe(200); expect((await cancelResp.json()).status).toBe("cancelled"); // The toast should show with an Undo action; click it well within 7s. const toast = toastRegion(page); await expect(toast).toContainText("Order cancelled"); const restorePatch = page.waitForResponse( (r) => new URL(r.url()).pathname === statusUrl && r.request().method() === "PATCH", { timeout: UNDO_WINDOW_MS - 1000 }, ); await toast.getByRole("button", { name: "Undo" }).click(); const restoreResp = await restorePatch; expect(restoreResp.status()).toBe(200); const restoredBody = await restoreResp.json(); expect(restoredBody.status).toBe("pending"); // UI re-renders: timeline reappears and the cancel button is back. const restoredCard = page.locator(".glass-panel.rounded-xl").first(); await expect(restoredCard.locator("div.h-7.w-7")).toHaveCount(4); await expect(restoredCard).toContainText("Awaiting receiver"); await expect( restoredCard.getByRole("button", { name: "Cancel order", exact: true }), ).toBeVisible(); // DB confirms the row was restored. expect(await getOrderStatus(placedOrder.id)).toBe("pending"); }); test("Arabic: delete a finished order and Undo aborts the deletion", async ({ page, }) => { const user = await createUser("undo_delete_ar", "ar"); const service = await getAvailableService(); const serviceName = service.nameAr; // Seed a completed order so the trash button is available immediately. const orderId = await insertOrder(user.id, service.id, "completed"); await setLanguage(page, "ar"); await loginViaUi(page, user.username, TEST_PASSWORD); await page.goto("/my-orders"); const card = page.locator(".glass-panel.rounded-xl").first(); await expect(card).toContainText(serviceName); await expect(card).toContainText("مكتمل"); // orderStatus.completed // Fail the test if a DELETE actually goes out — Undo must cancel the timer. let deleteRequestSeen = false; page.on("request", (req) => { const path = new URL(req.url()).pathname; if ( path === `/api/orders/${orderId}` && req.method() === "DELETE" ) { deleteRequestSeen = true; } }); // Trash button uses aria-label = t("myOrders.delete") => "حذف" in Arabic. await card.getByRole("button", { name: "حذف", exact: true }).click(); const confirmDialog = page.getByRole("alertdialog"); await expect(confirmDialog).toBeVisible(); await confirmDialog .getByRole("button", { name: "نعم، احذف", exact: true }) .click(); // Card disappears from the list while the delete is pending. await expect( page.locator(".glass-panel.rounded-xl").filter({ hasText: serviceName }), ).toHaveCount(0); // #223: single-row delete now goes through the same i18n-plural toast // as bulk-clear, so N=1 reads as the `clearedCount_one` form // ("تم حذف طلب واحد") instead of the legacy generic copy. // Action label is unchanged ("تراجع"). const toast = toastRegion(page); await expect(toast).toContainText("تم حذف طلب واحد"); await toast.getByRole("button", { name: "تراجع" }).click(); // The card should come back to the list immediately after Undo. await expect( page.locator(".glass-panel.rounded-xl").filter({ hasText: serviceName }), ).toHaveCount(1); // Wait past the original delete window to make absolutely sure the // scheduled DELETE was cancelled (and that no late delete fires). await page.waitForTimeout(UNDO_WINDOW_MS + 1500); expect(deleteRequestSeen).toBe(false); expect(await orderExists(orderId)).toBe(true); expect(await getOrderStatus(orderId)).toBe("completed"); // And the card is still on screen with the same finished state. const stillVisible = page .locator(".glass-panel.rounded-xl") .filter({ hasText: serviceName }) .first(); await expect(stillVisible).toBeVisible(); await expect(stillVisible).toContainText("مكتمل"); }); });