0cee551f60
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.
448 lines
15 KiB
JavaScript
448 lines
15 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 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);
|
|
});
|