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

3
.gitignore vendored
View File

@@ -39,5 +39,8 @@ apps/web/playwright-report/
apps/web/test-results/ apps/web/test-results/
apps/web/tests/.auth/ apps/web/tests/.auth/
# PWA dev-mode service worker output
apps/web/dev-dist/
# Claude Code per-project runtime state (schedulers etc.) # Claude Code per-project runtime state (schedulers etc.)
.claude/ .claude/

View File

@@ -8,11 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## Project Status
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). Fase 7 is the active plan: 14 Playwright tests filling the collective-flow UI gap (onboarding / invitation acceptance / admin-manage) — see `plan/fase-7-collective-flow-tests.md`. 236 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 46 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** **Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). 248 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7; Fase 7 is the active plan) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7; Fase 7 is complete — collective-flow E2E coverage)
- `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups (desktop + mobile variants). Consult before changing UI. - `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups (desktop + mobile variants). Consult before changing UI.
### Fase 1 — what has been built ### Fase 1 — what has been built
@@ -74,6 +74,40 @@ just test-e2e # Playwright E2E tests (requires just dev running)
just test-e2e-headed # Playwright with visible browser (debugging) just test-e2e-headed # Playwright with visible browser (debugging)
``` ```
### Fase 7 — collective-flow E2E coverage (2026-04-14)
12 new Playwright tests + 1 DB fixtures helper + 1 migration. Writing the tests uncovered three product-code bugs which are fixed as part of this phase.
**Tests (all green, included in `just test-e2e`):**
- `apps/web/tests/e2e/onboarding.test.ts` — O-01..O-03: Eva auto-redirect to `/onboarding`, happy-path create, empty-name submit-disabled.
- `apps/web/tests/e2e/invitation.test.ts` — I-01..I-05: admin generates token, logged-out-then-accept, logged-in one-click accept, expired + already-used error states.
- `apps/web/tests/e2e/manage-collective.test.ts` — MC-02..MC-05: promote member → admin, demote back, remove member, generate pending invitation.
- `apps/web/tests/fixtures/db.ts``resetEva()` / `seedExpiredInvitation()` / `seedUsedInvitation()` / `restoreSeedMembership()` / `countMembership()`. All via raw SQL (bypasses RLS) so they don't depend on `SUPABASE_SERVICE_ROLE_KEY` being in the Playwright process env.
**Scope dropped from the plan:**
- **O-04** (multi-collective via a "+ new collective" button in the sidebar): the affordance does not exist in the UI; adding it would be feature work, out of a test-only phase.
- **MC-01** (rename collective): `/collective/manage` has no rename input; same reasoning.
Both are flagged in `plan/fase-7-collective-flow-tests.md` as future UI follow-ups.
**Product-code fixes discovered while writing these tests (all committed with this phase):**
1. **`supabase/migrations/012_create_collective_rpc.sql` — atomic `create_collective(name, emoji)` RPC.** Pre-existing onboarding flow did two separate REST calls (`INSERT collectives` chained with `.select().single()`, then `INSERT collective_members`). Both failed: the RETURNING on the first triggered the SELECT policy (`is_member(id)`) which the creator can't pass yet, and the second was gated by `is_admin(collective_id)` against a collective with zero admins. The RPC is `SECURITY DEFINER` and does both inserts atomically. `onboarding/+page.svelte` now calls `supabase.rpc('create_collective', {...})`.
2. **`routes/+layout.svelte` — resume pending invitation after login.** `invitation/[token]/+page.svelte` stashes the token in `sessionStorage.pendingInvitationToken` before triggering OAuth, but nothing read it back. Post-login redirect logic now checks sessionStorage and routes the user back to `/invitation/<token>` instead of defaulting to `/onboarding` or `/lists`.
3. **`routes/(app)/collective/manage/+page.svelte` — reactively reload members.** `onMount` called `loadMembers()` once; if `$currentCollective` was still null at that point (race with the auth listener on a cold navigation), the query short-circuited and the list stayed empty. Now subscribed to `currentCollective` so `loadMembers` re-runs whenever the store resolves.
4. **`routes/invitation/[token]/+page.svelte` — wait for authLoading before deciding.** Racing with the auth listener's first emit caused the page to see `!$isAuthenticated` for a freshly-authenticated user and trigger a redundant Keycloak round-trip. Now awaits `authLoading → false` before branching.
**Test IDs added to UI (stable selectors for 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` (with `data-error-key` attribute carrying the error key for assertion).
**Gotchas discovered during execution (do not remove):**
18. **`.select()` after `.insert()` evaluates the SELECT RLS policy on the returned row, not just the INSERT policy.** PostgREST's chained `.insert(...).select().single()` adds `RETURNING *` to the INSERT, and Postgres then evaluates the table's SELECT policy against the fresh row. For policies that gate on sibling-table state (e.g., `is_member(id)` requiring a row in `collective_members`), this causes INSERTs to spuriously fail with "new row violates row-level security policy" even though the INSERT policy itself passed. Fix: drop the `.select()` if you don't need the row back, or (better) wrap the insert + any sibling-table sets in a `SECURITY DEFINER` RPC that atomically builds the membership state. F-08 in `rls-isolation.test.ts` has been silently masking this since Fase 1 because it only asserts the _spoof_ case; never verified the happy-path insert actually succeeded.
### Fase 6+ — production deploy to ambrosio (2026-04-14) ### Fase 6+ — production deploy to ambrosio (2026-04-14)
Live URLs: **https://erosi.oier.ovh** (app + Supabase API, single-domain) + **https://auth.oier.ovh** (Keycloak). OVH VPS, Ubuntu 24, Docker 29. Live URLs: **https://erosi.oier.ovh** (app + Supabase API, single-domain) + **https://auth.oier.ovh** (Keycloak). OVH VPS, Ubuntu 24, Docker 29.

View File

@@ -25,6 +25,7 @@
"svelte-dnd-action": "^0.9.51" "svelte-dnd-action": "^0.9.51"
}, },
"devDependencies": { "devDependencies": {
"@colectivo/test-utils": "workspace:*",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-node": "^5.2.11", "@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1", "@sveltejs/kit": "^2.15.1",

View File

@@ -27,8 +27,17 @@
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role); const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
const isAdmin = $derived(myRole === 'admin'); const isAdmin = $derived(myRole === 'admin');
onMount(async () => { onMount(() => {
await loadMembers(); // Re-run loadMembers() whenever the active collective resolves or
// changes. onMount alone races with the auth listener's first
// `currentCollective.set(...)` on cold navigations (hit /collective/manage
// directly in a new tab or after a page reload) and we end up with an
// empty list because the first fetch was short-circuited by
// `$currentCollective == null`.
const unsub = currentCollective.subscribe((c) => {
if (c) void loadMembers();
});
return unsub;
}); });
async function loadMembers() { async function loadMembers() {
@@ -163,7 +172,7 @@
{:else} {:else}
<ul> <ul>
{#each members as member} {#each members as member}
<li class="flex items-center gap-3 py-3"> <li data-testid="member-row-{member.user_id}" class="flex items-center gap-3 py-3">
<Avatar <Avatar
name={member.display_name} name={member.display_name}
type={member.avatar_type} type={member.avatar_type}
@@ -182,6 +191,7 @@
{#if isAdmin && member.user_id !== $currentUser?.id} {#if isAdmin && member.user_id !== $currentUser?.id}
<select <select
data-testid="role-select-{member.user_id}"
value={member.role} value={member.role}
onchange={(e) => changeRole(member.user_id, e.currentTarget.value as Member['role'])} onchange={(e) => changeRole(member.user_id, e.currentTarget.value as Member['role'])}
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-xs class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-xs
@@ -192,6 +202,7 @@
{/each} {/each}
</select> </select>
<button <button
data-testid="remove-member-{member.user_id}"
onclick={() => removeMember(member.user_id)} onclick={() => removeMember(member.user_id)}
class="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600 class="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
dark:hover:bg-red-900/20" dark:hover:bg-red-900/20"
@@ -230,6 +241,7 @@
</div> </div>
<button <button
data-testid="generate-invite"
onclick={generateInviteLink} onclick={generateInviteLink}
disabled={generating} disabled={generating}
class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
@@ -240,7 +252,7 @@
{#if generatedLink} {#if generatedLink}
<div class="flex items-center gap-2 rounded-lg bg-surface p-2 dark:bg-surface-raised"> <div class="flex items-center gap-2 rounded-lg bg-surface p-2 dark:bg-surface-raised">
<code class="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-400"> <code data-testid="invite-link" class="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-400">
{generatedLink} {generatedLink}
</code> </code>
<button <button

View File

@@ -38,7 +38,14 @@
// token refreshes or when the user is already on a real route. // token refreshes or when the user is already on a real route.
const path = $page.url.pathname; const path = $page.url.pathname;
if (path === '/auth/callback' || path === '/') { if (path === '/auth/callback' || path === '/') {
if ($userCollectives.length === 0) { // If the user came in via /invitation/[token] and had to
// log in first, resume that flow instead of going to
// /lists or /onboarding.
const pendingToken = sessionStorage.getItem('pendingInvitationToken');
if (pendingToken) {
sessionStorage.removeItem('pendingInvitationToken');
goto(`/invitation/${pendingToken}`);
} else if ($userCollectives.length === 0) {
goto('/onboarding'); goto('/onboarding');
} else { } else {
goto('/lists'); goto('/lists');

View File

@@ -3,8 +3,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { isAuthenticated } from '$lib/stores/auth'; import { authLoading, isAuthenticated } from '$lib/stores/auth';
import { login } from '$lib/auth'; import { login } from '$lib/auth';
import { get } from 'svelte/store';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success'; type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success';
@@ -20,7 +21,23 @@
status = 'error'; status = 'error';
return; return;
} }
if (!$isAuthenticated) {
// Wait for the auth listener to have finished its first emit before
// deciding. Otherwise onMount can race with onAuthStateChange and the
// page misreads an already-authenticated user as logged-out, triggering
// a redundant Keycloak round-trip.
if (get(authLoading)) {
await new Promise<void>((resolve) => {
const unsub = authLoading.subscribe((v) => {
if (!v) {
unsub();
resolve();
}
});
});
}
if (!get(isAuthenticated)) {
// After login, GoTrue redirects to /auth/callback which goes to /. // After login, GoTrue redirects to /auth/callback which goes to /.
// We store the invitation token so we can resume after login. // We store the invitation token so we can resume after login.
sessionStorage.setItem('pendingInvitationToken', token); sessionStorage.setItem('pendingInvitationToken', token);
@@ -77,6 +94,7 @@
</h1> </h1>
<p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p> <p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p>
<button <button
data-testid="invitation-accept"
onclick={accept} onclick={accept}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900" hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
@@ -95,7 +113,7 @@
{:else if status === 'error'} {:else if status === 'error'}
<div class="mb-4 text-4xl"></div> <div class="mb-4 text-4xl"></div>
<p class="mb-4 text-sm text-red-600">{errorMessage(errorKey)}</p> <p data-testid="invitation-error" data-error-key={errorKey} class="mb-4 text-sm text-red-600">{errorMessage(errorKey)}</p>
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a> <a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
{/if} {/if}
</div> </div>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { currentCollective, userCollectives } from '$lib/stores/collective'; import { currentCollective, userCollectives } from '$lib/stores/collective';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
@@ -32,30 +31,29 @@
createError = null; createError = null;
const supabase = getSupabase(); const supabase = getSupabase();
const userId = $currentUser?.id;
if (!userId) return;
const { data: collective, error: collectiveError } = await supabase // Atomic create: the RPC inserts both the collective and the creator's
.from('collectives') // admin membership in one transaction (SECURITY DEFINER, bypasses RLS).
.insert({ name: collectiveName.trim(), emoji: collectiveEmoji, created_by: userId }) // Doing this as two separate REST calls fails because the SELECT policy
.select() // on collectives and the INSERT policy on collective_members both gate
.single(); // on membership the creator doesn't have yet.
const { data, error } = await supabase.rpc('create_collective', {
p_name: collectiveName.trim(),
p_emoji: collectiveEmoji
});
if (collectiveError || !collective) { if (error || !data) {
createError = collectiveError?.message ?? 'Failed to create collective.'; createError = error?.message ?? 'Failed to create collective.';
creating = false; creating = false;
return; return;
} }
const { error: memberError } = await supabase const collective = data as {
.from('collective_members') id: string;
.insert({ collective_id: collective.id, user_id: userId, role: 'admin' }); name: string;
emoji: string;
if (memberError) { created_at: string;
createError = memberError.message; };
creating = false;
return;
}
userCollectives.update((list) => [...list, collective]); userCollectives.update((list) => [...list, collective]);
currentCollective.set(collective); currentCollective.set(collective);
@@ -84,6 +82,7 @@
<!-- Tab switcher --> <!-- Tab switcher -->
<div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700"> <div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700">
<button <button
data-testid="onboarding-create-tab"
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'create' {activeTab === 'create'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900' ? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
@@ -93,6 +92,7 @@
{m.onboarding_create_tab()} {m.onboarding_create_tab()}
</button> </button>
<button <button
data-testid="onboarding-join-tab"
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'join' {activeTab === 'join'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900' ? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
@@ -111,6 +111,7 @@
</label> </label>
<input <input
id="collective-name" id="collective-name"
data-testid="collective-name-input"
type="text" type="text"
bind:value={collectiveName} bind:value={collectiveName}
placeholder={m.onboarding_collective_name_placeholder()} placeholder={m.onboarding_collective_name_placeholder()}
@@ -144,6 +145,7 @@
{/if} {/if}
<button <button
data-testid="collective-submit"
type="submit" type="submit"
disabled={!collectiveName.trim() || creating} disabled={!collectiveName.trim() || creating}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white

View 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);
});
});

View File

@@ -0,0 +1,128 @@
/**
* MC-series: /collective/manage — admin UI flows (Fase 7.3).
*
* Ana is admin of the seed collective; Borja + Carmen are members; David is
* guest. Tests run serially and restoreSeedMembership() in afterAll puts the
* roster back to seed state so downstream suites (lists, items, tasks, notes)
* see their expected roles.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { restoreSeedMembership } from '../fixtures/db.js';
import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';
test.describe.configure({ mode: 'serial' });
test.describe('Manage collective (admin UI)', () => {
test.afterAll(async () => {
await restoreSeedMembership();
await closePool();
});
test('MC-02: Ana promotes Borja from member to admin; change persists', async ({ page }) => {
await loginAs(page, USERS.ana);
await page.goto('/collective/manage');
const borjaSelect = page.getByTestId(`role-select-${USERS.borja.id}`);
await expect(borjaSelect).toBeVisible({ timeout: 15_000 });
await expect(borjaSelect).toHaveValue('member');
await borjaSelect.selectOption('admin');
// DB updated.
await expect
.poll(
async () => {
const r = await sql(
'SELECT role FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
[COLLECTIVE_ID, USERS.borja.id]
);
return r.rows[0]?.role;
},
{ timeout: 5_000 }
)
.toBe('admin');
// On reload, the UI reflects the new role.
await page.reload();
await expect(page.getByTestId(`role-select-${USERS.borja.id}`)).toHaveValue('admin', {
timeout: 10_000
});
});
test('MC-03: Ana demotes Borja back to member', async ({ page }) => {
await loginAs(page, USERS.ana);
await page.goto('/collective/manage');
const borjaSelect = page.getByTestId(`role-select-${USERS.borja.id}`);
await expect(borjaSelect).toBeVisible({ timeout: 15_000 });
// Precondition from MC-02: Borja is admin. Fresh runs may start with
// Borja as member — use a conditional so this test is order-independent.
const currentRole = await borjaSelect.inputValue();
if (currentRole !== 'admin') {
await borjaSelect.selectOption('admin');
await page.waitForTimeout(200);
}
await borjaSelect.selectOption('member');
await expect
.poll(async () => {
const r = await sql(
'SELECT role FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
[COLLECTIVE_ID, USERS.borja.id]
);
return r.rows[0]?.role;
})
.toBe('member');
});
test('MC-04: Ana removes Carmen; the row disappears and membership row is gone', async ({
page
}) => {
await loginAs(page, USERS.ana);
await page.goto('/collective/manage');
const carmenRow = page.getByTestId(`member-row-${USERS.carmen.id}`);
await expect(carmenRow).toBeVisible({ timeout: 15_000 });
await page.getByTestId(`remove-member-${USERS.carmen.id}`).click();
await expect(carmenRow).toHaveCount(0, { timeout: 5_000 });
const r = await sql(
'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
[COLLECTIVE_ID, USERS.carmen.id]
);
expect(r.rows[0].n).toBe(0);
});
test('MC-05: Ana generates a pending invitation; the token is visible + live', 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() ?? '';
const token = url.split('/').pop()!;
const r = await sql(
`SELECT accepted_at, expires_at > now() AS active
FROM public.collective_invitations WHERE token = $1`,
[token]
);
expect(r.rowCount).toBe(1);
expect(r.rows[0].accepted_at).toBeNull();
expect(r.rows[0].active).toBe(true);
// Cleanup the generated row so downstream doesn't inherit it.
await sql('DELETE FROM public.collective_invitations WHERE token = $1', [token]);
});
});

View File

@@ -0,0 +1,92 @@
/**
* O-series: /onboarding — collective creation (Fase 7.1).
*
* Eva is the seeded no-collective user; the app layout redirects her to
* /onboarding automatically after login. Each test resets her state in
* beforeEach so tests don't leak into each other.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { resetEva } from '../fixtures/db.js';
import { closePool, sql } from '@colectivo/test-utils';
test.describe.configure({ mode: 'serial' });
test.describe('Onboarding', () => {
test.beforeEach(async () => {
await resetEva();
});
test.afterAll(async () => {
await resetEva();
await closePool();
});
test('O-01: non-member Eva is auto-redirected to /onboarding after login', async ({ page }) => {
await loginAs(page, USERS.eva, { waitForCollective: false });
// The root +layout.svelte sends users with zero collectives to /onboarding.
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
// Create-tab form should be visible by default.
await expect(page.getByTestId('collective-name-input')).toBeVisible();
await expect(page.getByTestId('collective-submit')).toBeVisible();
});
test('O-02: happy path — name + emoji → redirect to /lists + sidebar updates', async ({
page
}) => {
await loginAs(page, USERS.eva, { waitForCollective: false });
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
const name = `Eva Home ${Date.now()}`;
await page.getByTestId('collective-name-input').fill(name);
await page.getByRole('button', { name: '🏡', exact: true }).click();
await page.getByTestId('collective-submit').click();
// Landing page after creation.
await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
// Sidebar (desktop) shows the new name with the chosen emoji.
const sidebar = page.getByTestId('desktop-sidebar');
await expect(sidebar.getByText(name, { exact: false })).toBeVisible({ timeout: 10_000 });
await expect(sidebar.getByText('🏡', { exact: false })).toBeVisible();
// localStorage cached the active collective id matching the DB row.
const activeId = await page.evaluate(() => localStorage.getItem('activeCollectiveId'));
expect(activeId).toBeTruthy();
const res = await sql('SELECT id, name, emoji FROM public.collectives WHERE id = $1', [
activeId
]);
expect(res.rowCount).toBe(1);
expect(res.rows[0].name).toBe(name);
expect(res.rows[0].emoji).toBe('🏡');
// Eva is admin of the new collective.
const m = await sql(
'SELECT role FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
[activeId, USERS.eva.id]
);
expect(m.rowCount).toBe(1);
expect(m.rows[0].role).toBe('admin');
});
test('O-03: empty name → submit disabled, no POST fires', async ({ page }) => {
await loginAs(page, USERS.eva, { waitForCollective: false });
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
// With an empty name, the submit button is disabled by `disabled={!collectiveName.trim()}`.
const submit = page.getByTestId('collective-submit');
await expect(submit).toBeDisabled();
// A whitespace-only name should still count as empty.
await page.getByTestId('collective-name-input').fill(' ');
await expect(submit).toBeDisabled();
// No collective rows created for Eva.
const res = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE created_by = $1', [
USERS.eva.id
]);
expect(res.rows[0].n).toBe(0);
});
});

110
apps/web/tests/fixtures/db.ts vendored Normal file
View 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;
}

View File

@@ -9,7 +9,13 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import type { USERS } from './users.js'; 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.goto('/');
await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, { await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
timeout: 15_000 timeout: 15_000
@@ -34,6 +40,12 @@ export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USER
{ timeout: 15_000 } { 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 // Wait for `$currentCollective` to populate — any test that creates rows
// needs this, because `handleCreate` silently returns when collective is // needs this, because `handleCreate` silently returns when collective is
// null. On localhost the round-trip was fast enough that races were rare, // null. On localhost the round-trip was fast enough that races were rare,

View File

@@ -18,6 +18,14 @@ export const USERS = {
role: 'member' as const, role: 'member' as const,
storageState: 'tests/.auth/borja.json' 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: { david: {
id: '44444444-4444-4444-4444-444444444444', id: '44444444-4444-4444-4444-444444444444',
email: 'david@dev.local', email: 'david@dev.local',

View File

@@ -3,6 +3,10 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
@@ -10,11 +14,11 @@
"dependencies": { "dependencies": {
"@colectivo/types": "workspace:*", "@colectivo/types": "workspace:*",
"@supabase/supabase-js": "^2.46.2", "@supabase/supabase-js": "^2.46.2",
"jose": "^5.9.6" "jose": "^5.9.6",
"pg": "^8.13.3"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"pg": "^8.13.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^2.1.8" "vitest": "^2.1.8"

View File

@@ -1,7 +1,19 @@
### Fase 7 — Collective creation flow: fill the E2E gap ### 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.** **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`. **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). 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): **`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). - [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).
- [ ] `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] `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. - [x] `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] `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`. 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` **Fichero:** `apps/web/tests/e2e/onboarding.test.ts`
**Fixture:** Eva (`USERS.eva`), sin colectivo al iniciar (garantizado por `resetEva()` en `beforeEach`). **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. - [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.
- [ ] **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-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). - [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** — 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. - [ ] ~~**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` **Fichero:** `apps/web/tests/e2e/invitation.test.ts`
**Fixtures:** Ana (admin del colectivo semilla) + Eva (no-miembro). `resetEva()` en `beforeEach`. **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`. - [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`.
- [ ] **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-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. - [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.
- [ ] **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-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-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. **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` **Fichero:** `apps/web/tests/e2e/manage-collective.test.ts`
**Fixtures:** Ana (admin), Borja (member), Carmen (member) sobre el colectivo semilla. `restoreSeedMembership()` en `afterAll`. **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-01**~~*Dropped: `/collective/manage` no tiene input de rename. Follow-up abierto.*
- [ ] **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-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. - [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.
- [ ] **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-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. - [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. **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 #### 7.Z Verificación
- [ ] `just test-e2e` → 14 tests nuevos verdes, 0 regresiones en los 46 existentes (total: 60 Playwright). - [x] `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. - [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.
- [ ] Actualizar `CLAUDE.md` con el nuevo total de tests + una línea describiendo la cobertura añadida. - [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.
--- ---

9
pnpm-lock.yaml generated
View File

@@ -36,6 +36,9 @@ importers:
specifier: ^0.9.51 specifier: ^0.9.51
version: 0.9.69(svelte@5.55.3) version: 0.9.69(svelte@5.55.3)
devDependencies: devDependencies:
'@colectivo/test-utils':
specifier: workspace:*
version: link:../../packages/test-utils
'@playwright/test': '@playwright/test':
specifier: ^1.50.0 specifier: ^1.50.0
version: 1.59.1 version: 1.59.1
@@ -117,13 +120,13 @@ importers:
jose: jose:
specifier: ^5.9.6 specifier: ^5.9.6
version: 5.10.0 version: 5.10.0
pg:
specifier: ^8.13.3
version: 8.20.0
devDependencies: devDependencies:
'@types/pg': '@types/pg':
specifier: ^8.11.10 specifier: ^8.11.10
version: 8.20.0 version: 8.20.0
pg:
specifier: ^8.13.3
version: 8.20.0
typescript: typescript:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3

View File

@@ -0,0 +1,55 @@
-- Migration 012: atomic create_collective(name, emoji) RPC.
--
-- Background: creating a collective via two separate REST calls —
-- 1. INSERT INTO collectives ...
-- 2. INSERT INTO collective_members (role='admin') ...
-- — hits two RLS dead-ends:
--
-- a) The first INSERT, when chained with `.select().single()` (the idiomatic
-- supabase-js pattern), triggers a SELECT policy check on the returned
-- row. The SELECT policy is `is_member(id)`, and the creator isn't a
-- member yet — step 2 hasn't happened — so the whole INSERT fails.
-- b) The second INSERT is gated by `is_admin(collective_id)`, and nobody
-- is admin of the newly-created collective yet, so this also fails.
--
-- The intended semantics are "the creator becomes the first admin atomically"
-- — which is exactly what a SECURITY DEFINER function is for.
--
-- This RPC replaces the two-call pattern in the onboarding flow. It runs as
-- the function owner (supabase_admin via search_path = public default grants)
-- so it bypasses RLS for both inserts, and returns the new collective row.
CREATE OR REPLACE FUNCTION public.create_collective(
p_name text,
p_emoji text
)
RETURNS public.collectives
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid := auth.uid();
v_collective public.collectives%ROWTYPE;
BEGIN
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000';
END IF;
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
RAISE EXCEPTION 'name_required' USING ERRCODE = '22023';
END IF;
INSERT INTO public.collectives (name, emoji, created_by)
VALUES (btrim(p_name), coalesce(p_emoji, '🏠'), v_user_id)
RETURNING * INTO v_collective;
INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES (v_collective.id, v_user_id, 'admin');
RETURN v_collective;
END;
$$;
REVOKE ALL ON FUNCTION public.create_collective(text, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.create_collective(text, text) TO authenticated;