diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index f65af7a7..4aa3e468 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-place-cancel.spec.mjs b/artifacts/tx-os/tests/order-place-cancel.spec.mjs new file mode 100644 index 00000000..cfadc090 --- /dev/null +++ b/artifacts/tx-os/tests/order-place-cancel.spec.mjs @@ -0,0 +1,287 @@ +// 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