feat(fase-10): CU-H08 dissolve collective — RPC + danger-zone UI

Migration 018 adds public.dissolve_collective(uuid):
- admin-only; non-admin (member/guest/non-member) → errcode P0002
- single DELETE from public.collectives; all child tables cascade
  (collective_members, collective_invitations, shopping_lists →
  shopping_items, task_lists → tasks, notes, item_frequency,
  sync_conflicts — verified by audit, no schema change needed)

/collective/manage gains a "Danger zone" card (admin-only) with a
Dissolve button. The confirmation modal loads live counts of lists,
tasks and notes, displays them in the warning, and requires the user
to type the collective name exactly before the confirm button is
enabled. On success, locals stores drop the collective and the user
is sent to /lists (next collective) or /onboarding (none left).

pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all
rejected with P0002, admin succeeds, collective row gone, child
list cascade-deleted, members cascade-deleted. Playwright D-01..D-03
cover the happy path, the type-the-name guard, and member visibility.

Auxiliary collective fixture (NOT the seed) per test — the seed
collective must survive every run for downstream suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:11:21 +02:00
parent 5e2ba3a6d6
commit 83f613ec1e
4 changed files with 402 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
/**
* D-series: /collective/manage → dissolve (Fase 10.3, CU-H08).
*
* ⚠️ This suite must NOT dissolve the seed collective. We create an auxiliary
* collective per test, manipulate it, then either let the test dissolve it
* (D-01) or clean it up in afterEach.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { closePool, sql } from '@colectivo/test-utils';
test.describe.configure({ mode: 'serial' });
const aux: { id: string | null } = { id: null };
const auxName = `Aux dissolve ${Date.now()}`;
/**
* Create an auxiliary collective owned (created_by) by Borja but with Ana
* as the sole admin (Borja is dropped from membership). This lets Ana
* dissolve it from the UI without touching the seed.
*/
async function createAuxCollective(): Promise<string> {
const r = await sql(
`INSERT INTO public.collectives (name, emoji, created_by)
VALUES ($1, '💥', $2) RETURNING id`,
[auxName, USERS.borja.id]
);
const id = r.rows[0].id as string;
await sql(
`INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES ($1, $2, 'admin'), ($1, $3, 'member')`,
[id, USERS.ana.id, USERS.borja.id]
);
return id;
}
test.describe('Dissolve collective (CU-H08)', () => {
test.afterEach(async () => {
if (aux.id) {
// Best-effort cleanup if the test didn't dissolve it.
await sql('DELETE FROM public.collectives WHERE id = $1', [aux.id]);
aux.id = null;
}
});
test.afterAll(async () => {
await closePool();
});
test('D-03: Borja (member) does NOT see the dissolve button', async ({ page }) => {
aux.id = await createAuxCollective();
await loginAs(page, USERS.borja);
// Switch active collective to the aux one in localStorage, then reload.
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
await page.goto('/collective/manage');
// Make sure the page is hydrated by waiting on a known member row.
await expect(page.getByTestId(`member-row-${USERS.ana.id}`)).toBeVisible({ timeout: 15_000 });
// Borja must not see the dissolve button (admin-gated).
await expect(page.getByTestId('dissolve-collective-button')).toHaveCount(0);
});
test('D-02: dissolve button disabled until input matches collective name exactly', async ({
page
}) => {
aux.id = await createAuxCollective();
await loginAs(page, USERS.ana);
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
await page.goto('/collective/manage');
await page.getByTestId('dissolve-collective-button').click();
const confirm = page.getByTestId('dissolve-confirm-button');
await expect(confirm).toBeDisabled();
await page.getByTestId('dissolve-confirm-input').fill('wrong name');
await expect(confirm).toBeDisabled();
await page.getByTestId('dissolve-confirm-input').fill(auxName);
await expect(confirm).toBeEnabled();
});
test('D-01: Ana dissolves auxiliary collective → redirect + row gone', async ({ page }) => {
aux.id = await createAuxCollective();
await loginAs(page, USERS.ana);
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
await page.goto('/collective/manage');
await page.getByTestId('dissolve-collective-button').click();
await page.getByTestId('dissolve-confirm-input').fill(auxName);
await page.getByTestId('dissolve-confirm-button').click();
// After dissolve we either land on /lists (other collectives remain) or
// /onboarding (no collectives). Ana still has the seed one, so /lists.
await expect(page).toHaveURL(/\/(lists|onboarding)$/, { timeout: 15_000 });
const r = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [
aux.id
]);
expect(r.rows[0].n).toBe(0);
// Test cleanup already happened via cascade.
aux.id = null;
});
});