c53641c721
Picked the 3 most isolated items from the 7-item umbrella; deferred the rest as #259/#260/#261. #223 + #224 — singular toast + summary on partial failure (T001): - my-orders.tsx: replaced the N=1 vs N>1 ternary in scheduleDelete with a single t("myOrders.clearedCount", { count }) so i18next picks _one / _other automatically. Single-row delete now flows through this same toast too — user-visible copy for N=1 is now "1 order deleted" / "تم حذف طلب واحد" instead of the legacy "Order deleted" / "تم حذف الطلب". - my-orders.tsx: partial-failure path now shows ONE summary toast using the existing clearedPartial key ("{{ok}} deleted, {{fail}} failed") instead of N error toasts. Total failure (okCount===0) keeps deleteFailed. - Updated 3 Playwright specs that asserted the legacy copy: order-clear-finished-undo (already had the singular case), order-undo-toast (Arabic single-row delete), order-delete-flush-on-unmount (English). Note: the legacy "myOrders.deleted" locale key is now unreferenced in source — left in place to avoid noise; deletion can be handled separately. #238 — opt-out coverage in executive-meetings-notifications.test.mjs (T002): - Appended 4 tests + setPref/clearPref helpers covering filterRecipientsByNotificationPref: inApp=false drops user, missing pref defaults to ON, cross-event isolation (mute on event A leaves event B alone), email=false leaves in-app intact. Helpers use ON CONFLICT on the verified unique index. Some scenarios overlap existing tests in executive-meetings.test.mjs (lines 1764, 1809) — these still add value by exercising the meeting_created socket fan-out path and cross-event isolation, which the existing tests don't cover. #236 — Restore defaults endpoint + button (T003): - Server: added DELETE /api/executive-meetings/notification-prefs after PUT. Scoped strictly to req.session.userId, returns {ok, count}. Reuses requireExecutiveAccess guard. Architect confirmed no cross-user leakage. - Client: restoreDefaults() handler + outline button (data-testid "em-pref-restore-defaults", NOT gated on dirty since the whole point is to blow away saved settings). New i18n keys restoreDefaults / restored in both locales. - Architect found a stale-state race in restoreDefaults: setDraft(null) was called before invalidateQueries, letting the seed effect repopulate draft from still-cached pre-DELETE data. Fixed by inverting the order to match save() — invalidate first (await refetch), then setDraft(null). - Tests: appended 2 integration tests to executive-meetings.test.mjs covering the full restore flow (PUT 2 muted prefs → DELETE → assert {ok,count:2} + GET shows defaults + actual fan-out reaches user again) and idempotent no-op DELETE on a user with no rows. Test results: - executive-meetings.test.mjs: 47/47 pass (incl. 2 new DELETE tests) - executive-meetings-notifications.test.mjs: 11/11 pass (incl. 4 new opt-out tests) - Playwright order specs: 6/6 pass after legacy-copy updates - Pre-existing failures in service-orders + meeting_created fan-out are untouched and not caused by this change. Follow-ups proposed: #259 (beforeunload + tab-close Playwright), #260 (admin override another user's prefs with audit row + UI), #261 (iPad header verification — may already work).
348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
// 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);
|
|
|
|
// #223: single-row delete now goes through the same i18n-plural toast
|
|
// as bulk-clear, so N=1 reads as the `clearedCount_one` form
|
|
// ("تم حذف طلب واحد") instead of the legacy generic copy.
|
|
// Action label is unchanged ("تراجع").
|
|
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("مكتمل");
|
|
});
|
|
});
|