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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user