diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 6aa235e5..5792704d 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/tests/order-delete-flush-on-unmount.spec.mjs b/artifacts/tx-os/tests/order-delete-flush-on-unmount.spec.mjs new file mode 100644 index 00000000..1c58da81 --- /dev/null +++ b/artifacts/tx-os/tests/order-delete-flush-on-unmount.spec.mjs @@ -0,0 +1,277 @@ +// UI test for the unmount-flush behaviour of /my-orders. +// +// my-orders.tsx schedules each delete behind a 7-second setTimeout so that +// the user has time to click "Undo". On unmount it MUST clear those timers +// AND immediately fire performDelete() for whatever IDs were still pending, +// otherwise navigating away during the undo window would silently lose the +// delete (the timer is GC'd with the page and the API call never goes out). +// +// Today the existing specs only cover: +// - Undo aborts the timer (order-undo-toast.spec.mjs) +// - Letting the timer expire (order-clear-finished-undo.spec.mjs) +// Neither covers the unmount-flush path, so this spec fills that gap. +// +// Flow exercised: +// - Seed a completed order directly in the DB. +// - Log in, go to /my-orders, click the trash icon + confirm. +// - WITHOUT clicking Undo and well before the 7s timer fires, navigate +// away from /my-orders by clicking the in-page Back button. This is an +// in-SPA wouter navigation (setLocation("/services")), so the page +// actually unmounts and the cleanup useEffect runs — unlike a full +// page.goto() which would tear down the document and abort fetch. +// - Verify a DELETE /api/orders/:id request was observed during the +// navigation. +// - Verify the order row is actually gone from the database. +// +// 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 unmount-flush 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, 'Unmount Flush 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 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) { + 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 unmount flushes pending deletes", () => { + test("English: navigating away during the undo window flushes the DELETE", async ({ + page, + }) => { + const user = await createUser("unmount_flush_en", "en"); + const service = await getAvailableService(); + const serviceName = service.nameEn; + + // Seed a completed order so the trash button is available immediately. + const orderId = await insertOrder(user.id, service.id, "completed"); + + await setLanguage(page, "en"); + 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("Completed"); + + // Track every DELETE /api/orders/:id request along with when it fired. + // The unmount cleanup must flush our queued id immediately on unmount, + // not after the 7s timer would have naturally elapsed. + const deleteRequests = []; + page.on("request", (req) => { + const path = new URL(req.url()).pathname; + if ( + path === `/api/orders/${orderId}` && + req.method() === "DELETE" + ) { + deleteRequests.push({ path, at: Date.now() }); + } + }); + + // Click trash + confirm "Yes, delete" to queue the deletion. + await card.getByRole("button", { name: "Delete", exact: true }).click(); + const confirmDialog = page.getByRole("alertdialog"); + await expect(confirmDialog).toBeVisible(); + await confirmDialog + .getByRole("button", { name: "Yes, delete", 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); + + // The Undo toast should be open. We deliberately do NOT click Undo — + // we want the cleanup path, not the cancel path. + const toast = toastRegion(page); + await expect(toast).toContainText("Order deleted"); + await expect(toast.getByRole("button", { name: "Undo" })).toBeVisible(); + + // Sanity: nothing should have fired yet — the 7s timer is still pending. + expect(deleteRequests).toHaveLength(0); + expect(await orderExists(orderId)).toBe(true); + + // Navigate away via the in-page Back button. This is wouter + // setLocation("/services"), which is a real in-SPA navigation, so + // MyOrdersPage actually unmounts and the cleanup useEffect runs. A + // page.goto() to a new URL would instead tear down the document and + // would not exercise the React unmount path. + // + // The waitForResponse timeout is intentionally well under + // UNDO_WINDOW_MS so that a regression which removes the cleanup — + // and therefore relies on the natural 7s setTimeout to fire — would + // time out here instead of silently passing. + const FLUSH_DEADLINE_MS = 3000; + const deleteResponsePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/orders/${orderId}` && + r.request().method() === "DELETE", + { timeout: FLUSH_DEADLINE_MS }, + ); + const navStartedAt = Date.now(); + await page.getByRole("button", { name: "Back", exact: true }).click(); + + // We should land on /services thanks to the in-SPA navigation. + await page.waitForURL((url) => url.pathname.endsWith("/services"), { + timeout: 5_000, + }); + + // The cleanup must have flushed the pending delete: a DELETE request + // for our order id was observed and it returned a 2xx. + const deleteResp = await deleteResponsePromise; + expect(deleteResp.status()).toBeGreaterThanOrEqual(200); + expect(deleteResp.status()).toBeLessThan(300); + expect(deleteRequests.map((r) => r.path)).toContain( + `/api/orders/${orderId}`, + ); + + // The DELETE must have fired promptly after the unmount, not after + // the natural 7s timer. Anything close to UNDO_WINDOW_MS would mean + // the cleanup didn't actually flush. + const firedAt = deleteRequests[0].at; + expect(firedAt - navStartedAt).toBeLessThan(FLUSH_DEADLINE_MS); + + // And the row is actually gone from the database. + expect(await orderExists(orderId)).toBe(false); + + // Forget about this id so afterAll doesn't try to delete a missing row. + const idx = createdOrderIds.indexOf(orderId); + if (idx !== -1) createdOrderIds.splice(idx, 1); + }); +});