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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user