2ffd63832a
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
420 lines
14 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
});
|