Files
TX/artifacts/tx-os/tests/order-place-cancel.spec.mjs
riyadhafraa 6c160530fa Add e2e tests for the order placement and cancellation flow
Original task (#65): Persist coverage for the new "Order" + "My Orders"
flow with a Playwright spec under artifacts/teaboy-os/tests/ that places
an order, sees the "Awaiting receiver" pill in /my-orders, cancels it,
and verifies the red cancelled pill replaces the timeline and the
cancel button disappears. Both Arabic and English label paths must be
exercised, and the spec must run under `pnpm --filter @workspace/teaboy-os
test:e2e`.

Implementation
- Added artifacts/tx-os/tests/order-place-cancel.spec.mjs (the artifact
  is now `tx-os`; `teaboy-os` no longer exists, so the spec lives in
  the same tests directory as `leave-group-successor.spec.mjs` and is
  picked up automatically by the existing test:e2e Playwright config).
- Style mirrors leave-group-successor.spec.mjs: file-scoped DB pool,
  per-test seeded user with the `user` role, after-all cleanup that
  deletes the spec's orders, the user's stray orders/notifications,
  user_roles, and the user itself.
- The spec runs the same flow twice, once with English labels and once
  with Arabic labels, driven through a `placeAndCancelFlow(page, lang,
  labels)` helper that:
    1. Forces the UI language via `page.addInitScript` writing the
       `tx-lang` localStorage key BEFORE any page script runs.
    2. Seeds the test user with `preferred_language` matching `lang`,
       because AuthContext + login.tsx call
       `i18n.changeLanguage(user.preferredLanguage)` on login and would
       otherwise override the localStorage seed.
    3. Logs in via the real /login form, navigates to /services,
       clicks the available service tile (matched by aria-label =
       localized name), submits the order modal, and waits for the
       POST /api/orders 201 to capture the order id.
    4. Navigates to /my-orders, asserts the first card contains the
       service name, the pending pill ("Awaiting receiver" / "بانتظار
       المستلِم"), and 4 timeline step circles.
    5. Clicks the in-card Cancel button, confirms via the alertdialog,
       waits for the PATCH /api/orders/:id/status 200 → cancelled.
    6. Asserts the timeline step circles are gone, the centered red
       cancelled pill ("Cancelled" / "ملغى") is present, and the
       Cancel button is removed from the card.

Verification
- `pnpm --filter @workspace/tx-os exec playwright install chromium`
- `cd artifacts/tx-os && pnpm exec playwright test --config
  playwright.config.mjs` → all 6 specs pass (the 4 pre-existing ones +
  the 2 new EN/AR tests).

Deviations
- Task referenced the `teaboy-os` artifact path; the actual artifact in
  this codebase is `tx-os`, so the spec was placed under
  `artifacts/tx-os/tests/` and registered with the existing
  `pnpm --filter @workspace/tx-os test:e2e` script (the original
  `teaboy-os` filter no longer exists).

Replit-Task-Id: d2139d0e-9ee0-4795-8f94-29e1cfe4b35a
2026-04-28 05:37:08 +00:00

288 lines
9.8 KiB
JavaScript

// 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: "ملغى",
});
});
});