Files
TX/artifacts/tx-os/tests/order-receiver-flow.spec.mjs
T
riyadhafraa 2ffd63832a Add e2e tests for the receiver-side incoming-orders flow
Task #102: Adds Playwright UI coverage for the receiver side of the
service-orders flow, mirroring the existing API-level matrix in
artifacts/api-server/tests/service-orders.test.mjs.

What's added
- artifacts/tx-os/tests/order-receiver-flow.spec.mjs with two tests:
  1) "receiver claims, prepares, completes — requester sees live status
     updates": seeds a requester (role "user") and a receiver (role
     "order_receiver"), runs each in its own browser context, has the
     requester place an order via /services, then has the receiver claim
     it from /orders/incoming and walk it through Mark Preparing →
     Mark Completed. After every receiver action it asserts that the
     requester's /my-orders status pill ("Received", "Preparing",
     "Completed") updates without a manual refresh — exercising the
     order_updated socket.io push wired through use-notifications-socket.
  2) "receiver cancels after claiming — requester sees Cancelled pill":
     covers the receiver-side cancel path required by the task by
     claiming the order and then cancelling from /orders/incoming, and
     verifies the requester's card flips to the red "Cancelled" pill and
     loses the timeline step circles.

Test design notes
- English UI only; the language matrix is already covered for the
  requester side by order-place-cancel.spec.mjs.
- Each test creates a unique requester display name so that incoming
  cards can be uniquely located by service name + "From <display name>"
  even if other cards exist on the page.
- Status assertions use exact-text matching on the pill to avoid the
  "Received" / "Awaiting receiver" substring overlap.
- afterAll cleans up service_orders, notifications, user_roles and the
  seeded users (defensive: also deletes any orders referencing those
  users that weren't explicitly tracked).

Other
- The new spec is automatically picked up by the existing
  `pnpm --filter @workspace/tx-os test:e2e` run (testMatch in
  artifacts/tx-os/playwright.config.mjs already globs *.spec.mjs).
- Verified locally: full tx-os e2e suite (9 specs) passes.

Replit-Task-Id: bdbee3d1-20e0-4a40-b2b1-51ea16a15bb7
2026-04-29 13:45:24 +00:00

420 lines
14 KiB
JavaScript

// UI test for the receiver-side incoming-orders flow that lives across
// artifacts/tx-os/src/pages/orders-incoming.tsx and the requester's
// artifacts/tx-os/src/pages/my-orders.tsx.
//
// Mirrors part of the API matrix in
// artifacts/api-server/tests/service-orders.test.mjs but at the UI layer:
// - Seeds a requester (role "user") and a receiver (role "order_receiver").
// - Logs each into their own browser context.
// - Has the requester place an order via /services.
// - Switches to the receiver's /orders-incoming page, claims the order
// (Confirm Receipt), then walks it through Mark Preparing → Mark
// Completed.
// - After each receiver action, asserts that the requester's /my-orders
// card reflects the new status pill in real time. The push happens via
// the socket.io "order_updated" event the API server emits to the
// requester (see artifacts/api-server/src/routes/service-orders.ts +
// artifacts/tx-os/src/hooks/use-notifications-socket.ts), which
// invalidates the my-orders query and triggers a refetch.
// - Separately exercises the receiver-side cancel path: the receiver
// claims a second order, then cancels it from /orders-incoming, and
// the requester sees the red "Cancelled" pill.
//
// English UI only — the order-place-cancel.spec.mjs neighbour already
// exercises Arabic translations on the requester side, and the flow under
// test here is identical between languages save for label strings.
//
// 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 receiver-side incoming-orders UI test",
);
}
// Same convention as the other UI/API tests: 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, roleName, displayNameEn) {
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, $4, 'en', true) RETURNING id`,
[
username,
`${username}@example.com`,
TEST_PASSWORD_HASH,
displayNameEn ?? username,
],
);
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 = $2`,
[id, roleName],
);
return { id, username, displayName: displayNameEn ?? 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) {
// Defensive cleanup: catch any orders created against these users that
// we didn't explicitly track.
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 placeOrderViaUi(page, serviceNameEn) {
await page.goto("/services");
await page
.getByRole("button", { name: serviceNameEn, 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");
await expect(orderModal).toBeHidden();
return placedOrder;
}
// On /my-orders, the StatusPill renders the localized status text in a
// compact rounded-full span (see artifacts/tx-os/src/pages/my-orders.tsx).
// Asserting on the pill text avoids ambiguity with other text on the card
// (notably "Awaiting receiver" containing the substring "Received").
async function expectMyOrdersStatus(card, expectedText) {
await expect(
card.getByText(expectedText, { exact: true }).first(),
).toBeVisible({ timeout: 15_000 });
}
// On /orders-incoming, locate the card belonging to the order placed by
// `requesterDisplayName` for `serviceNameEn`. The card includes both
// (e.g. "<service name>" and "From <requester display name>"), so combining
// them uniquely identifies the order even if the page already has other
// cards from prior runs / unrelated users.
function incomingCardFor(page, serviceNameEn, requesterDisplayName) {
return page
.locator(".glass-panel.rounded-xl")
.filter({ hasText: serviceNameEn })
.filter({ hasText: requesterDisplayName });
}
async function waitForStatusPatch(page, orderId) {
return page.waitForResponse(
(resp) => {
const path = new URL(resp.url()).pathname;
return (
path === `/api/orders/${orderId}/status` &&
resp.request().method() === "PATCH"
);
},
{ timeout: 15_000 },
);
}
async function waitForConfirmReceipt(page, orderId) {
return page.waitForResponse(
(resp) => {
const path = new URL(resp.url()).pathname;
return (
path === `/api/orders/${orderId}/confirm-receipt` &&
resp.request().method() === "PATCH"
);
},
{ timeout: 15_000 },
);
}
test.describe("Receiver-side incoming-orders flow", () => {
test("receiver claims, prepares, completes — requester sees live status updates", async ({
browser,
}) => {
const requester = await createUser(
"ord_req",
"user",
`Req ${uniqueSuffix()}`,
);
const receiver = await createUser(
"ord_recv",
"order_receiver",
`Recv ${uniqueSuffix()}`,
);
const service = await getAvailableService();
const requesterContext = await browser.newContext();
const receiverContext = await browser.newContext();
try {
const requesterPage = await requesterContext.newPage();
const receiverPage = await receiverContext.newPage();
await setLanguage(requesterPage, "en");
await setLanguage(receiverPage, "en");
await loginViaUi(requesterPage, requester.username, TEST_PASSWORD);
await loginViaUi(receiverPage, receiver.username, TEST_PASSWORD);
// ---- Requester places an order via /services ----
const placedOrder = await placeOrderViaUi(requesterPage, service.nameEn);
// ---- Requester opens /my-orders so the live updates have a target ----
await requesterPage.goto("/my-orders");
const requesterCard = requesterPage
.locator(".glass-panel.rounded-xl")
.first();
await expect(requesterCard).toContainText(service.nameEn);
await expectMyOrdersStatus(requesterCard, "Awaiting receiver");
// ---- Receiver opens /orders/incoming and claims it ----
await receiverPage.goto("/orders/incoming");
const incomingCard = incomingCardFor(
receiverPage,
service.nameEn,
requester.displayName,
);
await expect(incomingCard.first()).toBeVisible();
const claimPromise = waitForConfirmReceipt(receiverPage, placedOrder.id);
await incomingCard
.first()
.getByRole("button", { name: "Confirm Receipt", exact: true })
.click();
const claimResp = await claimPromise;
expect(claimResp.status()).toBe(200);
// Requester should see "Received" pill via the order_updated socket
// push that invalidates the my-orders query.
await expectMyOrdersStatus(requesterCard, "Received");
// ---- Receiver moves it to Preparing ----
// After the refetch, the card has moved to the "My active orders"
// section but still uniquely identifiable by service + requester.
const mineCardReceived = incomingCardFor(
receiverPage,
service.nameEn,
requester.displayName,
);
await expect(
mineCardReceived
.first()
.getByRole("button", { name: "Mark Preparing", exact: true }),
).toBeVisible();
const preparingPromise = waitForStatusPatch(receiverPage, placedOrder.id);
await mineCardReceived
.first()
.getByRole("button", { name: "Mark Preparing", exact: true })
.click();
const preparingResp = await preparingPromise;
expect(preparingResp.status()).toBe(200);
await expectMyOrdersStatus(requesterCard, "Preparing");
// ---- Receiver moves it to Completed ----
const mineCardPreparing = incomingCardFor(
receiverPage,
service.nameEn,
requester.displayName,
);
await expect(
mineCardPreparing
.first()
.getByRole("button", { name: "Mark Completed", exact: true }),
).toBeVisible();
const completedPromise = waitForStatusPatch(receiverPage, placedOrder.id);
await mineCardPreparing
.first()
.getByRole("button", { name: "Mark Completed", exact: true })
.click();
const completedResp = await completedPromise;
expect(completedResp.status()).toBe(200);
await expectMyOrdersStatus(requesterCard, "Completed");
} finally {
await requesterContext.close();
await receiverContext.close();
}
});
test("receiver cancels after claiming — requester sees Cancelled pill", async ({
browser,
}) => {
const requester = await createUser(
"ord_req_cx",
"user",
`ReqCx ${uniqueSuffix()}`,
);
const receiver = await createUser(
"ord_recv_cx",
"order_receiver",
`RecvCx ${uniqueSuffix()}`,
);
const service = await getAvailableService();
const requesterContext = await browser.newContext();
const receiverContext = await browser.newContext();
try {
const requesterPage = await requesterContext.newPage();
const receiverPage = await receiverContext.newPage();
await setLanguage(requesterPage, "en");
await setLanguage(receiverPage, "en");
await loginViaUi(requesterPage, requester.username, TEST_PASSWORD);
await loginViaUi(receiverPage, receiver.username, TEST_PASSWORD);
const placedOrder = await placeOrderViaUi(requesterPage, service.nameEn);
await requesterPage.goto("/my-orders");
const requesterCard = requesterPage
.locator(".glass-panel.rounded-xl")
.first();
await expect(requesterCard).toContainText(service.nameEn);
await expectMyOrdersStatus(requesterCard, "Awaiting receiver");
// Receiver claims first so the cancel path under test exercises the
// "received → cancelled" transition (the only cancel path the
// receiver UI offers, see orders-incoming.tsx handleCancel guard).
await receiverPage.goto("/orders/incoming");
const incomingCard = incomingCardFor(
receiverPage,
service.nameEn,
requester.displayName,
);
const claimPromise = waitForConfirmReceipt(receiverPage, placedOrder.id);
await incomingCard
.first()
.getByRole("button", { name: "Confirm Receipt", exact: true })
.click();
const claimResp = await claimPromise;
expect(claimResp.status()).toBe(200);
await expectMyOrdersStatus(requesterCard, "Received");
// ---- Receiver cancels the claimed order ----
const mineCard = incomingCardFor(
receiverPage,
service.nameEn,
requester.displayName,
);
const cancelBtn = mineCard
.first()
.getByRole("button", { name: "Cancel", exact: true });
await expect(cancelBtn).toBeVisible();
const cancelPromise = waitForStatusPatch(receiverPage, placedOrder.id);
await cancelBtn.click();
const cancelResp = await cancelPromise;
expect(cancelResp.status()).toBe(200);
const cancelBody = await cancelResp.json();
expect(cancelBody.status).toBe("cancelled");
// Requester sees the cancelled pill via the live socket update; the
// timeline circles are replaced with a centered "Cancelled" badge.
await expectMyOrdersStatus(requesterCard, "Cancelled");
await expect(requesterCard.locator("div.h-7.w-7")).toHaveCount(0);
} finally {
await requesterContext.close();
await receiverContext.close();
}
});
});