8fb6d54d78
Adds artifacts/api-server/tests/conversations-leave.test.mjs, modeled on the existing apps-open.test.mjs, covering POST /conversations/:id/leave: - Solo member leaving deletes the conversation entirely. - Sole admin leaving with no successor auto-promotes the earliest-joined remaining member. - Sole admin leaving with a chosen successorId promotes that user. - Sole admin leaving with a non-member successorId returns 400 and leaves the group untouched (leaver still admin, no promotion). - Non-admin leaving a group removes them with no admin promotion. Tests create their own users (with the standard user role) and groups directly in Postgres so joined_at ordering is deterministic for the auto-promotion case, then exercise the route through HTTP using a real session cookie obtained from POST /api/auth/login. An after() hook cleans up all created conversations, participants, messages, role assignments, and users. The optional e2e for the chooser dialog is intentionally deferred and proposed as follow-up #51. Verified by running `pnpm --filter @workspace/api-server test` three times consecutively; all 15 tests pass on every run. Replit-Task-Id: e31c169d-a4f5-4387-a642-b39a422c1408
266 lines
8.7 KiB
JavaScript
266 lines
8.7 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 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");
|
|
});
|