288 lines
9.8 KiB
JavaScript
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: "ملغى",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|