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 SVC_STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const SVC_PREFIX = `TestSvc_${SVC_STAMP}_`; let adminId; let adminUsername; let adminCookie; let createdUserIds = []; let createdAppIds = []; let createdServiceIds = []; let createdGroupIds = []; 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 stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; adminUsername = `del_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, 'Delete 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], ); await pool.query( `INSERT INTO user_groups (user_id, group_id) SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins') ON CONFLICT DO NOTHING`, [adminId], ); adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); }); after(async () => { if (createdServiceIds.length) { 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, ]); } // Defensive sweep: kill any leftover services whose name_en matches the // per-run test prefix, plus their child rows in service_orders. Mirrors // the apps-side sweep added in. const svcSweep = await pool.query( `SELECT id FROM services WHERE name_en LIKE $1`, [`${SVC_PREFIX}%`], ); if (svcSweep.rowCount > 0) { const svcIds = svcSweep.rows.map((r) => r.id); await pool.query( `DELETE FROM service_orders WHERE service_id = ANY($1::int[])`, [svcIds], ); await pool.query(`DELETE FROM services WHERE id = ANY($1::int[])`, [ svcIds, ]); } if (createdAppIds.length) { 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 user_app_orders WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ createdAppIds, ]); } // Defensive sweep: kill any leftover apps whose slug matches a test prefix // (clean_app_*, busy_app_*, force_app_*). This guards against the case // where a test crashes between INSERT and the createdAppIds.push above. const sweep = await pool.query( `SELECT id FROM apps WHERE slug LIKE 'clean_app_%' OR slug LIKE 'busy_app_%' OR slug LIKE 'force_app_%'`, ); if (sweep.rowCount > 0) { const ids = sweep.rows.map((r) => r.id); await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [ids]); await pool.query(`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [ids]); await pool.query(`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [ids]); await pool.query(`DELETE FROM user_app_orders WHERE app_id = ANY($1::int[])`, [ids]); await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ids]); } if (createdGroupIds.length) { 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, ]); } for (const uid of [adminId, ...createdUserIds]) { if (uid !== undefined) { await pool.query(`DELETE FROM notes WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); } } await pool.end(); }); // ---------- Users ---------- test("DELETE /api/users/:id without dependents succeeds (no force needed)", async () => { const username = `del_clean_${Date.now().toString(36)}`; const r = 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`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const uid = r.rows[0].id; const res = await fetch(`${API_BASE}/api/users/${uid}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]); assert.equal(check.rowCount, 0); }); test("DELETE /api/users/:id with dependents returns 409 with counts", async () => { const username = `del_busy_${Date.now().toString(36)}`; const r = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Busy User', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const uid = r.rows[0].id; createdUserIds.push(uid); await pool.query( `INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`, [uid], ); const res = await fetch(`${API_BASE}/api/users/${uid}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const body = await res.json(); assert.equal(body.noteCount, 2); const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [ uid, ]); assert.equal(stillThere.rowCount, 1); }); test("DELETE /api/users/:id?force=true deletes user and writes audit log", async () => { const username = `del_force_${Date.now().toString(36)}`; const r = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Force User', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const uid = r.rows[0].id; await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [ uid, ]); const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]); assert.equal(check.rowCount, 0); const audit = await pool.query( `SELECT * FROM audit_logs WHERE action = ANY($1) AND target_id = $2`, [["user.delete", "user.force_delete"], uid], ); assert.equal(audit.rowCount, 1); assert.equal(audit.rows[0].actor_user_id, adminId); }); // ---------- Apps ---------- test("DELETE /api/apps/:id without dependents succeeds (no force needed)", async () => { const slug = `clean_app_${Date.now().toString(36)}`; const a = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, sort_order) VALUES ($1, 'تطبيق', 'App Clean', '/x', 999) RETURNING id`, [slug], ); const id = a.rows[0].id; createdAppIds.push(id); const res = await fetch(`${API_BASE}/api/apps/${id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); }); test("DELETE /api/apps/:id with dependents returns 409 with counts", async () => { const slug = `busy_app_${Date.now().toString(36)}`; const a = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, sort_order) VALUES ($1, 'تطبيق', 'App Busy', '/y', 999) RETURNING id`, [slug], ); const appId = a.rows[0].id; createdAppIds.push(appId); const g = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`, [`gApp_${Date.now().toString(36)}`], ); const groupId = g.rows[0].id; createdGroupIds.push(groupId); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [groupId, appId], ); await pool.query( `INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2), ($1, $2)`, [adminId, appId], ); const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const body = await res.json(); assert.equal(body.groupCount, 1); assert.equal(body.openCount, 2); const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [ appId, ]); assert.equal(stillThere.rowCount, 1); }); test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async () => { const slug = `force_app_${Date.now().toString(36)}`; const a = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, sort_order) VALUES ($1, 'تطبيق', 'App Force', '/z', 999) RETURNING id`, [slug], ); const appId = a.rows[0].id; createdAppIds.push(appId); const g = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`, [`gAppF_${Date.now().toString(36)}`], ); const groupId = g.rows[0].id; createdGroupIds.push(groupId); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [groupId, appId], ); await pool.query( `INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`, [adminId, appId], ); const res = await fetch(`${API_BASE}/api/apps/${appId}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const check = await pool.query(`SELECT id FROM apps WHERE id = $1`, [appId]); assert.equal(check.rowCount, 0); const audit = await pool.query( `SELECT * FROM audit_logs WHERE action = ANY($1) AND target_id = $2`, [["app.delete", "app.force_delete"], String(appId)], ); assert.equal(audit.rowCount, 1); }); // ---------- Services ---------- test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => { const s = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`, [`${SVC_PREFIX}CleanAr`, `${SVC_PREFIX}Clean`], ); const id = s.rows[0].id; createdServiceIds.push(id); const res = await fetch(`${API_BASE}/api/services/${id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); // #195: plain (no-deps) deletes now write a `service.delete` audit row // carrying both nameEn and nameAr so the audit log can render the service // name in either locale even after the row is gone. const audit = await pool.query( `SELECT action, metadata FROM audit_logs WHERE target_type = 'service' AND target_id = $1`, [String(id)], ); assert.equal(audit.rowCount, 1); assert.equal(audit.rows[0].action, "service.delete"); assert.equal(audit.rows[0].metadata.nameEn, `${SVC_PREFIX}Clean`); assert.equal(audit.rows[0].metadata.nameAr, `${SVC_PREFIX}CleanAr`); }); test("DELETE /api/services/:id?force=true with no dependents writes service.delete (not service.force_delete)", async () => { // #195: the `force` query has no effect when there are no dependents, so // the plain `service.delete` row is correct here. `service.force_delete` // is reserved for the path that actually had to clear dependent orders. const s = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.50, true) RETURNING id`, [`${SVC_PREFIX}ForceCleanAr`, `${SVC_PREFIX}ForceClean`], ); const id = s.rows[0].id; createdServiceIds.push(id); const res = await fetch(`${API_BASE}/api/services/${id}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const audit = await pool.query( `SELECT action FROM audit_logs WHERE target_type = 'service' AND target_id = $1`, [String(id)], ); assert.equal(audit.rowCount, 1); assert.equal(audit.rows[0].action, "service.delete"); }); test("DELETE /api/services/:id with orders returns 409 with counts", async () => { const s = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 2.00, true) RETURNING id`, [`${SVC_PREFIX}Busy`], ); const sid = s.rows[0].id; createdServiceIds.push(sid); await pool.query( `INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending'), ($1, $2, 'pending'), ($1, $2, 'completed')`, [adminId, sid], ); const res = await fetch(`${API_BASE}/api/services/${sid}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const body = await res.json(); assert.equal(body.orderCount, 3); const stillThere = await pool.query(`SELECT id FROM services WHERE id = $1`, [ sid, ]); assert.equal(stillThere.rowCount, 1); }); test("DELETE /api/services/:id?force=true deletes service + orders and writes audit log", async () => { const s = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 3.00, true) RETURNING id`, [`${SVC_PREFIX}Force`], ); const sid = s.rows[0].id; createdServiceIds.push(sid); await pool.query( `INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending')`, [adminId, sid], ); const res = await fetch(`${API_BASE}/api/services/${sid}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const check = await pool.query(`SELECT id FROM services WHERE id = $1`, [ sid, ]); assert.equal(check.rowCount, 0); const ordersGone = await pool.query( `SELECT id FROM service_orders WHERE service_id = $1`, [sid], ); assert.equal(ordersGone.rowCount, 0); const audit = await pool.query( `SELECT * FROM audit_logs WHERE action = 'service.force_delete' AND target_id = $1`, [String(sid)], ); assert.equal(audit.rowCount, 1); });