diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index b018b84c..723b7996 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/tests/order-receiver-flow.spec.mjs b/artifacts/tx-os/tests/order-receiver-flow.spec.mjs new file mode 100644 index 00000000..1b391a10 --- /dev/null +++ b/artifacts/tx-os/tests/order-receiver-flow.spec.mjs @@ -0,0 +1,419 @@ +// 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(); + } + }); +});