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