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