Add Playwright spec for /my-orders unmount-flush of pending deletes

Original task (#158): /my-orders has a useEffect cleanup that, on
unmount, clears every still-running undo timer and immediately fires
the deletions for whatever IDs were queued. This unmount-flush path
was previously not covered by tests — only the "Undo" path was.

Implementation:
- New spec: artifacts/tx-os/tests/order-delete-flush-on-unmount.spec.mjs
- Mirrors the test scaffolding (DB pool, user/order seeding, login,
  language init, toast locator) used by the existing order-undo-toast
  and order-clear-finished-undo specs.
- Flow: seed a completed order, log in, go to /my-orders, click trash
  + "Yes, delete" to queue the deletion, then navigate away via the
  in-page Back button (which is wouter setLocation("/services") — an
  in-SPA route change, so MyOrdersPage actually unmounts and the
  cleanup useEffect runs).
- Asserts both required outcomes from the task spec:
  * A DELETE /api/orders/:id request is observed during the
    navigation (with a 2xx response).
  * The order row is actually gone from the DB.
- Additionally records the timestamp of the DELETE request and asserts
  it fires within 3s of the navigation (well under UNDO_WINDOW_MS).
  This guards against a regression that removes the cleanup but
  accidentally still passes because the natural 7s timer eventually
  fires anyway in the SPA — without the cleanup, the DELETE would
  arrive ~7s later and the assertion would time out.
- Clean up the seeded order id from the afterAll list when the test
  successfully deletes it via the UI, so teardown does not try to
  re-delete a missing row.

No production code changed; this is a pure test addition.

Replit-Task-Id: 655f3d3b-5fc5-4a87-a754-76ec67148186
This commit is contained in:
riyadhafraa
2026-04-30 17:29:14 +00:00
parent f6078a8968
commit 40534b7c8f
2 changed files with 277 additions and 0 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -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
// <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();
}
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);
});
});