/** * I-series: invitation acceptance UI (Fase 7.2). * * Ana (admin of the seed collective) generates an invitation link in * /collective/manage; Eva (non-member) accepts it. The test uses TWO browser * contexts — one per user — to keep sessions isolated and to capture the * token produced by the UI without cross-contaminating localStorage. */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; import { resetEva, seedExpiredInvitation, seedUsedInvitation, countMembership } from '../fixtures/db.js'; import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils'; test.describe.configure({ mode: 'serial' }); test.describe('Invitations', () => { test.beforeEach(async () => { await resetEva(); }); test.afterAll(async () => { await resetEva(); // Wipe invitations Ana created during the suite so the DB is clean for // downstream suites. await sql('DELETE FROM public.collective_invitations WHERE collective_id = $1', [ COLLECTIVE_ID ]); await closePool(); }); test('I-01: Ana generates an invitation link with a valid token', 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() ?? ''; expect(url).toMatch(/\/invitation\/[0-9a-f-]{36}$/i); const token = url.split('/').pop()!; // DB row reflects an active (not-yet-used, not-expired) invitation. const row = await sql( `SELECT role, accepted_at, expires_at > now() AS active FROM public.collective_invitations WHERE token = $1`, [token] ); expect(row.rowCount).toBe(1); expect(row.rows[0].accepted_at).toBeNull(); expect(row.rows[0].active).toBe(true); }); test('I-02: logged-out Eva opens invite → login → accept → becomes member', async ({ browser }) => { // Ana: generate the token in one context. const anaCtx = await browser.newContext(); const anaPage = await anaCtx.newPage(); await loginAs(anaPage, USERS.ana); await anaPage.goto('/collective/manage'); await anaPage.getByTestId('generate-invite').click(); const url = (await anaPage.getByTestId('invite-link').textContent())?.trim() ?? ''; const token = url.split('/').pop()!; await anaCtx.close(); // Eva: logged-out context, hit the invitation URL directly. const evaCtx = await browser.newContext(); const evaPage = await evaCtx.newPage(); // The invitation page stores the token in sessionStorage and kicks off // Keycloak login. After login Eva lands back on the app; the token is // consumed by the /invitation/[token] page on next navigation. await evaPage.goto(`/invitation/${token}`); // Keycloak login form: await evaPage.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, { timeout: 15_000 }); await evaPage.fill('#username', USERS.eva.email); await evaPage.fill('#password', USERS.eva.password); await evaPage.click('#kc-login'); // Back in the app. There are two UX paths here depending on whether // sessionStorage carried the token across the redirect; either lands on // /invitation/[token] with the accept button visible, or on / which // redirects to /onboarding (no collective yet). Be liberal in what we // accept: navigate back to the token page if we drifted. // Wait for auth-token to land, then wait for the root layout's post-login // redirect to route us back to /invitation/. If for some reason we // drift elsewhere (race on localStorage presence vs. goto firing), force- // navigate back. await evaPage.waitForFunction( () => Object.keys(localStorage).some( (k) => k.startsWith('sb-') && k.endsWith('-auth-token') ), null, { timeout: 20_000 } ); await evaPage.waitForURL(new RegExp(`/invitation/${token}`), { timeout: 10_000 }).catch(async () => { await evaPage.goto(`/invitation/${token}`); }); // The page flips through loading → ready once it sees an authenticated session. const acceptBtn = evaPage.getByTestId('invitation-accept'); await expect(acceptBtn).toBeVisible({ timeout: 15_000 }); await acceptBtn.click(); await expect(evaPage).toHaveURL(/\/lists$/, { timeout: 15_000 }); // Membership landed in the DB. expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(1); await evaCtx.close(); }); test('I-03: logged-in Eva accepts a fresh invite in one click', async ({ browser }) => { const anaCtx = await browser.newContext(); const anaPage = await anaCtx.newPage(); await loginAs(anaPage, USERS.ana); await anaPage.goto('/collective/manage'); await anaPage.getByTestId('generate-invite').click(); const url = (await anaPage.getByTestId('invite-link').textContent())?.trim() ?? ''; const token = url.split('/').pop()!; await anaCtx.close(); const evaCtx = await browser.newContext(); const evaPage = await evaCtx.newPage(); // Log Eva in first (she has no collective, so she lands on /onboarding). await loginAs(evaPage, USERS.eva, { waitForCollective: false }); await evaPage.goto(`/invitation/${token}`); await evaPage.getByTestId('invitation-accept').click(); await expect(evaPage).toHaveURL(/\/lists$/, { timeout: 10_000 }); expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(1); await evaCtx.close(); }); test('I-04: expired invitation shows the expired state, no membership', async ({ page }) => { const token = await seedExpiredInvitation(); await loginAs(page, USERS.eva, { waitForCollective: false }); await page.goto(`/invitation/${token}`); // The page initially shows the accept button — the server-side RPC is // the one that detects the expiration. Click accept, then assert the // error state. await page.getByTestId('invitation-accept').click(); await expect(page.getByTestId('invitation-error')).toBeVisible({ timeout: 10_000 }); const errKey = await page.getByTestId('invitation-error').getAttribute('data-error-key'); expect(errKey).toBe('expired'); expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(0); }); test('I-05: already-used invitation shows the used state, no extra membership', async ({ page }) => { const token = await seedUsedInvitation(); await loginAs(page, USERS.eva, { waitForCollective: false }); await page.goto(`/invitation/${token}`); await page.getByTestId('invitation-accept').click(); await expect(page.getByTestId('invitation-error')).toBeVisible({ timeout: 10_000 }); const errKey = await page.getByTestId('invitation-error').getAttribute('data-error-key'); expect(errKey).toBe('already_used'); expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(0); }); });