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:
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("مكتمل");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user