diff --git a/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs b/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs new file mode 100644 index 00000000..d9e4ff04 --- /dev/null +++ b/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs @@ -0,0 +1,453 @@ +// 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], + ); +} + +async function createConversationWithMessage(creatorId) { + const { rows } = await pool.query( + `INSERT INTO conversations (created_by, name_en) VALUES ($1, 'inline counts test') RETURNING id`, + [creatorId], + ); + const conversationId = rows[0].id; + await pool.query( + `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`, + [conversationId, creatorId], + ); + return conversationId; +} + +test.afterAll(async () => { + // Order matters: messages / conversations / notes / service_orders + // must go before users (sender_id and created_by have 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 messages WHERE sender_id = ANY($1::int[])`, + [createdUserIds], + ); + await pool.query( + `DELETE FROM conversations WHERE created_by = ANY($1::int[])`, + [createdUserIds], + ); + 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", + userConversations: "convos", + userMessages: "messages", + }, + ar: { + appGroups: "مجموعة", + appRestrictions: "قيد", + appOpens: "فتحة", + serviceOrders: "طلب", + userNotes: "ملاحظة", + userOrders: "طلب", + userConversations: "محادثة", + userMessages: "رسالة", + }, +}; + +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); + await createConversationWithMessage(userWithDeps.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}"]`, + ); + const convChip = page.locator( + `[data-testid="user-counts-conversations-${userWithDeps.id}"]`, + ); + const msgChip = page.locator( + `[data-testid="user-counts-messages-${userWithDeps.id}"]`, + ); + await expect(notesChip).toBeVisible(); + await expect(userOrdersChip).toBeVisible(); + await expect(convChip).toBeVisible(); + await expect(msgChip).toBeVisible(); + await expect(notesChip).toContainText("1"); + await expect(notesChip).toContainText(labels.userNotes); + await expect(userOrdersChip).toContainText("1"); + await expect(userOrdersChip).toContainText(labels.userOrders); + await expect(convChip).toContainText("1"); + await expect(convChip).toContainText(labels.userConversations); + await expect(msgChip).toContainText("1"); + await expect(msgChip).toContainText(labels.userMessages); + + // 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); + }); + }); +}