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

@@ -27,8 +27,17 @@
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
const isAdmin = $derived(myRole === 'admin');
onMount(async () => {
await loadMembers();
onMount(() => {
// 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() {
@@ -163,7 +172,7 @@
{:else}
<ul>
{#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
name={member.display_name}
type={member.avatar_type}
@@ -182,6 +191,7 @@
{#if isAdmin && member.user_id !== $currentUser?.id}
<select
data-testid="role-select-{member.user_id}"
value={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
@@ -192,6 +202,7 @@
{/each}
</select>
<button
data-testid="remove-member-{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
dark:hover:bg-red-900/20"
@@ -230,6 +241,7 @@
</div>
<button
data-testid="generate-invite"
onclick={generateInviteLink}
disabled={generating}
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}
<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}
</code>
<button

View File

@@ -38,7 +38,14 @@
// token refreshes or when the user is already on a real route.
const path = $page.url.pathname;
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');
} else {
goto('/lists');

View File

@@ -3,8 +3,9 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { isAuthenticated } from '$lib/stores/auth';
import { authLoading, isAuthenticated } from '$lib/stores/auth';
import { login } from '$lib/auth';
import { get } from 'svelte/store';
import * as m from '$lib/paraglide/messages';
type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success';
@@ -20,7 +21,23 @@
status = 'error';
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 /.
// We store the invitation token so we can resume after login.
sessionStorage.setItem('pendingInvitationToken', token);
@@ -77,6 +94,7 @@
</h1>
<p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p>
<button
data-testid="invitation-accept"
onclick={accept}
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"
@@ -95,7 +113,7 @@
{:else if status === 'error'}
<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>
{/if}
</div>

View File

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