Files
TX/artifacts/tx-os/tests/order-place-cancel.spec.mjs
T

288 lines
9.8 KiB
JavaScript
Raw Normal View History

// UI test for the end-to-end order placement and cancellation flow that lives
// across artifacts/tx-os/src/pages/services.tsx, the order modal in
// artifacts/tx-os/src/components/order-service-modal.tsx, and
// artifacts/tx-os/src/pages/my-orders.tsx.
//
// Mirrors the API-side coverage in
// artifacts/api-server/tests/service-orders.test.mjs but at the UI layer:
// - Logs in as a regular user (seeded via the database).
// - Navigates to /services and clicks an available service tile.
// - Submits the order modal and waits for the POST /api/orders 201 response.
// - Confirms the new order shows up at the top of /my-orders with the
// "Awaiting receiver" pill (pending status).
// - Cancels it via the in-card Cancel button + confirmation dialog and waits
// for the PATCH /api/orders/:id/status 200 response.
// - Asserts the red cancelled pill replaces the timeline (no timeline step
// circles remain) and the Cancel button is gone from the card.
//
// Both Arabic and English label paths are exercised via separate tests; the
// language is forced through localStorage before the page loads.
//
// 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
//
// The test seeds its own user + uses an existing available service, then
// cleans up the user, their orders, and any notifications it produced.
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 placement / cancellation UI test",
);
}
// Same convention as artifacts/api-server/tests/service-orders.test.mjs:
// a precomputed bcrypt hash of the literal password "TestPass123!".
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
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 UI Test', $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];
}
test.afterAll(async () => {
if (createdOrderIds.length > 0) {
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
createdOrderIds,
]);
}
if (createdUserIds.length > 0) {
// Catch any orders the user created that we didn't track (defensive).
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(),
]);
}
async function placeAndCancelFlow(page, lang, labels) {
// Seed the user with a server-side preferredLanguage matching the test
// language, because AuthContext / login.tsx call `i18n.changeLanguage(
// user.preferredLanguage)` after login and would otherwise override our
// localStorage tx-lang seed.
const user = await createUser(`order_ui_${lang}`, lang);
const service = await getAvailableService();
const serviceName = lang === "ar" ? service.nameAr : service.nameEn;
await setLanguage(page, lang);
await loginViaUi(page, user.username, TEST_PASSWORD);
// ---- Place an order from /services ----
await page.goto("/services");
// Service tiles render as <button aria-label={localized name}>; pick the
// first one matching our chosen service.
await page
.getByRole("button", { name: serviceName, exact: true })
.first()
.click();
const orderModal = page.getByRole("dialog");
await expect(orderModal).toBeVisible();
// Modal title is "Order: {name}" / "اطلب: {name}".
await expect(orderModal).toContainText(labels.modalTitle);
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: labels.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");
// Modal closes after a successful placement.
await expect(orderModal).toBeHidden();
// ---- Verify it appears at the top of /my-orders with the pending pill ----
await page.goto("/my-orders");
// OrderCard root element: the order cards are the only `.glass-panel.rounded-xl`
// containers on this page (the page header uses `border-b`, not `rounded-xl`).
const orderCards = page.locator(".glass-panel.rounded-xl");
await expect(orderCards.first()).toBeVisible();
const firstCard = orderCards.first();
await expect(firstCard).toContainText(serviceName);
// Status pill (top-right) shows the "pending" label.
await expect(firstCard).toContainText(labels.pending);
// Before cancellation there must be 4 timeline step circles
// (pending → received → preparing → completed).
const timelineSteps = firstCard.locator("div.h-7.w-7");
await expect(timelineSteps).toHaveCount(4);
// Cancel button is present and clickable.
const cancelBtn = firstCard.getByRole("button", {
name: labels.cancelOrder,
exact: true,
});
await expect(cancelBtn).toBeVisible();
// ---- Cancel the order via the confirmation dialog ----
await cancelBtn.click();
const confirmDialog = page.getByRole("alertdialog");
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(labels.cancelConfirmTitle);
const cancelPromise = page.waitForResponse(
(resp) => {
const path = new URL(resp.url()).pathname;
return (
path === `/api/orders/${placedOrder.id}/status` &&
resp.request().method() === "PATCH"
);
},
{ timeout: 15_000 },
);
await confirmDialog
.getByRole("button", { name: labels.confirmCancel, exact: true })
.click();
const cancelResp = await cancelPromise;
expect(cancelResp.status()).toBe(200);
const cancelledBody = await cancelResp.json();
expect(cancelledBody.status).toBe("cancelled");
// ---- Verify the cancelled pill replaces the timeline + cancel button gone ----
// After react-query invalidates and refetches, the card re-renders with the
// centered cancelled pill instead of the 4 step circles.
const refreshedCard = page.locator(".glass-panel.rounded-xl").first();
await expect(
refreshedCard.locator("div.h-7.w-7"),
).toHaveCount(0);
// The red cancelled badge inside the timeline area is now visible.
await expect(refreshedCard).toContainText(labels.cancelledPill);
// The Cancel button is gone from the card (cancel is no longer allowed).
await expect(
refreshedCard.getByRole("button", {
name: labels.cancelOrder,
exact: true,
}),
).toHaveCount(0);
}
test.describe("Order placement + cancellation flow", () => {
test("English UI: place order from /services, cancel it from /my-orders", async ({
page,
}) => {
await placeAndCancelFlow(page, "en", {
modalTitle: "Order:",
send: "Send",
pending: "Awaiting receiver",
cancelOrder: "Cancel order",
cancelConfirmTitle: "Cancel this order?",
confirmCancel: "Yes, cancel",
cancelledPill: "Cancelled",
});
});
test("Arabic UI: place order from /services, cancel it from /my-orders", async ({
page,
}) => {
await placeAndCancelFlow(page, "ar", {
modalTitle: "اطلب:",
send: "إرسال",
pending: "بانتظار المستلِم",
cancelOrder: "إلغاء الطلب",
cancelConfirmTitle: "إلغاء هذا الطلب؟",
confirmCancel: "نعم، إلغاء",
cancelledPill: "ملغى",
});
});
});