Files
collective-lists/apps/web/tests/fixtures/login.ts
Oier Bravo Urtasun f5c8cb68be 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).
2026-04-14 22:33:33 +02:00

72 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Live login helper — runs the real Keycloak OAuth flow in the current context.
*
* We use this instead of `storageState` because Supabase JS v2's async
* `_recoverAndRefresh` doesn't reliably re-fire `onAuthStateChange`/INITIAL_SESSION
* when the browser context is built from a saved localStorage snapshot. Doing the
* full login for each test adds ~23 seconds but produces deterministic state.
*/
import type { Page } from '@playwright/test';
import type { USERS } from './users.js';
export async function loginAs(
page: Page,
user: (typeof USERS)[keyof typeof USERS],
opts: { waitForCollective?: boolean } = {}
): Promise<void> {
const { waitForCollective = true } = opts;
await page.goto('/');
await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
timeout: 15_000
});
await page.fill('#username', user.email);
await page.fill('#password', user.password);
await page.click('#kc-login');
// We pin Playwright's baseURL to http://localhost:5173 (see playwright.config.ts)
// regardless of whether PUBLIC_APP_URL is the LAN IP for device previews, so
// the post-login origin is always localhost.
await page.waitForURL(
(url) => url.origin === 'http://localhost:5173' && !url.pathname.startsWith('/auth/callback'),
{ timeout: 20_000 }
);
await page.waitForFunction(
() =>
Object.keys(window.localStorage).some(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
),
null,
{ timeout: 15_000 }
);
// Opt-out for no-collective users (Eva): the sidebar never appears because
// the root layout sends them straight to /onboarding, so the emoji-button
// heuristic below would time out with no useful signal. Callers that exercise
// the onboarding flow pass `waitForCollective: false`.
if (!waitForCollective) return;
// Wait for `$currentCollective` to populate — any test that creates rows
// needs this, because `handleCreate` silently returns when collective is
// null. On localhost the round-trip was fast enough that races were rare,
// but over LAN IP the extra latency exposes the race. We detect it by the
// sidebar's collective button rendering the actual name (otherwise it
// shows the app_name fallback "Colectivo").
await page.waitForFunction(
(appName) => {
const buttons = document.querySelectorAll('button');
for (const b of Array.from(buttons)) {
const txt = b.textContent?.trim();
if (txt && txt !== appName && !txt.startsWith(appName)) {
// Heuristic: any non-default button with emoji-prefixed name
// (seed collective is "Casa García-López" with emoji "🏠").
if (/\p{Emoji}/u.test(txt)) return true;
}
}
return false;
},
'Colectivo',
{ timeout: 10_000 }
);
}