84398de390
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.
415 lines
14 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|