Add browser tests for the inline dependency counts on the admin lists

Task #184. The admin Apps / Services / Users panels render small
testid'd chips under each row that summarize how many dependent
records reference the entity. Until now, that behavior had no
regression coverage, which made it easy to silently drop the chips
in a future refactor.

Adds artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs.
The spec seeds, per locale (en + ar):
  - One app with every dep kind populated (groups + restrictions +
    opens) and one app with none.
  - One service with orders and one service with none.
  - One user with notes / orders / conversations / messages and one
    bare user.
Asserts that:
  - The container `[data-testid="<entity>-counts-<id>"]` and each
    populated child chip render with the localized count text.
  - The container is omitted entirely (count = 0 in DOM) when the
    entity has zero dependents — anchored by the row's delete button
    so we know the row itself rendered.
  - <html dir> matches the locale (rtl in Arabic, ltr in English) and
    the chip row's computed flex direction follows it, so a future
    hard-coded LTR regression would fail.

Two implementation notes worth flagging:
  - Switching admin sections via `page.goto` only changes the URL
    hash on the same path, which does NOT trigger a reload, and the
    admin page only reads `?section=` once on mount. The spec pairs
    each goto with `page.reload()` so the section actually re-applies.
  - The userWithDeps order rides on serviceWithOrders (instead of
    serviceNoOrders) so the bare service truly has zero orders. As a
    side effect serviceWithOrders has 2 orders, so the chip text
    assertion only checks the localized noun and a non-zero digit
    rather than pinning the exact count.

Cleanup runs in afterAll: deletes messages / conversations / notes /
service_orders before users (sender_id and created_by have no ON
DELETE rule) and service_orders before services (RESTRICT on
service_id), matching the constraints in lib/db/src/schema.

Verified locally: both new tests pass and the existing admin specs
(admin-create-group-app-visibility, admin-user-edit-review en/ar)
still pass.

Cleanup vs the previous attempt: removed an ad-hoc tests/_debug.mjs
that was used to reproduce the hash-only-navigation bug and reverted
artifacts/tx-os/public/opengraph.jpg, which had been touched by an
unrelated incidental rebuild during local testing.

Replit-Task-Id: 8a1f407d-2a22-4e70-b8ad-25ff7d9b0dae
This commit is contained in:
riyadhafraa
2026-05-01 08:40:42 +00:00
parent efe74f150a
commit 53d13a5939
@@ -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^="<entity>-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 <html dir>. 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);
});
});
}