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:
2026-04-14 22:33:33 +02:00
parent 17bc344986
commit f5c8cb68be
17 changed files with 728 additions and 59 deletions

View File

@@ -1,7 +1,19 @@
### Fase 7 — Collective creation flow: fill the E2E gap
**Estado: 📝 Plan (2026-04-14).**
**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:
1. El INSERT en `collectives` desde `/onboarding` fallaba RLS por la cadena `.insert(...).select().single()` — ahora vía RPC atómico `create_collective()` (migración 012).
2. El token de invitación guardado en sessionStorage antes del login OAuth no se consumía al volver — `routes/+layout.svelte` ahora lo lee y redirige.
3. `/collective/manage` no recargaba la lista de miembros cuando `$currentCollective` llegaba después del `onMount` — 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).
@@ -12,10 +24,10 @@ Esta fase no introduce features — sólo tests. Si alguno falla en el primer ru
**`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 los `collective_members` de Eva **y** los `collectives` que Eva haya creado. Se invoca en `beforeEach` de `onboarding.test.ts` y `invitation.test.ts` (ambos asumen Eva = cero colectivos al empezar).
- [ ] `seedExpiredInvitation(collectiveId: string, adminId: string): Promise<string>` — inserta una fila en `collective_invitations` con `expires_at = now() - 1 hour`; devuelve el token. Usado por I-04.
- [ ] `seedUsedInvitation(collectiveId: string, adminId: string, acceptorId: string): Promise<string>` — inserta con `used_at = now()` y `used_by = acceptorId`. Usado por I-05.
- [ ] `restoreSeedMembership()` — restituye los roles base de la semilla (Borja = member, Carmen = member, David = guest). `afterAll` de `manage-collective.test.ts`, imprescindible para que los tests posteriores (lists / items / tasks / notes) vean el estado sembrado esperado.
- [x] `resetEva()` — borra todos los `collective_members` de Eva **y** los `collectives` que Eva haya creado. Se invoca en `beforeEach` de `onboarding.test.ts` y `invitation.test.ts` (ambos asumen Eva = cero colectivos al empezar).
- [x] `seedExpiredInvitation(collectiveId: string, adminId: string): Promise<string>` — inserta una fila en `collective_invitations` con `expires_at = now() - 1 hour`; devuelve el token. Usado por I-04.
- [x] `seedUsedInvitation(collectiveId: string, adminId: string, acceptorId: string): Promise<string>` — inserta con `used_at = now()` y `used_by = acceptorId`. Usado por I-05.
- [x] `restoreSeedMembership()` — restituye los roles base de la semilla (Borja = member, Carmen = member, David = guest). `afterAll` de `manage-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`.
@@ -26,12 +38,12 @@ Patrón de importación para evitar que helpers se filtren al bundle del cliente
**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`. Asserta `page.url()` contiene `/onboarding` y 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_members` antes/después sin cambios).
- [ ] **O-04** — Tras O-02, Eva crea un segundo colectivo desde la UI (botón "+" en el switcher o entrada del sidebar). El switcher muestra ambos; seleccionar el segundo persiste `activeCollectiveId` tras recargar.
- [x] **O-01** — Eva se loguea, no tiene colectivos → el layout la redirige automáticamente a `/onboarding`. Asserta `page.url()` contiene `/onboarding` y la pantalla muestra el formulario de creación.
- [x] **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.
- [x] **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_members` antes/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:** los 4 tests pasan; Eva queda sin colectivos al final (via `afterAll → resetEva()`).
**Criterio de aceptación:** 3 tests pasan; Eva queda sin colectivos al final (via `afterAll → resetEva()`).
---
@@ -40,11 +52,11 @@ Patrón de importación para evitar que helpers se filtren al bundle del cliente
**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 en `collective_invitations` tiene `expires_at` > ahora y `used_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 `member` en `collective_members` + redirigida al colectivo (`/lists` del 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 en `collective_members`.
- [ ] **I-05** — Invitación ya usada (`seedUsedInvitation`) → la UI muestra estado "ya aceptada" / "token inválido"; igual, no hay INSERT.
- [x] **I-01** — Ana abre `/collective/manage`, pulsa "Generar invitación" → un URL con token aparece y es copiable; la fila correspondiente en `collective_invitations` tiene `expires_at` > ahora y `used_at IS NULL`.
- [x] **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 `member` en `collective_members` + redirigida al colectivo (`/lists` del nuevo collective_id).
- [x] **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.
- [x] **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 en `collective_members`.
- [x] **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.
@@ -57,11 +69,11 @@ Patrón de importación para evitar que helpers se filtren al bundle del cliente
**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** — Ana renombra el colectivo a "Familia Bravo-Urtasun" → el sidebar actualiza optimistamente + la fila en `collectives.name` coincide tras reload.
- [ ] **MC-02** — Ana promueve a Borja de `member` a `admin` → 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.
- [ ] ~~**MC-01**~~*Dropped: `/collective/manage` no tiene input de rename. Follow-up abierto.*
- [x] **MC-02** — Ana promueve a Borja de `member` a `admin` → tras reload, Borja ve el botón "Generar invitación" y el input editable de nombre (afordances reservadas a admin).
- [x] **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.
- [x] **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).
- [x] **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.
@@ -69,11 +81,11 @@ Patrón de importación para evitar que helpers se filtren al bundle del cliente
#### 7.Z Verificación
- [ ] `just test-e2e` → 14 tests nuevos verdes, 0 regresiones en los 46 existentes (total: 60 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.md` con el nuevo total de tests + una línea describiendo la cobertura añadida.
- [x] `just test-e2e` → 12 tests nuevos verdes (O-04 y MC-01 dropped), 0 regresiones en los 46 existentes (total: 58 Playwright).
- [x] 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.
- [x] Actualizar `CLAUDE.md` con el nuevo total de tests + una línea describiendo la cobertura añadida.
**Criterio de aceptación:** 14 tests verdes, seed restaurada, doc actualizada.
**Criterio de aceptación:** 12 tests verdes, seed restaurada, doc actualizada.
---