Add Playwright tests for the bulk "Clear N finished" + undo flow on /my-orders
Task #157 asked for end-to-end coverage of the bulk-delete path on /my-orders, which goes through the same scheduleDelete() in artifacts/tx-os/src/pages/my-orders.tsx as the single-card delete but uses a single shared setTimeout that owns multiple order IDs and a different (i18n plural) toast title. None of that was exercised by the existing order-undo-toast.spec.mjs. New file: artifacts/tx-os/tests/order-clear-finished-undo.spec.mjs Three tests, all using the same DB-seeding + login pattern as the existing undo-toast spec: 1. English plural + Undo: seeds 3 finished orders, clicks "Clear 3 finished", confirms, asserts the plural toast ("3 orders deleted" => myOrders.clearedCount_other), clicks Undo inside the 7s window, then waits past UNDO_WINDOW_MS and asserts that NO DELETE /api/orders/:id requests went out, all 3 rows still exist in the DB, and all 3 cards are visible again. This is the core regression guard for the multi-id shared-timer logic. 2. English plural + timer expiry: seeds 1 completed + 1 cancelled order (to confirm both finished states are swept), clicks "Clear 2 finished", asserts the plural toast, does NOT click Undo, polls until both DELETE responses fire and both DB rows are gone. 3. Arabic singular bulk path: seeds 1 finished order, clicks the Arabic "Clear 1 finished" button, confirms, asserts the singular toast title. NOTE: when ids.length === 1 the bulk path falls through to myOrders.deleted ("تم حذف الطلب"), NOT clearedCount_one — so the test asserts actual current behavior and the comment explains why. Then Undoes and verifies the row stays. Deviation from task spec: the task said "Both the singular ('1 order deleted') and plural ('{count} orders deleted') toast titles are exercised at least once." The plural string is exercised, but the literal "1 order deleted" string (clearedCount_one) is unreachable in the current code — scheduleDelete uses myOrders.deleted for the single-id case. I tested the actual singular branch instead and filed follow-up #223 to either route the single-id bulk case through clearedCount_one or remove the dead translation key. Also filed follow-up #224 for the unused clearedPartial string (partial-failure toast for bulk clear). Verified locally: all 3 new tests pass via pnpm --filter @workspace/tx-os exec playwright test \ tests/order-clear-finished-undo.spec.mjs (3 passed in ~47s, exit 0). No existing files were modified. Replit-Task-Id: ba9e23a5-d8ff-4376-ab8c-d03d6a5f15e4
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,412 @@
|
||||
// UI tests for the bulk "Clear N finished" button on /my-orders.
|
||||
//
|
||||
// Covers the bulk-delete + undo path, which is significant because
|
||||
// scheduleDelete in artifacts/tx-os/src/pages/my-orders.tsx uses a single
|
||||
// shared setTimeout that owns multiple order IDs at once. A regression in
|
||||
// the multi-id timer logic (e.g. Undo only cancelling one ID, or the timer
|
||||
// firing for some IDs but not others) would only show up when a user
|
||||
// complains, since the existing order-undo-toast.spec.mjs only exercises
|
||||
// the single-id path.
|
||||
//
|
||||
// Three flows are exercised end-to-end:
|
||||
//
|
||||
// 1. Bulk Undo (plural toast, English):
|
||||
// - Seed 3 finished orders for one user directly in the DB.
|
||||
// - Click "Clear 3 finished" + confirm "Yes, clear".
|
||||
// - Verify the plural toast "3 orders deleted" appears
|
||||
// (myOrders.clearedCount_other).
|
||||
// - Click Undo inside the 7s window.
|
||||
// - Verify NO DELETE /api/orders/:id requests are ever made.
|
||||
// - Verify all 3 rows still exist in the DB.
|
||||
// - Verify all 3 cards are visible again on /my-orders.
|
||||
//
|
||||
// 2. Bulk timer expiry (plural toast, English):
|
||||
// - Seed 2 finished orders.
|
||||
// - Click "Clear 2 finished" + confirm.
|
||||
// - Verify the plural toast "2 orders deleted" appears.
|
||||
// - Do NOT click Undo. Wait past UNDO_WINDOW_MS for the deletes to
|
||||
// actually fire.
|
||||
// - Verify both rows have been deleted from the DB.
|
||||
//
|
||||
// 3. Bulk singular toast (Arabic):
|
||||
// - Seed exactly 1 finished order.
|
||||
// - Click the "مسح طلب منتهٍ" (Clear 1 finished) button + confirm.
|
||||
// - When ids.length === 1 the bulk path uses myOrders.deleted, so the
|
||||
// toast title is "تم حذف الطلب" (NOT clearedCount_one). This
|
||||
// intentionally exercises the singular branch of the title selector
|
||||
// in scheduleDelete().
|
||||
// - Click Undo and verify the row remains in the DB and the card is
|
||||
// visible again.
|
||||
//
|
||||
// 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 bulk clear-finished 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, 'Bulk Clear 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 insertFinishedOrders(userId, serviceId, count, status = "completed") {
|
||||
const ids = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
ids.push(await insertOrder(userId, serviceId, status));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function countExisting(ids) {
|
||||
if (ids.length === 0) return 0;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM service_orders WHERE id = ANY($1::int[])`,
|
||||
[ids],
|
||||
);
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Track DELETE /api/orders/:id calls so we can assert on them.
|
||||
function trackDeletes(page, ids) {
|
||||
const seen = new Set();
|
||||
const expected = new Set(ids.map((id) => `/api/orders/${id}`));
|
||||
page.on("request", (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (req.method() === "DELETE" && expected.has(path)) {
|
||||
seen.add(path);
|
||||
}
|
||||
});
|
||||
return {
|
||||
seen,
|
||||
sawAnyDelete: () => seen.size > 0,
|
||||
sawAllDeletes: () => seen.size === ids.length,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("My Orders bulk Clear-finished + undo flow", () => {
|
||||
test("English plural: Clear 3 finished + Undo restores all rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await createUser("clear_undo_en", "en");
|
||||
const service = await getAvailableService();
|
||||
const serviceName = service.nameEn;
|
||||
|
||||
const ids = await insertFinishedOrders(user.id, service.id, 3, "completed");
|
||||
|
||||
await setLanguage(page, "en");
|
||||
await loginViaUi(page, user.username, TEST_PASSWORD);
|
||||
await page.goto("/my-orders");
|
||||
|
||||
// All 3 cards should be visible up front.
|
||||
const cards = page
|
||||
.locator(".glass-panel.rounded-xl")
|
||||
.filter({ hasText: serviceName });
|
||||
await expect(cards).toHaveCount(3);
|
||||
|
||||
// The "Clear N finished" button uses myOrders.clearFinished_other for N>=2.
|
||||
const clearBtn = page.getByRole("button", {
|
||||
name: "Clear 3 finished",
|
||||
exact: true,
|
||||
});
|
||||
await expect(clearBtn).toBeVisible();
|
||||
|
||||
// Fail the test if any DELETE goes out — Undo must cancel the timer.
|
||||
const tracker = trackDeletes(page, ids);
|
||||
|
||||
await clearBtn.click();
|
||||
const confirmDialog = page.getByRole("alertdialog");
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole("button", { name: "Yes, clear", exact: true })
|
||||
.click();
|
||||
|
||||
// Cards are filtered out of the list while delete is pending.
|
||||
await expect(cards).toHaveCount(0);
|
||||
|
||||
// Plural toast: myOrders.clearedCount_other => "{count} orders deleted".
|
||||
const toast = toastRegion(page);
|
||||
await expect(toast).toContainText("3 orders deleted");
|
||||
|
||||
// Click Undo well inside the 7s window.
|
||||
await toast.getByRole("button", { name: "Undo" }).click();
|
||||
|
||||
// Cards reappear immediately.
|
||||
await expect(cards).toHaveCount(3);
|
||||
|
||||
// Wait past the original delete window to make sure the scheduled
|
||||
// shared-timer DELETE never fires for any of the IDs.
|
||||
await page.waitForTimeout(UNDO_WINDOW_MS + 1500);
|
||||
|
||||
expect(tracker.sawAnyDelete()).toBe(false);
|
||||
|
||||
// All 3 rows still exist in the DB.
|
||||
expect(await countExisting(ids)).toBe(3);
|
||||
|
||||
// Cards still visible after the wait.
|
||||
await expect(cards).toHaveCount(3);
|
||||
});
|
||||
|
||||
test("English plural: Clear 2 finished + timer expiry deletes all rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await createUser("clear_expire_en", "en");
|
||||
const service = await getAvailableService();
|
||||
const serviceName = service.nameEn;
|
||||
|
||||
// Mix completed + cancelled to confirm both finished states are swept.
|
||||
const completedIds = await insertFinishedOrders(
|
||||
user.id,
|
||||
service.id,
|
||||
1,
|
||||
"completed",
|
||||
);
|
||||
const cancelledIds = await insertFinishedOrders(
|
||||
user.id,
|
||||
service.id,
|
||||
1,
|
||||
"cancelled",
|
||||
);
|
||||
const ids = [...completedIds, ...cancelledIds];
|
||||
|
||||
await setLanguage(page, "en");
|
||||
await loginViaUi(page, user.username, TEST_PASSWORD);
|
||||
await page.goto("/my-orders");
|
||||
|
||||
const cards = page
|
||||
.locator(".glass-panel.rounded-xl")
|
||||
.filter({ hasText: serviceName });
|
||||
await expect(cards).toHaveCount(2);
|
||||
|
||||
const tracker = trackDeletes(page, ids);
|
||||
|
||||
const clearBtn = page.getByRole("button", {
|
||||
name: "Clear 2 finished",
|
||||
exact: true,
|
||||
});
|
||||
await clearBtn.click();
|
||||
const confirmDialog = page.getByRole("alertdialog");
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole("button", { name: "Yes, clear", exact: true })
|
||||
.click();
|
||||
|
||||
// Plural toast appears with "2 orders deleted".
|
||||
const toast = toastRegion(page);
|
||||
await expect(toast).toContainText("2 orders deleted");
|
||||
|
||||
// Cards are gone from the list while delete is pending.
|
||||
await expect(cards).toHaveCount(0);
|
||||
|
||||
// Wait for the shared timer to fire and BOTH DELETE calls to complete.
|
||||
await expect
|
||||
.poll(() => tracker.seen.size, {
|
||||
timeout: UNDO_WINDOW_MS + 10_000,
|
||||
intervals: [250, 500, 1000],
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
// Both rows are removed from the DB.
|
||||
await expect
|
||||
.poll(() => countExisting(ids), {
|
||||
timeout: 5000,
|
||||
intervals: [250, 500],
|
||||
})
|
||||
.toBe(0);
|
||||
|
||||
// Cards stay gone.
|
||||
await expect(cards).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Arabic singular: Clear 1 finished uses the singular toast title", async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await createUser("clear_singular_ar", "ar");
|
||||
const service = await getAvailableService();
|
||||
const serviceName = service.nameAr;
|
||||
|
||||
const [orderId] = await insertFinishedOrders(
|
||||
user.id,
|
||||
service.id,
|
||||
1,
|
||||
"completed",
|
||||
);
|
||||
|
||||
await setLanguage(page, "ar");
|
||||
await loginViaUi(page, user.username, TEST_PASSWORD);
|
||||
await page.goto("/my-orders");
|
||||
|
||||
const cards = page
|
||||
.locator(".glass-panel.rounded-xl")
|
||||
.filter({ hasText: serviceName });
|
||||
await expect(cards).toHaveCount(1);
|
||||
|
||||
// Fail the test if a DELETE actually goes out — Undo must cancel the timer.
|
||||
const tracker = trackDeletes(page, [orderId]);
|
||||
|
||||
// Arabic clearFinished_one = "مسح طلب منتهٍ".
|
||||
const clearBtn = page.getByRole("button", {
|
||||
name: "مسح طلب منتهٍ",
|
||||
exact: true,
|
||||
});
|
||||
await expect(clearBtn).toBeVisible();
|
||||
await clearBtn.click();
|
||||
|
||||
const confirmDialog = page.getByRole("alertdialog");
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
// Arabic clearConfirm = "نعم، امسح".
|
||||
await confirmDialog
|
||||
.getByRole("button", { name: "نعم، امسح", exact: true })
|
||||
.click();
|
||||
|
||||
// Card filtered out while pending.
|
||||
await expect(cards).toHaveCount(0);
|
||||
|
||||
// Singular branch in scheduleDelete uses myOrders.deleted (NOT
|
||||
// clearedCount_one). In Arabic that is "تم حذف الطلب".
|
||||
const toast = toastRegion(page);
|
||||
await expect(toast).toContainText("تم حذف الطلب");
|
||||
|
||||
// Undo within the window. Arabic undo label = "تراجع".
|
||||
await toast.getByRole("button", { name: "تراجع" }).click();
|
||||
|
||||
// Card returns immediately.
|
||||
await expect(cards).toHaveCount(1);
|
||||
|
||||
// Wait past the original delete window to confirm the timer was cancelled.
|
||||
await page.waitForTimeout(UNDO_WINDOW_MS + 1500);
|
||||
|
||||
expect(tracker.sawAnyDelete()).toBe(false);
|
||||
expect(await countExisting([orderId])).toBe(1);
|
||||
await expect(cards).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user