// UI test for the leave-group successor picker dialog in chat.tsx. // // Mirrors the backend coverage in // artifacts/api-server/tests/conversations-leave.test.mjs but at the UI layer: // it drives the actual dialog rendered by artifacts/tx-os/src/pages/chat.tsx // in a real browser, including: // - The cancel path (close the dialog -> nothing changes). // - The chosen-successor path (pick a specific member -> they become admin). // // Run with: // DATABASE_URL=... pnpm --filter @workspace/tx-os exec playwright test // // Browser binaries can be installed once with: // pnpm --filter @workspace/tx-os exec playwright install chromium // // The test seeds its own users + group via the database and cleans up after // itself, so it can run against the live dev environment without polluting it. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run the leave-group UI test"); } // Same convention as artifacts/api-server/tests/conversations-leave.test.mjs: // a precomputed bcrypt hash of the literal password "TestPass123!". 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 = []; function uniqueSuffix() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix) { const username = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], ); 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, displayName: prefix }; } async function createGroup(creatorId, members) { const groupName = `Successor Picker Test ${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO conversations (name_en, is_group, created_by) VALUES ($1, true, $2) RETURNING id`, [groupName, creatorId], ); const conversationId = rows[0].id; createdConversationIds.push(conversationId); // Insert participants in deterministic joined_at order so that the auto // promotion would pick the *first* non-admin member. The test then verifies // that an explicit successor choice can override that default. 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, groupName }; } 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; } test.afterAll(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(); }); async function loginViaUi(page, username, password) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(password); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } async function openLeaveDialog(page, groupName) { await page.goto("/chat"); // Wait for the conversation list to load and click the seeded group. await page.getByRole("button", { name: new RegExp(groupName) }).first().click(); // Open the actions sheet, then "Leave group". await page.locator('[data-testid="button-chat-actions"]').click(); await expect(page.locator('[data-testid="dialog-chat-actions"]')).toBeVisible(); await page.locator('[data-testid="button-leave-group"]').click(); await expect(page.locator('[data-testid="dialog-confirm-leave"]')).toBeVisible(); await expect(page.locator('[data-testid="successor-chooser"]')).toBeVisible(); } test.describe("Leave group: successor picker dialog", () => { test("cancel path leaves the group and admin assignments unchanged", async ({ page, }) => { const admin = await createUser("leave_ui_cancel_admin"); const m1 = await createUser("leave_ui_cancel_m1"); const m2 = await createUser("leave_ui_cancel_m2"); const { conversationId, groupName } = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, { userId: m2.id, isAdmin: false }, ]); await loginViaUi(page, admin.username, TEST_PASSWORD); await openLeaveDialog(page, groupName); // Auto option is selected by default; both successor options are listed. await expect( page.locator('[data-testid="successor-option-auto"] input[type="radio"]'), ).toBeChecked(); await expect( page.locator(`[data-testid="successor-option-${m1.id}"]`), ).toBeVisible(); await expect( page.locator(`[data-testid="successor-option-${m2.id}"]`), ).toBeVisible(); // Click the Cancel button inside the leave dialog. await page .locator('[data-testid="dialog-confirm-leave"] button') .filter({ hasText: /^(Cancel|إلغاء)$/ }) .click(); await expect( page.locator('[data-testid="dialog-confirm-leave"]'), ).toBeHidden(); // Membership and admin flags must be unchanged after a cancel. const parts = await getParticipants(conversationId); expect(parts).toHaveLength(3); const adminRow = parts.find((p) => p.userId === admin.id); const m1Row = parts.find((p) => p.userId === m1.id); const m2Row = parts.find((p) => p.userId === m2.id); expect(adminRow?.isAdmin).toBe(true); expect(m1Row?.isAdmin).toBe(false); expect(m2Row?.isAdmin).toBe(false); }); test("picking a specific successor promotes that member, not the auto pick", async ({ page, }) => { const admin = await createUser("leave_ui_pick_admin"); const m1 = await createUser("leave_ui_pick_m1"); // would be the auto pick const m2 = await createUser("leave_ui_pick_m2"); // explicitly chosen const { conversationId, groupName } = await createGroup(admin.id, [ { userId: admin.id, isAdmin: true }, { userId: m1.id, isAdmin: false }, { userId: m2.id, isAdmin: false }, ]); await loginViaUi(page, admin.username, TEST_PASSWORD); await openLeaveDialog(page, groupName); // Pick the later-joined member (m2) as the successor. await page .locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`) .check(); await expect( page.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`), ).toBeChecked(); await expect( page.locator('[data-testid="successor-option-auto"] input[type="radio"]'), ).not.toBeChecked(); // Confirm the leave. const leavePromise = page.waitForResponse( (resp) => resp.url().includes(`/api/conversations/${conversationId}/leave`) && resp.request().method() === "POST", ); await page.locator('[data-testid="button-confirm-leave"]').click(); const leaveResp = await leavePromise; expect(leaveResp.status()).toBe(200); // The dialog closes and the user is returned to the conversation list. await expect( page.locator('[data-testid="dialog-confirm-leave"]'), ).toBeHidden(); // The chosen successor — not the auto pick — must be the new admin. const parts = await getParticipants(conversationId); expect(parts).toHaveLength(2); expect(parts.find((p) => p.userId === admin.id)).toBeUndefined(); const m1Row = parts.find((p) => p.userId === m1.id); const m2Row = parts.find((p) => p.userId === m2.id); expect(m1Row?.isAdmin).toBe(false); expect(m2Row?.isAdmin).toBe(true); }); });