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).
129 lines
4.1 KiB
TypeScript
129 lines
4.1 KiB
TypeScript
/**
|
|
* 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]);
|
|
});
|
|
});
|