feat(fase-7): collective-flow E2E coverage + fix 3 latent bugs
12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.
Tests:
- tests/e2e/onboarding.test.ts O-01..O-03 (Eva auto-redirect, happy
path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts I-01..I-05 (token generation, accept
logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
member, generate pending invite)
- tests/fixtures/db.ts resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership,
countMembership — all via raw SQL so they
don't depend on SUPABASE_SERVICE_ROLE_KEY
Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
`create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
used `.insert(...).select().single()` which triggers the SELECT policy on
the fresh row — creator isn't a member yet, so the INSERT was rejected with
"row-level security" even though the INSERT policy passed. onboarding now
calls the RPC which inserts both the collective AND the creator-as-admin
membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
has been silently masking this bug (only the spoof branch was asserted).
- routes/+layout.svelte: post-login redirect now reads sessionStorage
`pendingInvitationToken` and routes back to /invitation/<token> instead of
defaulting to /onboarding or /lists. The invitation page already stashed
the token before kicking off Keycloak, but nothing read it back.
- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
to currentCollective so it re-runs when the store resolves after cold
navigation. onMount alone races with the auth listener's first emit and
the member list stayed empty on direct URL hits.
- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
whether to redirect to login. Previously raced with the auth listener and
triggered a redundant Keycloak round-trip for freshly-authenticated users.
Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)
Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.
Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
This commit is contained in:
168
apps/web/tests/e2e/invitation.test.ts
Normal file
168
apps/web/tests/e2e/invitation.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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/<token>. 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user