// Browser tests for the inline dependency counts on the admin lists. // // Locks in three things for the Apps / Services / Users panels: // 1. The `[data-testid^="-counts-"]` container + each // populated child chip render when dependent records exist. // 2. The container is omitted (count = 0 in DOM) for an entity // with zero dependents. // 3. Layout + localized text hold up in both English and Arabic. 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 this test"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdAppIds = []; const createdServiceIds = []; const createdGroupIds = []; function uniqueSuffix() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } async function createUser(prefix, opts = {}) { const username = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_ar, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id`, [ username, `${username}@example.com`, TEST_PASSWORD_HASH, opts.displayNameAr ?? prefix, opts.displayNameEn ?? prefix, opts.preferredLanguage ?? "en", ], ); const id = rows[0].id; createdUserIds.push(id); const roleName = opts.admin ? "admin" : "user"; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`, [id, roleName], ); return { id, username }; } async function createApp(prefix) { const slug = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO apps (name_ar, name_en, slug, icon_name, route, color, sort_order, description_en) VALUES ($1, $2, $3, 'Grid2X2', '/test', '#000000', 999, 'inline counts test app') RETURNING id`, [`تطبيق ${prefix}`, `App ${prefix}`, slug], ); const id = rows[0].id; createdAppIds.push(id); return { id, slug }; } async function createGroup(prefix) { const name = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'inline counts test group') RETURNING id`, [name], ); const id = rows[0].id; createdGroupIds.push(id); return { id, name }; } async function attachAppToGroup(appId, groupId) { await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [groupId, appId], ); } async function attachRestrictionToApp(appId) { // Any permission row is fine — the count is just count(*) on // app_permissions for this appId. const { rows } = await pool.query(`SELECT id FROM permissions LIMIT 1`); if (rows.length === 0) { throw new Error("No permissions exist in DB; cannot attach restriction"); } await pool.query( `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`, [appId, rows[0].id], ); } async function recordAppOpen(userId, appId) { await pool.query( `INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`, [userId, appId], ); } async function createService(prefix) { const nameEn = `Service ${prefix} ${uniqueSuffix()}`; const nameAr = `خدمة ${prefix} ${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available, sort_order) VALUES ($1, $2, '1.00', true, 999) RETURNING id`, [nameAr, nameEn], ); const id = rows[0].id; createdServiceIds.push(id); return { id, nameEn, nameAr }; } async function createOrder(userId, serviceId) { await pool.query( `INSERT INTO service_orders (user_id, service_id) VALUES ($1, $2)`, [userId, serviceId], ); } async function createNote(userId) { await pool.query( `INSERT INTO notes (user_id, title, content) VALUES ($1, 'inline counts', 'test')`, [userId], ); } test.afterAll(async () => { // Order matters: notes / service_orders must go before users // (user_id has no ON DELETE rule). service_orders must also go // before services (service_id is ON DELETE RESTRICT). if (createdUserIds.length > 0) { await pool.query( `DELETE FROM notes WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query( `DELETE FROM service_orders WHERE user_id = ANY($1::int[])`, [createdUserIds], ); } if (createdServiceIds.length > 0) { await pool.query( `DELETE FROM service_orders WHERE service_id = ANY($1::int[])`, [createdServiceIds], ); await pool.query( `DELETE FROM services WHERE id = ANY($1::int[])`, [createdServiceIds], ); } if (createdAppIds.length > 0) { await pool.query( `DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds], ); } if (createdGroupIds.length > 0) { await pool.query( `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query( `DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query( `DELETE FROM groups WHERE id = ANY($1::int[])`, [createdGroupIds], ); } if (createdUserIds.length > 0) { await pool.query( `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query( `DELETE FROM user_groups WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); async function login(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 setLanguage(page, lang) { // The app persists language via localStorage key `tx-lang` (see // artifacts/tx-os/src/i18n.ts). Set it before any page mount so the // very first render is in the chosen language. await page.addInitScript((l) => { try { window.localStorage.setItem("tx-lang", l); } catch {} }, lang); } // Localized chip labels pulled from artifacts/tx-os/src/locales/{en,ar}.json. // We assert against the unit word so future plural variants still pass. const COUNT_TEXT = { en: { appGroups: "groups", appRestrictions: "restrictions", appOpens: "opens", serviceOrders: "orders", userNotes: "notes", userOrders: "orders", }, ar: { appGroups: "مجموعة", appRestrictions: "قيد", appOpens: "فتحة", serviceOrders: "طلب", userNotes: "ملاحظة", userOrders: "طلب", }, }; for (const lang of ["en", "ar"]) { test.describe(`Admin inline dependency counts (${lang})`, () => { test("counts render for entities with deps and are omitted when zero", async ({ page, }) => { const labels = COUNT_TEXT[lang]; // ---- Setup: admin + paired (with-deps / no-deps) entities ---- const admin = await createUser("counts_admin", { admin: true, preferredLanguage: lang, }); const appWithDeps = await createApp("withdeps"); const appNoDeps = await createApp("nodeps"); const groupForApp = await createGroup("appgroup"); await attachAppToGroup(appWithDeps.id, groupForApp.id); await attachRestrictionToApp(appWithDeps.id); await recordAppOpen(admin.id, appWithDeps.id); const serviceWithOrders = await createService("withorders"); const serviceNoOrders = await createService("noorders"); await createOrder(admin.id, serviceWithOrders.id); const userWithDeps = await createUser("u_withdeps"); const userNoDeps = await createUser("u_nodeps"); await createNote(userWithDeps.id); // Pile the user's order onto the SAME service that already has // one so `serviceNoOrders` stays at zero orders and we can assert // its counts row is omitted. await createOrder(userWithDeps.id, serviceWithOrders.id); // ---- Login ---- await setLanguage(page, lang); await login(page, admin.username, TEST_PASSWORD); // ===== Apps panel ===== await page.goto("/admin#section=apps"); const withCounts = page.locator( `[data-testid="app-counts-${appWithDeps.id}"]`, ); await expect(withCounts).toBeVisible({ timeout: 10_000 }); const groupsChip = page.locator( `[data-testid="app-counts-groups-${appWithDeps.id}"]`, ); const restrictionsChip = page.locator( `[data-testid="app-counts-restrictions-${appWithDeps.id}"]`, ); const opensChip = page.locator( `[data-testid="app-counts-opens-${appWithDeps.id}"]`, ); await expect(groupsChip).toBeVisible(); await expect(restrictionsChip).toBeVisible(); await expect(opensChip).toBeVisible(); // Localized text: "1 groups" (en) or "1 مجموعة" (ar) etc. await expect(groupsChip).toContainText("1"); await expect(groupsChip).toContainText(labels.appGroups); await expect(restrictionsChip).toContainText("1"); await expect(restrictionsChip).toContainText(labels.appRestrictions); await expect(opensChip).toContainText("1"); await expect(opensChip).toContainText(labels.appOpens); // The companion app row exists but its counts container is omitted. // The delete button anchors the row so we know the app rendered. await expect( page.locator(`[data-testid="app-delete-${appNoDeps.id}"]`), ).toBeVisible(); await expect( page.locator(`[data-testid="app-counts-${appNoDeps.id}"]`), ).toHaveCount(0); // ===== Services panel ===== // page.goto that only changes the URL hash on the same path does // NOT trigger a full reload, and the admin page reads `?section=` // only on mount — pair every goto with reload() to re-apply it. await page.goto("/admin#section=services"); await page.reload(); const serviceCounts = page.locator( `[data-testid="service-counts-${serviceWithOrders.id}"]`, ); await expect(serviceCounts).toBeVisible({ timeout: 10_000 }); const ordersChip = page.locator( `[data-testid="service-counts-orders-${serviceWithOrders.id}"]`, ); await expect(ordersChip).toBeVisible(); // Two orders ride on this service (one from the admin and one // from `userWithDeps`), so just assert the chip is non-empty and // ends in the localized noun rather than pinning the exact count. await expect(ordersChip).toContainText(labels.serviceOrders); await expect(ordersChip).toContainText(/[1-9]/); // The bare service: row present (delete button), counts row absent. await expect( page.locator(`[data-testid="service-delete-${serviceNoOrders.id}"]`), ).toBeVisible(); await expect( page.locator(`[data-testid="service-counts-${serviceNoOrders.id}"]`), ).toHaveCount(0); // ===== Users panel ===== await page.goto("/admin#section=users"); await page.reload(); // Filter by username so the test rows are guaranteed to render. const search = page.locator('[data-testid="user-search"]'); await expect(search).toBeVisible({ timeout: 10_000 }); // The two test users share the prefix `u_` — search by that to // narrow the list to just our created rows. await search.fill("u_"); // Wait for the row with deps to appear. The counts container is // the assertion target; if it's visible the row obviously is too. const userCounts = page.locator( `[data-testid="user-counts-${userWithDeps.id}"]`, ); await expect(userCounts).toBeVisible({ timeout: 10_000 }); const notesChip = page.locator( `[data-testid="user-counts-notes-${userWithDeps.id}"]`, ); const userOrdersChip = page.locator( `[data-testid="user-counts-orders-${userWithDeps.id}"]`, ); await expect(notesChip).toBeVisible(); await expect(userOrdersChip).toBeVisible(); await expect(notesChip).toContainText("1"); await expect(notesChip).toContainText(labels.userNotes); await expect(userOrdersChip).toContainText("1"); await expect(userOrdersChip).toContainText(labels.userOrders); // The bare user: row present (delete button), counts row absent. await expect( page.locator(`[data-testid="user-delete-${userNoDeps.id}"]`), ).toBeVisible(); await expect( page.locator(`[data-testid="user-counts-${userNoDeps.id}"]`), ).toHaveCount(0); // ---- Bilingual layout sanity check ---- // The chip row uses `flex flex-wrap` and inherits text direction // from . Verify the document direction matches the // selected language so a future regression that hard-codes LTR // would fail here. Also assert the chip row itself is laid out as // a flex container with non-zero width in the active direction. const expectedDir = lang === "ar" ? "rtl" : "ltr"; await expect(page.locator("html")).toHaveAttribute("dir", expectedDir); const counts = await userCounts.evaluate((el) => { const cs = window.getComputedStyle(el); return { display: cs.display, direction: cs.direction, width: el.getBoundingClientRect().width, }; }); expect(counts.display).toBe("flex"); expect(counts.direction).toBe(expectedDir); expect(counts.width).toBeGreaterThan(0); }); }); }