dorfteich/apps/web/e2e/members.spec.ts
Claude Opus 4.8 7f1c49db53
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m29s
CI / Auth e2e pack (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Add pond member management UI (#54)
Pond Admins manage who participates in a pond, by role, with editor/reader
seat quotas — the member-facing layer over the grant model (#51/#52).

- shared: `MemberView`/`PondMembersView` + add/change-role schemas
  (`members.ts`), a `members` i18n namespace (de+en), and member error codes.
- api `members/`: a member-centric API over pond-scope user grants —
  `GET /ponds/:id/members` (any member, for transparency: list grouped by
  effective role + seat usage + `canManage`), `POST` (add by exact username or
  e-mail — no directory browsing), `PATCH :userId` (change role), `DELETE
  :userId` (remove), all Pond-Admin-gated by the guard. Editor/reader seats are
  enforced against `editors_per_pond`/`readers_per_pond` (#22) inside a
  per-pond advisory-locked transaction so counts cannot race; the owner's
  membership is protected, personal ponds refuse a second admin (shared grant
  rule), and the last Pond Admin cannot be dropped. Every change invalidates
  the pond permission cache and fires the access NOTIFY (#39/#53).
- web `members/`: `MemberManager` in Pond Settings — list grouped by role with
  a search filter and seat usage, add-by-identifier form (disabled with a
  localized explanation when the chosen role's seats are full), per-member role
  change and remove; read-only for non-admins; the personal-pond rule is
  surfaced. There is no invitation flow (v1): adding is immediate, and the copy
  says so.
- tests: `members.e2e.db.test.ts` (add/change/remove, seat exhaustion,
  personal-pond and owner rules, read-only transparency, last-admin) and a
  `members` browser pack (immediate second-browser access, quota disables the
  add action, non-admin read-only) with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 20:22:52 +02:00

101 lines
4.3 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Pond member management UI (issue #54): adding takes effect immediately (no
* invitation), seat quotas disable the add action, and non-admin members see
* the list read-only. Uses a second regular account (`fixture-editor`) as the
* member whose access the owner (`fixture-user`) manages.
*/
async function createSharedPond(owner: BrowserContext): Promise<{ id: string; slug: string }> {
const res = await owner.request.post('/api/v1/ponds', {
data: { name: `Members ${Date.now()}` },
});
if (!res.ok()) throw new Error(`create pond failed: ${res.status()} ${await res.text()}`);
return (await res.json()) as { id: string; slug: string };
}
/** Site-admin only: change the instance-wide editor seat default. */
async function setEditorQuota(admin: BrowserContext, value: number): Promise<void> {
const res = await admin.request.patch('/api/v1/admin/settings', {
data: { 'quota.editorsPerPond': value },
});
if (!res.ok()) throw new Error(`patch settings failed: ${res.status()} ${await res.text()}`);
}
test('adding a member takes effect immediately (second browser)', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const invitee = await contextForUser(browser, BASE_URL, 'fixture-editor');
const pond = await createSharedPond(owner);
// Not a member yet: the pond hides itself from the invitee.
expect((await invitee.request.get(`/api/v1/ponds/${pond.slug}`)).status()).toBe(404);
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
await page.locator('.member-add__identifier').fill('fixture-editor');
await page.locator('.member-add__role').selectOption('reader');
await page.locator('.member-add__submit').click();
await expect(page.locator('.member-row[data-username="fixture-editor"]')).toBeVisible();
// Access is immediate for the second browser — no acceptance step.
await expect
.poll(async () => (await invitee.request.get(`/api/v1/ponds/${pond.slug}`)).status())
.toBe(200);
await owner.close();
await invitee.close();
});
test('quota exhaustion disables the add action with an explanation', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
try {
await setEditorQuota(admin, 1);
const pond = await createSharedPond(owner);
const page = await owner.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// Fill the single editor seat.
await page.locator('.member-add__identifier').fill('fixture-editor');
await page.locator('.member-add__role').selectOption('editor');
await page.locator('.member-add__submit').click();
await expect(page.locator('.member-row[data-username="fixture-editor"]')).toBeVisible();
// With the seat full, choosing the editor role blocks any further add.
await page.locator('.member-add__role').selectOption('editor');
await expect(page.locator('.member-add__submit')).toBeDisabled();
await expect(page.locator('.member-add__quota-full')).toBeVisible();
} finally {
await setEditorQuota(admin, 5);
await admin.close();
await owner.close();
}
});
test('non-admin members see the member list read-only', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const member = await contextForUser(browser, BASE_URL, 'fixture-editor');
const pond = await createSharedPond(owner);
await owner.request.post(`/api/v1/ponds/${pond.id}/members`, {
data: { usernameOrEmail: 'fixture-editor', role: 'reader' },
});
const page = await member.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// The membership is transparent — the owner is listed…
await expect(page.locator('.member-row[data-username="fixture-user"]')).toBeVisible();
// …but a non-admin gets no add form and no per-member controls.
await expect(page.locator('.member-add')).toHaveCount(0);
await expect(page.locator('.member-row__remove')).toHaveCount(0);
await expect(page.locator('.member-manager__note')).toBeVisible();
await owner.close();
await member.close();
});