/** * MC-series: /collective/manage — admin UI flows (Fase 7.3). * * Ana is admin of the seed collective; Borja + Carmen are members; David is * guest. Tests run serially and restoreSeedMembership() in afterAll puts the * roster back to seed state so downstream suites (lists, items, tasks, notes) * see their expected roles. */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; import { restoreSeedMembership } from '../fixtures/db.js'; import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils'; test.describe.configure({ mode: 'serial' }); test.describe('Manage collective (admin UI)', () => { test.afterAll(async () => { await restoreSeedMembership(); await closePool(); }); test('MC-02: Ana promotes Borja from member to admin; change persists', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/collective/manage'); const borjaSelect = page.getByTestId(`role-select-${USERS.borja.id}`); await expect(borjaSelect).toBeVisible({ timeout: 15_000 }); await expect(borjaSelect).toHaveValue('member'); await borjaSelect.selectOption('admin'); // DB updated. await expect .poll( async () => { const r = await sql( 'SELECT role FROM public.collective_members WHERE collective_id = $1 AND user_id = $2', [COLLECTIVE_ID, USERS.borja.id] ); return r.rows[0]?.role; }, { timeout: 5_000 } ) .toBe('admin'); // On reload, the UI reflects the new role. await page.reload(); await expect(page.getByTestId(`role-select-${USERS.borja.id}`)).toHaveValue('admin', { timeout: 10_000 }); }); test('MC-03: Ana demotes Borja back to member', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/collective/manage'); const borjaSelect = page.getByTestId(`role-select-${USERS.borja.id}`); await expect(borjaSelect).toBeVisible({ timeout: 15_000 }); // Precondition from MC-02: Borja is admin. Fresh runs may start with // Borja as member — use a conditional so this test is order-independent. const currentRole = await borjaSelect.inputValue(); if (currentRole !== 'admin') { await borjaSelect.selectOption('admin'); await page.waitForTimeout(200); } await borjaSelect.selectOption('member'); await expect .poll(async () => { const r = await sql( 'SELECT role FROM public.collective_members WHERE collective_id = $1 AND user_id = $2', [COLLECTIVE_ID, USERS.borja.id] ); return r.rows[0]?.role; }) .toBe('member'); }); test('MC-04: Ana removes Carmen; the row disappears and membership row is gone', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/collective/manage'); const carmenRow = page.getByTestId(`member-row-${USERS.carmen.id}`); await expect(carmenRow).toBeVisible({ timeout: 15_000 }); await page.getByTestId(`remove-member-${USERS.carmen.id}`).click(); await expect(carmenRow).toHaveCount(0, { timeout: 5_000 }); const r = await sql( 'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2', [COLLECTIVE_ID, USERS.carmen.id] ); expect(r.rows[0].n).toBe(0); }); test('MC-05: Ana generates a pending invitation; the token is visible + live', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/collective/manage'); await expect(page.getByTestId(`role-select-${USERS.borja.id}`)).toBeVisible({ timeout: 15_000 }); await page.getByTestId('generate-invite').click(); const link = page.getByTestId('invite-link'); await expect(link).toBeVisible({ timeout: 5_000 }); const url = (await link.textContent())?.trim() ?? ''; const token = url.split('/').pop()!; const r = await sql( `SELECT accepted_at, expires_at > now() AS active FROM public.collective_invitations WHERE token = $1`, [token] ); expect(r.rowCount).toBe(1); expect(r.rows[0].accepted_at).toBeNull(); expect(r.rows[0].active).toBe(true); // Cleanup the generated row so downstream doesn't inherit it. await sql('DELETE FROM public.collective_invitations WHERE token = $1', [token]); }); });