Add Playwright tests for the My Orders cancel/delete undo toast

Original task (#103): cover the 7-second "Undo" action that appears after
cancelling or deleting an order on /my-orders, since the existing
order-place-cancel spec only verified the post-cancel state and never
exercised the undo path.

What was added:
- New spec at artifacts/tx-os/tests/order-undo-toast.spec.mjs with two cases:
  1. English: place a fresh pending order via the services modal, cancel it
     from the in-card confirm dialog, click "Undo" inside the toast within
     the 7-second window, then assert the restore PATCH /api/orders/:id/status
     succeeds, the timeline + Cancel button reappear in the card, and the DB
     row is back to "pending".
  2. Arabic: seed a "completed" order directly in the DB, click the trash
     icon + "نعم، احذف", then click "تراجع" in the toast. Verifies that NO
     DELETE /api/orders/:id ever fires (page-level request listener), the
     order row still exists in the DB after the original 7s window has
     elapsed, and the card stays visible with the مكتمل status pill.
- Both English and Arabic toast labels are exercised, satisfying the
  bilingual requirement.

Implementation notes / deviations:
- The spec is automatically picked up by the existing test:e2e config
  (testMatch: /.*\.spec\.mjs$/), so no playwright.config or package.json
  changes were needed.
- DB seeding/cleanup mirrors the conventions in
  artifacts/tx-os/tests/order-place-cancel.spec.mjs (same bcrypt hash for
  TestPass123!, same afterAll teardown of orders/notifications/user_roles/users).
- While wiring the test I discovered the shadcn Toaster's <li> root has no
  role="status" on this Radix version, so role-based selectors miss it.
  The spec uses `li[data-state="open"]` (Radix's data attribute) scoped to
  the most recent toast — explained in a comment in the file.

Verification:
- pnpm exec playwright test tests/order-undo-toast.spec.mjs → 2 passed.
- Re-ran together with the existing order-place-cancel spec → 4 passed,
  no regressions.

Follow-ups proposed (#157, #158): bulk "Clear N finished" + undo coverage,
and the unmount-flushes-pending-deletes path.

Replit-Task-Id: bd1c5067-e994-4299-b3a6-855ffb45ed71
This commit is contained in:
riyadhafraa
2026-04-29 14:28:30 +00:00
parent 43aa0c72a0
commit 9b3e320c78
2 changed files with 344 additions and 0 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,344 @@
// 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
// <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 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);
// Toast title in Arabic = "تم حذف الطلب", action label = "تراجع".
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("مكتمل");
});
});