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 createdUserIds = []; const createdConversationIds = []; async function createUser(prefix) { const username = `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Leave Test', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const id = rows[0].id; createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return { id, username }; } 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"); assert.ok(setCookie, "expected Set-Cookie header from login"); const sid = setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); assert.ok(sid, "expected connect.sid cookie"); return sid; } async function createGroup(creatorId, members) { const { rows } = await pool.query( `INSERT INTO conversations (name_en, is_group, created_by) VALUES ('Leave Test Group', true, $1) RETURNING id`, [creatorId], ); const conversationId = rows[0].id; createdConversationIds.push(conversationId); // Insert participants in a deterministic joined_at order so that auto // promotion picks the expected user. let offsetMs = 0; for (const m of members) { await pool.query( `INSERT INTO conversation_participants (conversation_id, user_id, is_admin, joined_at) VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`, [conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs], ); offsetMs += 1000; } return conversationId; } async function getParticipants(conversationId) { const { rows } = await pool.query( `SELECT user_id AS "userId", is_admin AS "isAdmin" FROM conversation_participants WHERE conversation_id = $1 ORDER BY joined_at ASC`, [conversationId], ); return rows; } async function conversationExists(conversationId) { const { rows } = await pool.query( `SELECT 1 FROM conversations WHERE id = $1`, [conversationId], ); return rows.length > 0; } async function leave(conversationId, cookie, body) { const res = await fetch( `${API_BASE}/api/conversations/${conversationId}/leave`, { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json", }, body: JSON.stringify(body ?? {}), }, ); if (process.env.LEAVE_TEST_DEBUG && res.status >= 400) { const text = await res.clone().text(); console.error( `[leave debug] status=${res.status} cookie=${cookie} body=${text}`, ); } return res; } after(async () => { if (createdConversationIds.length > 0) { await pool.query( `DELETE FROM messages WHERE conversation_id = ANY($1::int[])`, [createdConversationIds], ); await pool.query( `DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`, [createdConversationIds], ); await pool.query( `DELETE FROM conversations WHERE id = ANY($1::int[])`, [createdConversationIds], ); } if (createdUserIds.length > 0) { await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ createdUserIds, ]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); test("solo member leaving a group deletes the conversation", async () => { const solo = await createUser("leave_solo"); const conv = await createGroup(solo.id, [{ userId: solo.id, isAdmin: true }]); const cookie = await loginAndGetCookie(solo.username, TEST_PASSWORD); const res = await leave(conv, cookie); assert.equal(res.status, 200); const json = await res.json(); assert.equal(json.success, true); assert.equal( await conversationExists(conv), false, "conversation should be deleted when last member leaves", ); }); test("sole admin leaving auto-promotes the earliest remaining member", async () => { const admin = await createUser("leave_admin_auto"); const m1 = await createUser("leave_m1"); const m2 = await createUser("leave_m2"); // admin joined first, then m1, then m2 (handled by createGroup ordering) const conv = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, { userId: m2.id, isAdmin: false }, ]); const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD); const res = await leave(conv, cookie); assert.equal(res.status, 200); const parts = await getParticipants(conv); assert.equal(parts.length, 2); assert.ok( !parts.find((p) => p.userId === admin.id), "leaver must be removed", ); const promoted = parts.find((p) => p.isAdmin); assert.ok(promoted, "an admin should have been auto-promoted"); assert.equal( promoted.userId, m1.id, "earliest joined remaining member should be promoted", ); }); test("sole admin leaving with a chosen successor promotes that user", async () => { const admin = await createUser("leave_admin_chosen"); const m1 = await createUser("leave_chosen_m1"); const m2 = await createUser("leave_chosen_m2"); const conv = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, { userId: m2.id, isAdmin: false }, ]); const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD); const res = await leave(conv, cookie, { successorId: m2.id }); assert.equal(res.status, 200); const parts = await getParticipants(conv); assert.equal(parts.length, 2); const promoted = parts.find((p) => p.isAdmin); assert.ok(promoted, "an admin should be present after handoff"); assert.equal( promoted.userId, m2.id, "the chosen successor should be the new admin", ); }); test("sole admin leaving with an invalid successor returns 400 and does not leave", async () => { const admin = await createUser("leave_admin_bad"); const m1 = await createUser("leave_bad_m1"); const outsider = await createUser("leave_bad_outsider"); const conv = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, ]); const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD); const res = await leave(conv, cookie, { successorId: outsider.id }); assert.equal(res.status, 400); const parts = await getParticipants(conv); assert.equal(parts.length, 2, "membership should be unchanged"); const adminRow = parts.find((p) => p.userId === admin.id); assert.ok(adminRow, "leaver should still be a member"); assert.equal(adminRow.isAdmin, true, "leaver should still be admin"); const m1Row = parts.find((p) => p.userId === m1.id); assert.equal(m1Row.isAdmin, false, "m1 should not have been promoted"); }); test("non-admin leaving a group does not promote anyone", async () => { const admin = await createUser("leave_keep_admin"); const m1 = await createUser("leave_nonadmin_leaver"); const m2 = await createUser("leave_nonadmin_other"); const conv = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, { userId: m2.id, isAdmin: false }, ]); const cookie = await loginAndGetCookie(m1.username, TEST_PASSWORD); const res = await leave(conv, cookie); assert.equal(res.status, 200); const parts = await getParticipants(conv); assert.equal(parts.length, 2); assert.ok( !parts.find((p) => p.userId === m1.id), "leaver must be removed", ); const admins = parts.filter((p) => p.isAdmin); assert.equal(admins.length, 1, "exactly the original admin remains admin"); assert.equal(admins[0].userId, admin.id); const m2Row = parts.find((p) => p.userId === m2.id); assert.equal(m2Row.isAdmin, false, "non-admin member should not be promoted"); });