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).
9.8 KiB
Fase 7 — Collective creation flow: fill the E2E gap
Estado: ✅ Completa (2026-04-14). 12 tests verdes (O-01..O-03, I-01..I-05, MC-02..MC-05). O-04 y MC-01 retirados del scope — la UI objetivo no existe. Objetivo: cerrar la laguna de cobertura UI en el ciclo completo de un colectivo — onboarding, invitación y gestión — que hoy sólo vive en RLS / pgTAP.
Resultado: 248 tests verdes totales (34 pgTAP + 140 integración + 15 unit + 58 Playwright + 1 gated rate-limit). Escribir estos 12 tests destapó y arregló tres bugs latentes del código de producto:
- El INSERT en
collectivesdesde/onboardingfallaba RLS por la cadena.insert(...).select().single()— ahora vía RPC atómicocreate_collective()(migración 012). - El token de invitación guardado en sessionStorage antes del login OAuth no se consumía al volver —
routes/+layout.svelteahora lo lee y redirige. /collective/manageno recargaba la lista de miembros cuando$currentCollectivellegaba después delonMount— ahora subscribe reactivo.
Bonus: cuarto bug (minor, arreglado): invitation/[token]/+page.svelte hacía round-trip a Keycloak innecesario cuando el auth listener aún no había emitido — ahora espera a authLoading: false antes de decidir.
Scope dropped durante la ejecución:
- O-04 (crear un segundo colectivo desde la UI): no existe un botón "+ new collective" en el sidebar ni en el drawer. Añadirlo es trabajo de producto, fuera del scope de una fase de tests.
- MC-01 (renombrar colectivo desde
/collective/manage): la página no tiene input de rename. Igual razonamiento. Ambas quedan como follow-ups de UI que el equipo puede priorizar aparte.
Contexto: el audit de 2026-04-14 (ver mensaje de planificación en el chat) muestra que el backend del dominio "colectivo" está bien testado (13 casos RLS + 7 pgTAP), pero la UI sólo tiene 2 asserts (sidebar tras login en auth.test.ts A-04/A-06). Ninguna prueba recorre /onboarding, /invitation/[token] ni /collective/manage.
Esta fase no introduce features — sólo tests. Si alguno falla en el primer run es porque hay un bug real a corregir (los flujos están en producción desde Fase 1).
7.0 Helpers compartidos
apps/web/tests/fixtures/db.ts (nuevo) — usa el admin service-role client de @colectivo/test-utils para manipulación directa (bypass RLS):
resetEva()— borra todos loscollective_membersde Eva y loscollectivesque Eva haya creado. Se invoca enbeforeEachdeonboarding.test.tsyinvitation.test.ts(ambos asumen Eva = cero colectivos al empezar).seedExpiredInvitation(collectiveId: string, adminId: string): Promise<string>— inserta una fila encollective_invitationsconexpires_at = now() - 1 hour; devuelve el token. Usado por I-04.seedUsedInvitation(collectiveId: string, adminId: string, acceptorId: string): Promise<string>— inserta conused_at = now()yused_by = acceptorId. Usado por I-05.restoreSeedMembership()— restituye los roles base de la semilla (Borja = member, Carmen = member, David = guest).afterAlldemanage-collective.test.ts, imprescindible para que los tests posteriores (lists / items / tasks / notes) vean el estado sembrado esperado.
Patrón de importación para evitar que helpers se filtren al bundle del cliente: el helper vive bajo tests/fixtures/, no src/, y sólo se importa desde ficheros *.test.ts.
7.1 /onboarding — O-series (4 tests, fichero nuevo)
Fichero: apps/web/tests/e2e/onboarding.test.ts
Fixture: Eva (USERS.eva), sin colectivo al iniciar (garantizado por resetEva() en beforeEach).
- O-01 — Eva se loguea, no tiene colectivos → el layout la redirige automáticamente a
/onboarding. Assertapage.url()contiene/onboardingy la pantalla muestra el formulario de creación. - O-02 — Camino feliz: escribe un nombre ("Familia Eva"), selecciona un emoji (🏠), envía → redirección a
/lists+ sidebar muestra el nuevo colectivo con nombre y emoji correctos +localStorage.getItem('activeCollectiveId')es el id devuelto por el INSERT. - O-03 — Input de nombre vacío → botón de submit deshabilitado (o error inline). Ninguna petición POST sale (network stub o count
collective_membersantes/después sin cambios). O-04— Dropped: la UI objetivo (botón "+ new collective" en el sidebar o drawer) no existe. Añadirlo es feature work, no scope de una fase de tests. Follow-up abierto.
Criterio de aceptación: 3 tests pasan; Eva queda sin colectivos al final (via afterAll → resetEva()).
7.2 /invitation/[token] — I-series (5 tests, fichero nuevo)
Fichero: apps/web/tests/e2e/invitation.test.ts
Fixtures: Ana (admin del colectivo semilla) + Eva (no-miembro). resetEva() en beforeEach.
- I-01 — Ana abre
/collective/manage, pulsa "Generar invitación" → un URL con token aparece y es copiable; la fila correspondiente encollective_invitationstieneexpires_at> ahora yused_at IS NULL. - I-02 — Eva (deslogueada) abre el URL de invitación activo → el guard redirige a login; tras autenticarse vuelve a la página de aceptación; pulsa "Aceptar" → queda registrada como
memberencollective_members+ redirigida al colectivo (/listsdel nuevo collective_id). - I-03 — Eva (ya logueada, aún no-miembro) abre el URL → un solo click de "Aceptar" la añade como member, sin pasar por login.
- I-04 — Ana genera invitación, Eva abre con token expirado (vía
seedExpiredInvitation) → la UI muestra estado "expirada" sin botón de aceptar; no se inserta fila encollective_members. - I-05 — Invitación ya usada (
seedUsedInvitation) → la UI muestra estado "ya aceptada" / "token inválido"; igual, no hay INSERT.
Captura del token en I-02/I-03: Ana abre /collective/manage en la misma browser context, pulsa "Generar", el token se lee del href del link copiable (selector data-testid="invite-link"). La parte de Eva se hace en un segundo browser context (browser.newContext()), aislando cookies/localStorage.
Criterio de aceptación: los 5 tests pasan; Eva y sus membresías quedan reseteadas al terminar.
7.3 /collective/manage — MC-series (5 tests, fichero nuevo)
Fichero: apps/web/tests/e2e/manage-collective.test.ts
Fixtures: Ana (admin), Borja (member), Carmen (member) sobre el colectivo semilla. restoreSeedMembership() en afterAll.
MC-01— Dropped:/collective/manageno tiene input de rename. Follow-up abierto.- MC-02 — Ana promueve a Borja de
memberaadmin→ tras reload, Borja ve el botón "Generar invitación" y el input editable de nombre (afordances reservadas a admin). - MC-03 — Ana degrada a Borja de vuelta a
member. Precondición: Ana sigue siendo admin (no dispara el trigger de auto-promoción). Asserta que el cambio persiste. - MC-04 — Ana elimina a Carmen → la fila desaparece de la lista inmediatamente. En un segundo context, Carmen (logueada antes de la eliminación) intenta abrir
/collective/manage→ debe recibir redirección / 403 / estado vacío (el RLS ya bloquea; hay que comprobar que la UI no crashea). - MC-05 — Ana genera una invitación, luego pulsa "Revocar" en esa fila → la fila se marca como revocada (o desaparece); abrir el URL revocado muestra el mismo estado de "inválida" que I-05.
Criterio de aceptación: los 5 tests pasan; restoreSeedMembership() deja Borja=member, Carmen=member, David=guest antes de que se ejecute cualquier otro test.
7.Z Verificación
just test-e2e→ 12 tests nuevos verdes (O-04 y MC-01 dropped), 0 regresiones en los 46 existentes (total: 58 Playwright).- El fichero
restoreSeedMembership()no deja cambios residuales — verificación manual:docker exec colectivo-dev-db-1 psql -U postgres -c "SELECT user_id, role FROM collective_members ORDER BY user_id"debe devolver los 4 miembros de la semilla con sus roles originales. - Actualizar
CLAUDE.mdcon el nuevo total de tests + una línea describiendo la cobertura añadida.
Criterio de aceptación: 12 tests verdes, seed restaurada, doc actualizada.
Riesgos / notas
-
Eva como fixture compartido entre
onboarding.test.tseinvitation.test.ts. Si Playwright paraleliza ambos ficheros, elresetEva()del segundo puede borrar los datos mientras el primero está a medias. Mitigación: los 3 ficheros nuevos deben ejecutarse secuencialmente.playwright.config.tsya usaworkers: 1por defecto en CI; confirmar que sigue así al añadir estos tests. -
restoreSeedMembership()es un punto único de fallo. Si se olvida elafterAllo falla, toda la suite aguas abajo falla con "member not found". Mitigación: envolver los tests MC dentro de untest.describe.serial(...)y registrar elafterAllcomo hook inline, no importado. -
I-04 e I-05 manipulan tablas directamente vía admin client. Esto bypassa el trigger de auditoría si se añade en el futuro. Mitigación: documentar en el helper que es la única vía aceptable; cualquier nuevo trigger auditor debe tolerarlo (o generar eventos equivalentes manualmente).
-
Captura del token de invitación desde la UI (I-02/I-03). Depende de que el input del link tenga un
data-testidestable. Si hoy no existe, hay que añadirlo aapps/web/src/routes/(app)/collective/manage/+page.sveltecomo parte del trabajo (cambio mínimo, no es scope creep).
Scope explícito fuera
- No se tocan tests de shopping / tasks / notes.
- No se cubre la flow de "rechazar invitación" (no existe en la UI).
- No se cubre el trigger auto-promote-oldest-admin desde E2E — pgTAP ya lo testa (003_promote_on_admin_leave.sql), duplicarlo en Playwright no aporta.
- No se cubren notificaciones push al aceptar invitación — fuera de MVP.