feat(fase-10): CU-H06 leave collective — RPC + settings UI

Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
  to admin (inline; the existing user-delete trigger does not cover the
  membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
  to the dissolve flow (CU-H08)

Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.

pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:04:43 +02:00
parent 6cb13146f5
commit 2f0847a5a3
6 changed files with 529 additions and 2 deletions

View File

@@ -190,5 +190,35 @@
"settings_theme_system": "System", "settings_theme_system": "System",
"settings_sync_conflicts": "Sync conflicts", "settings_sync_conflicts": "Sync conflicts",
"settings_sync_conflicts_empty": "No conflicts logged.", "settings_sync_conflicts_empty": "No conflicts logged.",
"settings_sync_conflicts_export": "Export JSON" "settings_sync_conflicts_export": "Export JSON",
"settings_collectives": "Your collectives",
"settings_collectives_empty": "You don't belong to any collective.",
"settings_collectives_leave": "Leave",
"settings_collectives_confirm_leave_title": "Leave {name}?",
"settings_collectives_confirm_leave_body": "You will lose access to this collective. The content stays for the other members.",
"settings_collectives_confirm_leave_button": "Leave collective",
"settings_collectives_dissolve_hint": "You are the only member — use 'Dissolve' on the collective page instead.",
"settings_danger_zone": "Danger zone",
"settings_delete_account": "Delete my account",
"settings_delete_account_blurb": "Permanently delete your account and remove your memberships. Content you created stays in the collectives, attributed to a deleted user.",
"settings_delete_account_modal_title": "Delete your account?",
"settings_delete_account_modal_body": "This is permanent. What gets deleted: your account, your memberships, your saved language and theme. What stays: any lists, tasks or notes you created — they remain in their collectives, attributed to a deleted user. If you are the only admin of a collective, the longest-standing member is promoted automatically. Note: your sign-in account at the identity provider is not deleted; you can register again with the same email and a new profile will be created.",
"settings_delete_account_confirm_label": "Type {word} to confirm",
"settings_delete_account_confirm_word": "DELETE",
"settings_delete_account_button": "Delete account permanently",
"settings_delete_account_blocked": "You are the only admin of a collective with members who cannot be promoted. Promote someone first, or remove the other members.",
"common_cancel": "Cancel",
"manage_collective_name_label": "Collective name",
"manage_collective_emoji_label": "Icon",
"manage_collective_save": "Save",
"manage_dissolve_section": "Danger zone",
"manage_dissolve_button": "Dissolve collective",
"manage_dissolve_blurb": "Permanently delete this collective and all its content. This cannot be undone.",
"manage_dissolve_title": "Dissolve {name}?",
"manage_dissolve_warning": "{lists} lists, {tasks} tasks and {notes} notes will be permanently deleted. This is irreversible.",
"manage_dissolve_confirm_label": "Type {name} to confirm",
"manage_dissolve_confirm_button": "Dissolve permanently",
"sidebar_create_collective": "+ New collective",
"create_collective_modal_title": "New collective",
"create_collective_modal_button": "Create"
} }

View File

@@ -190,5 +190,35 @@
"settings_theme_system": "Sistema", "settings_theme_system": "Sistema",
"settings_sync_conflicts": "Conflictos de sincronización", "settings_sync_conflicts": "Conflictos de sincronización",
"settings_sync_conflicts_empty": "Sin conflictos registrados.", "settings_sync_conflicts_empty": "Sin conflictos registrados.",
"settings_sync_conflicts_export": "Exportar JSON" "settings_sync_conflicts_export": "Exportar JSON",
"settings_collectives": "Tus colectivos",
"settings_collectives_empty": "No perteneces a ningún colectivo.",
"settings_collectives_leave": "Abandonar",
"settings_collectives_confirm_leave_title": "¿Abandonar {name}?",
"settings_collectives_confirm_leave_body": "Perderás acceso a este colectivo. El contenido se queda para los demás miembros.",
"settings_collectives_confirm_leave_button": "Abandonar colectivo",
"settings_collectives_dissolve_hint": "Eres el único miembro — usa 'Disolver' en la página del colectivo en su lugar.",
"settings_danger_zone": "Zona peligrosa",
"settings_delete_account": "Eliminar mi cuenta",
"settings_delete_account_blurb": "Elimina permanentemente tu cuenta y todas tus membresías. El contenido que hayas creado se queda en los colectivos, atribuido a un usuario eliminado.",
"settings_delete_account_modal_title": "¿Eliminar tu cuenta?",
"settings_delete_account_modal_body": "Esto es permanente. Lo que se borra: tu cuenta, tus membresías, tu idioma y tema guardados. Lo que se queda: las listas, tareas o notas que hayas creado — se quedan en sus colectivos, atribuidas a un usuario eliminado. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente. Nota: tu cuenta en el proveedor de identidad NO se elimina; puedes volver a registrarte con el mismo email y se creará un perfil nuevo.",
"settings_delete_account_confirm_label": "Escribe {word} para confirmar",
"settings_delete_account_confirm_word": "ELIMINAR",
"settings_delete_account_button": "Eliminar cuenta permanentemente",
"settings_delete_account_blocked": "Eres el único admin de un colectivo con miembros que no pueden ser promovidos. Promueve a alguien primero o elimina a los demás miembros.",
"common_cancel": "Cancelar",
"manage_collective_name_label": "Nombre del colectivo",
"manage_collective_emoji_label": "Icono",
"manage_collective_save": "Guardar",
"manage_dissolve_section": "Zona peligrosa",
"manage_dissolve_button": "Disolver colectivo",
"manage_dissolve_blurb": "Borra permanentemente este colectivo y todo su contenido. No se puede deshacer.",
"manage_dissolve_title": "¿Disolver {name}?",
"manage_dissolve_warning": "Se borrarán {lists} listas, {tasks} tareas y {notes} notas. Esta acción es irreversible.",
"manage_dissolve_confirm_label": "Escribe {name} para confirmar",
"manage_dissolve_confirm_button": "Disolver permanentemente",
"sidebar_create_collective": "+ Nuevo colectivo",
"create_collective_modal_title": "Nuevo colectivo",
"create_collective_modal_button": "Crear"
} }

View File

@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase'; import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth'; import { currentUser } from '$lib/stores/auth';
import { userCollectives, currentCollective } from '$lib/stores/collective';
import { logout } from '$lib/auth'; import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte'; import ImageCropper from '$lib/components/ImageCropper.svelte';
@@ -156,6 +158,93 @@
.update({ language: lang }) .update({ language: lang })
.eq('id', $currentUser!.id); .eq('id', $currentUser!.id);
} }
// ── Fase 10.1: leave collective (CU-H06) ────────────────────────────────
let leaveCandidate = $state<{ id: string; name: string } | null>(null);
let leaveError = $state<string | null>(null);
function startLeave(c: { id: string; name: string }) {
leaveCandidate = c;
leaveError = null;
}
function cancelLeave() {
leaveCandidate = null;
leaveError = null;
}
async function confirmLeave() {
if (!leaveCandidate) return;
const target = leaveCandidate;
const supabase = getSupabase();
const { error } = await supabase.rpc('leave_collective', { p_collective_id: target.id });
if (error) {
// P0001 = sole member → instruct to dissolve instead.
leaveError = (error as { code?: string }).code === 'P0001'
? m.settings_collectives_dissolve_hint()
: error.message;
return;
}
// Update local stores: drop the collective from userCollectives. If it
// was the active one, switch to the next or kick to /onboarding.
const remaining = $userCollectives.filter((c) => c.id !== target.id);
userCollectives.set(remaining);
if ($currentCollective?.id === target.id) {
if (remaining.length > 0) {
const next = remaining[0];
currentCollective.set(next);
localStorage.setItem('activeCollectiveId', next.id);
} else {
currentCollective.set(null);
localStorage.removeItem('activeCollectiveId');
leaveCandidate = null;
await goto('/onboarding');
return;
}
}
leaveCandidate = null;
}
// ── Fase 10.5: delete account ───────────────────────────────────────────
let showDeleteAccount = $state(false);
let deleteConfirmInput = $state('');
let deleteAccountError = $state<string | null>(null);
let deleteInFlight = $state(false);
const deleteConfirmWord = m.settings_delete_account_confirm_word();
const deleteConfirmReady = $derived(deleteConfirmInput === deleteConfirmWord);
function openDeleteAccount() {
showDeleteAccount = true;
deleteConfirmInput = '';
deleteAccountError = null;
}
function closeDeleteAccount() {
showDeleteAccount = false;
deleteConfirmInput = '';
deleteAccountError = null;
}
async function confirmDeleteAccount() {
if (!deleteConfirmReady || deleteInFlight) return;
deleteInFlight = true;
deleteAccountError = null;
const { error } = await getSupabase().rpc('delete_account');
if (error) {
deleteInFlight = false;
deleteAccountError = (error as { code?: string }).code === 'P0003'
? m.settings_delete_account_blocked()
: error.message;
return;
}
// GoTrue row gone; clear local + start logout (RP-initiated) so Keycloak
// session is also ended. /logged-out is the post-logout landing.
await logout();
}
</script> </script>
<div class="flex flex-1 flex-col overflow-hidden"> <div class="flex flex-1 flex-col overflow-hidden">
@@ -280,6 +369,34 @@
import.meta.env.DEV inside the component itself. --> import.meta.env.DEV inside the component itself. -->
<SyncConflictsPanel /> <SyncConflictsPanel />
<!-- Collectives (Fase 10.1) -->
<section>
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.settings_collectives()}
</p>
{#if $userCollectives.length === 0}
<p class="text-sm text-text-secondary">{m.settings_collectives_empty()}</p>
{:else}
<ul>
{#each $userCollectives as c (c.id)}
<li data-testid="collective-row-{c.id}" class="flex items-center gap-3 py-2">
<span class="text-lg">{c.emoji}</span>
<span class="min-w-0 flex-1 truncate text-sm text-slate-900 dark:text-slate-50">{c.name}</span>
<button
type="button"
data-testid="leave-collective-{c.id}"
onclick={() => startLeave(c)}
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.settings_collectives_leave()}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Account — spacing creates the separation, not a border line --> <!-- Account — spacing creates the separation, not a border line -->
<section class="pt-8"> <section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
@@ -300,6 +417,23 @@
{m.settings_logout()} {m.settings_logout()}
</button> </button>
</section> </section>
<!-- Danger zone (Fase 10.5) -->
<section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
{m.settings_danger_zone()}
</p>
<p class="mb-3 text-sm text-text-secondary">{m.settings_delete_account_blurb()}</p>
<button
type="button"
data-testid="delete-account-open"
onclick={openDeleteAccount}
class="rounded-md border border-destructive px-4 py-2 text-sm font-semibold text-destructive
hover:bg-destructive/10"
>
{m.settings_delete_account()}
</button>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -311,3 +445,79 @@
onCancel={() => (cropperFile = null)} onCancel={() => (cropperFile = null)}
/> />
{/if} {/if}
<!-- Leave-collective confirmation modal (Fase 10.1) -->
{#if leaveCandidate}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="leave-modal">
<div class="w-full max-w-sm rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.settings_collectives_confirm_leave_title({ name: leaveCandidate.name })}
</h2>
<p class="mb-4 text-sm text-text-secondary">{m.settings_collectives_confirm_leave_body()}</p>
{#if leaveError}
<p class="mb-3 text-sm text-destructive" data-testid="leave-error">{leaveError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={cancelLeave}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="confirm-leave-collective"
onclick={confirmLeave}
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
>
{m.settings_collectives_confirm_leave_button()}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete-account modal (Fase 10.5) -->
{#if showDeleteAccount}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="delete-account-modal">
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.settings_delete_account_modal_title()}
</h2>
<p class="mb-4 text-sm text-text-secondary">{m.settings_delete_account_modal_body()}</p>
<label for="delete-account-input" class="mb-1 block text-xs font-medium text-text-secondary">
{m.settings_delete_account_confirm_label({ word: deleteConfirmWord })}
</label>
<input
id="delete-account-input"
data-testid="delete-account-confirm-input"
type="text"
bind:value={deleteConfirmInput}
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
{#if deleteAccountError}
<p class="mb-3 text-sm text-destructive" data-testid="delete-account-error">{deleteAccountError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closeDeleteAccount}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="confirm-delete-account"
disabled={!deleteConfirmReady || deleteInFlight}
onclick={confirmDeleteAccount}
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{m.settings_delete_account_button()}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,62 @@
/**
* L-series: /settings → leave collective (Fase 10.1, CU-H06).
*
* Borja (member of the seed collective) abandons it. The collective and its
* content stay; Borja's row in collective_members disappears; Ana — viewing
* /collective/manage — no longer sees Borja in the roster.
*
* Borja is then a no-collective user and is auto-redirected to /onboarding.
*
* Cleanup: re-insert Borja's seed membership in afterAll so downstream suites
* that rely on Borja being a member don't blow up.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';
test.describe.configure({ mode: 'serial' });
test.describe('Leave collective (CU-H06)', () => {
test.afterAll(async () => {
// Restore Borja's seed membership so other suites pass.
await sql(
`INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES ($1, $2, 'member')
ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[COLLECTIVE_ID, USERS.borja.id]
);
await closePool();
});
test('L-01: Borja leaves the seed collective → row gone, lands on onboarding', async ({
page
}) => {
await loginAs(page, USERS.borja);
await page.goto('/settings');
// Settings page renders the collectives section; Borja's seed collective
// has a leave button next to it.
const leaveButton = page.getByTestId(`leave-collective-${COLLECTIVE_ID}`);
await expect(leaveButton).toBeVisible({ timeout: 15_000 });
await leaveButton.click();
// Confirm in the modal.
await page.getByTestId('confirm-leave-collective').click();
// Borja had only one collective; he gets bounced to /onboarding.
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
// DB-side: membership row is gone, but the collective row + content survive.
const m = await sql(
'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
[COLLECTIVE_ID, USERS.borja.id]
);
expect(m.rows[0].n).toBe(0);
const c = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [
COLLECTIVE_ID
]);
expect(c.rows[0].n).toBe(1);
});
});

View File

@@ -0,0 +1,84 @@
-- Migration 017: leave_collective(uuid) RPC — Fase 10.1 (CU-H06).
--
-- A user abandons one of their collectives. The content stays.
--
-- If the user is the only admin and other members exist, the existing
-- `promote_oldest_member_on_admin_leave` trigger fires on DELETE FROM
-- public.users — but that trigger only runs on user-account delete, not
-- on collective_members delete. So we replicate the auto-promote logic
-- inline before removing the membership row.
--
-- If the user is the sole member of the collective, leaving is disallowed:
-- they must dissolve instead (CU-H08, migration 018). Errcode P0001 lets
-- the UI distinguish this case from generic errors and link to the
-- dissolve flow.
CREATE OR REPLACE FUNCTION public.leave_collective(p_collective_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, auth
AS $$
DECLARE
v_user uuid := auth.uid();
v_member_count int;
v_is_admin boolean;
v_other_admins int;
BEGIN
IF v_user IS NULL THEN
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
END IF;
-- Guard: caller must actually be a member of the collective.
IF NOT EXISTS (
SELECT 1 FROM public.collective_members
WHERE collective_id = p_collective_id AND user_id = v_user
) THEN
RAISE EXCEPTION 'not a member' USING ERRCODE = '42501';
END IF;
-- How many members in the collective?
SELECT count(*) INTO v_member_count
FROM public.collective_members
WHERE collective_id = p_collective_id;
IF v_member_count = 1 THEN
-- Sole member — must dissolve, not leave.
RAISE EXCEPTION 'cannot leave as sole member; dissolve instead'
USING ERRCODE = 'P0001';
END IF;
-- Check if the departing user is the only admin.
SELECT role = 'admin' INTO v_is_admin
FROM public.collective_members
WHERE collective_id = p_collective_id AND user_id = v_user;
IF v_is_admin THEN
SELECT count(*) INTO v_other_admins
FROM public.collective_members
WHERE collective_id = p_collective_id
AND user_id <> v_user
AND role = 'admin';
-- If sole admin, promote the oldest remaining (non-departing) member.
IF v_other_admins = 0 THEN
UPDATE public.collective_members
SET role = 'admin'
WHERE (collective_id, user_id) = (
SELECT collective_id, user_id
FROM public.collective_members
WHERE collective_id = p_collective_id
AND user_id <> v_user
ORDER BY joined_at ASC
LIMIT 1
);
END IF;
END IF;
DELETE FROM public.collective_members
WHERE collective_id = p_collective_id AND user_id = v_user;
END;
$$;
REVOKE ALL ON FUNCTION public.leave_collective(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.leave_collective(uuid) TO authenticated;

View File

@@ -0,0 +1,111 @@
-- pgTAP: leave_collective() RPC — Fase 10.1
-- Run with: psql -U postgres -d postgres -f supabase/tests/010_leave_collective.sql
--
-- Covers CU-H06:
-- * normal member abandons OK (membership row gone)
-- * sole admin with other members abandons OK + oldest member auto-promoted
-- * sole member rejected with errcode P0001 (must use dissolve_collective)
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(7);
-- ── Existence ────────────────────────────────────────────────────────────────
SELECT has_function(
'public', 'leave_collective', ARRAY['uuid'],
'public.leave_collective(uuid) RPC exists'
);
-- ── Setup: three test users + two collectives outside seed range ──────────────
INSERT INTO public.users (id, email, display_name, language, avatar_type)
VALUES
('e1000000-0000-0000-0000-000000000001', 'leave-admin@local', 'Leave Admin', 'es', 'initials'),
('e1000000-0000-0000-0000-000000000002', 'leave-member@local', 'Leave Member', 'es', 'initials'),
('e1000000-0000-0000-0000-000000000003', 'leave-solo@local', 'Leave Solo', 'es', 'initials')
ON CONFLICT (id) DO NOTHING;
-- Collective A: admin + member. created_by = Borja (so deleting admin row is safe).
INSERT INTO public.collectives (id, name, emoji, created_by)
VALUES ('cccccccc-1111-1111-1111-111111111111', 'Leave test A', '🚪',
'22222222-2222-2222-2222-222222222222')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
VALUES
('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000001', 'admin', now() - INTERVAL '10 days'),
('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
ON CONFLICT (collective_id, user_id) DO NOTHING;
-- Collective B: sole member only.
INSERT INTO public.collectives (id, name, emoji, created_by)
VALUES ('cccccccc-2222-2222-2222-222222222222', 'Leave test B (solo)', '🚪',
'22222222-2222-2222-2222-222222222222')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES
('cccccccc-2222-2222-2222-222222222222', 'e1000000-0000-0000-0000-000000000003', 'admin')
ON CONFLICT (collective_id, user_id) DO NOTHING;
-- ── Case 1: normal member leaves ──────────────────────────────────────────────
-- Authenticate as leave-member
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000002', true);
SELECT lives_ok(
$$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
'L-01a: member can leave without error'
);
SELECT is(
(SELECT count(*)::int FROM public.collective_members
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
AND user_id = 'e1000000-0000-0000-0000-000000000002'),
0,
'L-01a: membership row removed after leave'
);
-- ── Case 2: sole admin with other members ─────────────────────────────────────
-- Re-add the member to set up sole-admin scenario again.
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
VALUES ('cccccccc-1111-1111-1111-111111111111',
'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role;
-- Authenticate as the admin
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000001', true);
SELECT lives_ok(
$$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
'L-01b: sole admin can leave when other members exist'
);
SELECT is(
(SELECT role::text FROM public.collective_members
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
AND user_id = 'e1000000-0000-0000-0000-000000000002'),
'admin',
'L-01b: remaining member auto-promoted to admin'
);
SELECT is(
(SELECT count(*)::int FROM public.collective_members
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
AND user_id = 'e1000000-0000-0000-0000-000000000001'),
0,
'L-01b: departing admin row removed'
);
-- ── Case 3: sole member tries to leave ────────────────────────────────────────
-- Authenticate as leave-solo
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000003', true);
SELECT throws_ok(
$$ SELECT public.leave_collective('cccccccc-2222-2222-2222-222222222222') $$,
'P0001',
NULL,
'L-01c: sole member rejected with errcode P0001'
);
SELECT * FROM finish();
ROLLBACK;