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:
@@ -25,6 +25,7 @@
|
||||
"svelte-dnd-action": "^0.9.51"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@colectivo/test-utils": "workspace:*",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.15.1",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
168
apps/web/tests/e2e/invitation.test.ts
Normal file
168
apps/web/tests/e2e/invitation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
apps/web/tests/e2e/manage-collective.test.ts
Normal file
128
apps/web/tests/e2e/manage-collective.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
92
apps/web/tests/e2e/onboarding.test.ts
Normal file
92
apps/web/tests/e2e/onboarding.test.ts
Normal 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
110
apps/web/tests/fixtures/db.ts
vendored
Normal 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;
|
||||
}
|
||||
14
apps/web/tests/fixtures/login.ts
vendored
14
apps/web/tests/fixtures/login.ts
vendored
@@ -9,7 +9,13 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
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.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
|
||||
timeout: 15_000
|
||||
@@ -34,6 +40,12 @@ export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USER
|
||||
{ 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
|
||||
// needs this, because `handleCreate` silently returns when collective is
|
||||
// null. On localhost the round-trip was fast enough that races were rare,
|
||||
|
||||
8
apps/web/tests/fixtures/users.ts
vendored
8
apps/web/tests/fixtures/users.ts
vendored
@@ -18,6 +18,14 @@ export const USERS = {
|
||||
role: 'member' as const,
|
||||
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: {
|
||||
id: '44444444-4444-4444-4444-444444444444',
|
||||
email: 'david@dev.local',
|
||||
|
||||
Reference in New Issue
Block a user