// 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. "" and "From "), 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(); } }); });