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:
110
apps/web/tests/fixtures/db.ts
vendored
Normal file
110
apps/web/tests/fixtures/db.ts
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* DB helpers for Fase 7 E2E tests — direct-to-postgres helpers that bypass RLS
|
||||
* via the admin client. Import from `*.test.ts` only, never from `src/`.
|
||||
*
|
||||
* These helpers exist because Fase 7 exercises collective creation end-to-end:
|
||||
* tests need to reset the "Eva has no collective" precondition between runs
|
||||
* and seed invitation rows in exotic states (expired / already-accepted) that
|
||||
* the UI can't produce on its own.
|
||||
*/
|
||||
import { sql, ANA_ID, COLLECTIVE_ID } from '@colectivo/test-utils';
|
||||
|
||||
/**
|
||||
* Scrub all state created by Eva and all memberships she holds.
|
||||
*
|
||||
* Call in `beforeEach` of suites that assume Eva is a non-member at start.
|
||||
* Implementation via raw SQL (bypasses RLS + cascades) instead of admin client
|
||||
* to avoid a two-step delete dance.
|
||||
*/
|
||||
export async function resetEva(): Promise<void> {
|
||||
const EVA_ID = '55555555-5555-5555-5555-555555555555';
|
||||
// Delete collectives Eva created (CASCADE wipes members + invitations + lists).
|
||||
await sql('DELETE FROM public.collectives WHERE created_by = $1', [EVA_ID]);
|
||||
// Clear any memberships Eva holds in other collectives.
|
||||
await sql('DELETE FROM public.collective_members WHERE user_id = $1', [EVA_ID]);
|
||||
// Clear any invitations that *targeted* Eva (accepted_by).
|
||||
await sql(
|
||||
'UPDATE public.collective_invitations SET accepted_at = NULL, accepted_by = NULL WHERE accepted_by = $1',
|
||||
[EVA_ID]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an invitation row with an expires_at in the past.
|
||||
* Returns the token (uuid).
|
||||
*/
|
||||
export async function seedExpiredInvitation(
|
||||
collectiveId: string = COLLECTIVE_ID,
|
||||
createdBy: string = ANA_ID
|
||||
): Promise<string> {
|
||||
const res = await sql(
|
||||
`INSERT INTO public.collective_invitations (collective_id, role, created_by, expires_at)
|
||||
VALUES ($1, 'member', $2, now() - interval '1 hour')
|
||||
RETURNING token`,
|
||||
[collectiveId, createdBy]
|
||||
);
|
||||
return res.rows[0].token as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an invitation row already marked accepted (accepted_at + accepted_by).
|
||||
* Returns the token.
|
||||
*/
|
||||
export async function seedUsedInvitation(
|
||||
collectiveId: string = COLLECTIVE_ID,
|
||||
createdBy: string = ANA_ID,
|
||||
acceptedBy: string = ANA_ID
|
||||
): Promise<string> {
|
||||
const res = await sql(
|
||||
`INSERT INTO public.collective_invitations (collective_id, role, created_by, accepted_at, accepted_by)
|
||||
VALUES ($1, 'member', $2, now(), $3)
|
||||
RETURNING token`,
|
||||
[collectiveId, createdBy, acceptedBy]
|
||||
);
|
||||
return res.rows[0].token as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the seed collective's membership roster back to the post-seed state:
|
||||
* Ana = admin, Borja = member, Carmen = member, David = guest
|
||||
*
|
||||
* Call in `afterAll` of suites that mutate roles / remove members. Critical —
|
||||
* downstream suites (lists, items, tasks, notes) assume this shape.
|
||||
*/
|
||||
export async function restoreSeedMembership(): Promise<void> {
|
||||
// Use raw SQL to bypass RLS without needing the service-role JWT (which
|
||||
// isn't always exported to the Playwright process env).
|
||||
const seed = [
|
||||
['11111111-1111-1111-1111-111111111111', 'admin'],
|
||||
['22222222-2222-2222-2222-222222222222', 'member'],
|
||||
['33333333-3333-3333-3333-333333333333', 'member'],
|
||||
['44444444-4444-4444-4444-444444444444', 'guest']
|
||||
] as const;
|
||||
|
||||
for (const [userId, role] of seed) {
|
||||
await sql(
|
||||
`INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES ($1, $2, $3::public.member_role)
|
||||
ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[COLLECTIVE_ID, userId, role]
|
||||
);
|
||||
}
|
||||
|
||||
// Rename the collective back if a test changed it. Emoji stays whatever.
|
||||
await sql('UPDATE public.collectives SET name = $1 WHERE id = $2', [
|
||||
'Casa García-López',
|
||||
COLLECTIVE_ID
|
||||
]);
|
||||
|
||||
// Nuke any invitations that accumulated.
|
||||
await sql('DELETE FROM public.collective_invitations WHERE collective_id = $1', [COLLECTIVE_ID]);
|
||||
}
|
||||
|
||||
/** Count rows in collective_members for a given (collective, user). 0 or 1. */
|
||||
export async function countMembership(collectiveId: string, userId: string): Promise<number> {
|
||||
const res = await sql(
|
||||
'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
|
||||
[collectiveId, userId]
|
||||
);
|
||||
return res.rows[0].n as number;
|
||||
}
|
||||
14
apps/web/tests/fixtures/login.ts
vendored
14
apps/web/tests/fixtures/login.ts
vendored
@@ -9,7 +9,13 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { USERS } from './users.js';
|
||||
|
||||
export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USERS]): Promise<void> {
|
||||
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
|
||||
@@ -34,6 +40,12 @@ export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USER
|
||||
{ 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,
|
||||
|
||||
8
apps/web/tests/fixtures/users.ts
vendored
8
apps/web/tests/fixtures/users.ts
vendored
@@ -18,6 +18,14 @@ export const USERS = {
|
||||
role: 'member' as const,
|
||||
storageState: 'tests/.auth/borja.json'
|
||||
},
|
||||
carmen: {
|
||||
id: '33333333-3333-3333-3333-333333333333',
|
||||
email: 'carmen@dev.local',
|
||||
password: 'test1234',
|
||||
displayName: 'Carmen Martínez',
|
||||
role: 'member' as const,
|
||||
storageState: 'tests/.auth/carmen.json'
|
||||
},
|
||||
david: {
|
||||
id: '44444444-4444-4444-4444-444444444444',
|
||||
email: 'david@dev.local',
|
||||
|
||||
Reference in New Issue
Block a user