Files
TX/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs
T
riyadhafraa 84398de390 Task #511: Fully remove Chat feature
Destructive removal per user confirmation ("حذف نهائي ما يرجع").

Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
  socket handlers in src/index.ts, /admin/users/:id/dependents/
  conversations+messages endpoints, conversation/message dependency
  counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
  icon and messages StatCard on home, all chat-related UI in
  notifications + admin (dependency badges, delete-dialog rows,
  UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
  admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
  admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
  paths, related schemas (ConversationWithDetails, MessageWithSender,
  UserDependentConversation/MessageItem+Page, etc.), UserProfile and
  UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
  Regenerated client via orval.
- Database: dropped message_reads, messages,
  conversation_participants, conversations (CASCADE); deleted
  notifications with related_type='conversation' or type='chat';
  deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
  seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
  list-dependency-counts, delete-force-warnings, audit-log-coverage,
  and admin-inline-dependency-counts (e2e) — replaced chat dependents
  with note dependents where needed for force-delete coverage.

Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.

executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
2026-05-12 10:51:31 +00:00

415 lines
14 KiB
JavaScript

// 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],
);
}
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 <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);
});
});
}