c0cf2115dd
Goal: make the admin delete dialog show its dependency warning on
the FIRST click (matching the existing Groups UX), instead of
needing a 409 round-trip from the DELETE endpoint to populate the
warning.
Changes:
- lib/api-spec/openapi.yaml: added optional count fields to App
(groupCount, restrictionCount, openCount), Service (orderCount),
and UserProfile (noteCount, orderCount, conversationCount,
messageCount).
- artifacts/api-server/src/routes/apps.ts: GET /admin/apps now
batch-loads groupCount/restrictionCount/openCount via grouped
COUNT queries on group_apps, app_permissions, and app_opens.
- artifacts/api-server/src/routes/services.ts: GET /services now
batch-loads orderCount from service_orders.
- artifacts/api-server/src/routes/users.ts: GET /users now batch-
loads noteCount/orderCount/conversationCount/messageCount from
notes, service_orders, conversations.created_by, and
messages.sender_id.
- artifacts/tx-os/src/pages/admin.tsx: Apps, Services, and Users
delete buttons pre-populate their conflict-state from the row's
count fields so the DeletionWarningDialog shows the warning on
the first click. The lazy 409 fallback still works as a safety
net for any caller without counts.
- replit.md: documented the new admin-panel delete UX.
Tests:
- Added artifacts/api-server/tests/list-dependency-counts.test.mjs
with 6 tests verifying each list endpoint exposes the new count
fields with the expected non-zero values when dependents exist
AND zero values when they do not (apps, services, users).
- Existing artifacts/api-server/tests/delete-force-warnings.test.mjs
(9 tests) still passes — DELETE behavior unchanged.
- Verified end-to-end with a Playwright browser test: clicking the
service delete icon ONCE opens the confirmation modal with the
dependency warning ("orders", count >= 1) immediately.
Notes / drift:
- Count fields were added to the shared App/Service/UserProfile
schemas (not list-only sub-schemas) so the same shape is returned
from list and detail endpoints. Code review flagged this as
acceptable but broader than strictly necessary; left as is to
keep the API consistent.
Out of scope (already pre-existing on main, tracked as follow-ups):
- artifacts/api-server/tests/apps-open.test.mjs has a syntax error.
- artifacts/api-server/tests/app-permissions-unique.test.mjs has
two failing assertions about the composite primary key.
- artifacts/api-server/src/routes/executive-meetings.ts has 3 pre-
existing typecheck errors around the font-settings scope column.
- The `test` workflow exits with ECONNREFUSED when the api-server
isn't already up — needs a readiness check.
Replit-Task-Id: bd14fe73-9961-431b-ab5a-ab70f116e8c7
257 lines
9.6 KiB
JavaScript
257 lines
9.6 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error("DATABASE_URL must be set to run these tests");
|
|
}
|
|
|
|
const TEST_PASSWORD = "TestPass123!";
|
|
const TEST_PASSWORD_HASH =
|
|
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
|
|
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
let adminId;
|
|
let adminCookie;
|
|
let depUserId;
|
|
let depAppId;
|
|
let depServiceId;
|
|
let depGroupId;
|
|
let depPermissionId;
|
|
let depConvId;
|
|
|
|
async function loginAndGetCookie(username, password) {
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
|
const setCookie = res.headers.get("set-cookie");
|
|
return setCookie
|
|
.split(",")
|
|
.map((c) => c.split(";")[0].trim())
|
|
.find((c) => c.startsWith("connect.sid="));
|
|
}
|
|
|
|
before(async () => {
|
|
const adminUsername = `dep_admin_${STAMP}`;
|
|
const a = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Dep Admin', 'en', true) RETURNING id`,
|
|
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
adminId = a.rows[0].id;
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[adminId],
|
|
);
|
|
|
|
const u = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Dep User', 'en', true) RETURNING id`,
|
|
[`dep_user_${STAMP}`, `dep_user_${STAMP}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
depUserId = u.rows[0].id;
|
|
|
|
const app = await pool.query(
|
|
`INSERT INTO apps (slug, name_ar, name_en, route, color, icon_name)
|
|
VALUES ($1, $2, $3, '/dep', '#000', 'Box') RETURNING id`,
|
|
[`dep-app-${STAMP}`, `DepApp_${STAMP}`, `DepApp_${STAMP}`],
|
|
);
|
|
depAppId = app.rows[0].id;
|
|
|
|
const svc = await pool.query(
|
|
`INSERT INTO services (name_ar, name_en, description_ar, description_en)
|
|
VALUES ($1, $2, 'desc', 'desc') RETURNING id`,
|
|
[`DepSvc_${STAMP}`, `DepSvc_${STAMP}`],
|
|
);
|
|
depServiceId = svc.rows[0].id;
|
|
|
|
const grp = await pool.query(
|
|
`INSERT INTO groups (name, description_en) VALUES ($1, 'dep grp') RETURNING id`,
|
|
[`DepGrp_${STAMP}`],
|
|
);
|
|
depGroupId = grp.rows[0].id;
|
|
|
|
// Create a custom permission to attach as an app restriction.
|
|
const perm = await pool.query(
|
|
`INSERT INTO permissions (name, description_en) VALUES ($1, 'dep perm') RETURNING id`,
|
|
[`dep_perm_${STAMP}`],
|
|
);
|
|
depPermissionId = perm.rows[0].id;
|
|
|
|
// Dependents on the user: a note, an order, a conversation, a message.
|
|
await pool.query(
|
|
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
|
|
[depUserId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO service_orders (service_id, user_id, status, notes)
|
|
VALUES ($1, $2, 'pending', 'dep order')`,
|
|
[depServiceId, depUserId],
|
|
);
|
|
const conv = await pool.query(
|
|
`INSERT INTO conversations (is_group, name_en, created_by)
|
|
VALUES (true, 'dep conv', $1) RETURNING id`,
|
|
[depUserId],
|
|
);
|
|
depConvId = conv.rows[0].id;
|
|
await pool.query(
|
|
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
|
|
[depConvId, depUserId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
|
[depConvId, depUserId],
|
|
);
|
|
|
|
// App dependents: group link, permission restriction, an open record.
|
|
await pool.query(
|
|
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
|
[depGroupId, depAppId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
|
[depAppId, depPermissionId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO app_opens (app_id, user_id) VALUES ($1, $2)`,
|
|
[depAppId, depUserId],
|
|
);
|
|
|
|
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
|
});
|
|
|
|
after(async () => {
|
|
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [depUserId]);
|
|
await pool.query(`DELETE FROM conversation_participants WHERE user_id = $1`, [depUserId]);
|
|
await pool.query(`DELETE FROM conversations WHERE id = $1`, [depConvId]);
|
|
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [depUserId]);
|
|
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [depUserId]);
|
|
await pool.query(`DELETE FROM app_opens WHERE app_id = $1`, [depAppId]);
|
|
await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [depAppId]);
|
|
await pool.query(`DELETE FROM group_apps WHERE app_id = $1`, [depAppId]);
|
|
await pool.query(`DELETE FROM permissions WHERE id = $1`, [depPermissionId]);
|
|
await pool.query(`DELETE FROM apps WHERE id = $1`, [depAppId]);
|
|
await pool.query(`DELETE FROM services WHERE id = $1`, [depServiceId]);
|
|
await pool.query(`DELETE FROM groups WHERE id = $1`, [depGroupId]);
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [[adminId, depUserId]]);
|
|
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [[adminId, depUserId]]);
|
|
await pool.end();
|
|
});
|
|
|
|
test("GET /api/admin/apps exposes groupCount/restrictionCount/openCount", async () => {
|
|
const res = await fetch(`${API_BASE}/api/admin/apps`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const apps = await res.json();
|
|
const target = apps.find((a) => a.id === depAppId);
|
|
assert.ok(target, "expected dep app in admin apps list");
|
|
assert.equal(target.groupCount, 1, "groupCount should be 1");
|
|
assert.equal(target.restrictionCount, 1, "restrictionCount should be 1");
|
|
assert.equal(target.openCount, 1, "openCount should be 1");
|
|
});
|
|
|
|
test("GET /api/services exposes orderCount", async () => {
|
|
const res = await fetch(`${API_BASE}/api/services`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const services = await res.json();
|
|
const target = services.find((s) => s.id === depServiceId);
|
|
assert.ok(target, "expected dep service in services list");
|
|
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
|
});
|
|
|
|
test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount", async () => {
|
|
const res = await fetch(`${API_BASE}/api/users`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const users = await res.json();
|
|
const target = users.find((u) => u.id === depUserId);
|
|
assert.ok(target, "expected dep user in users list");
|
|
assert.equal(target.noteCount, 1, "noteCount should be 1");
|
|
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
|
assert.equal(target.conversationCount, 1, "conversationCount should be 1");
|
|
assert.equal(target.messageCount, 1, "messageCount should be 1");
|
|
});
|
|
|
|
test("Apps list returns zero counts for entities without dependents", async () => {
|
|
const cleanApp = await pool.query(
|
|
`INSERT INTO apps (slug, name_ar, name_en, route, color, icon_name)
|
|
VALUES ($1, $2, $3, '/clean', '#000', 'Box') RETURNING id`,
|
|
[`clean-app-${STAMP}`, `CleanApp_${STAMP}`, `CleanApp_${STAMP}`],
|
|
);
|
|
const cleanAppId = cleanApp.rows[0].id;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/admin/apps`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const apps = await res.json();
|
|
const target = apps.find((a) => a.id === cleanAppId);
|
|
assert.ok(target, "expected clean app in admin apps list");
|
|
assert.equal(target.groupCount, 0);
|
|
assert.equal(target.restrictionCount, 0);
|
|
assert.equal(target.openCount, 0);
|
|
} finally {
|
|
await pool.query(`DELETE FROM apps WHERE id = $1`, [cleanAppId]);
|
|
}
|
|
});
|
|
|
|
test("Services list returns zero orderCount for services without orders", async () => {
|
|
const cleanSvc = await pool.query(
|
|
`INSERT INTO services (name_ar, name_en, description_ar, description_en)
|
|
VALUES ($1, $2, 'd', 'd') RETURNING id`,
|
|
[`CleanSvc_${STAMP}`, `CleanSvc_${STAMP}`],
|
|
);
|
|
const cleanSvcId = cleanSvc.rows[0].id;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/services`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const services = await res.json();
|
|
const target = services.find((s) => s.id === cleanSvcId);
|
|
assert.ok(target, "expected clean service in services list");
|
|
assert.equal(target.orderCount, 0);
|
|
} finally {
|
|
await pool.query(`DELETE FROM services WHERE id = $1`, [cleanSvcId]);
|
|
}
|
|
});
|
|
|
|
test("Users list returns zero counts for users without dependents", async () => {
|
|
const cleanUser = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Clean User', 'en', true) RETURNING id`,
|
|
[`clean_user_${STAMP}`, `clean_user_${STAMP}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
const cleanUserId = cleanUser.rows[0].id;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/users`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const users = await res.json();
|
|
const target = users.find((u) => u.id === cleanUserId);
|
|
assert.ok(target, "expected clean user in users list");
|
|
assert.equal(target.noteCount, 0);
|
|
assert.equal(target.orderCount, 0);
|
|
assert.equal(target.conversationCount, 0);
|
|
assert.equal(target.messageCount, 0);
|
|
} finally {
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
|
|
}
|
|
});
|