dorfteich/apps/web/e2e/comments.spec.ts
Claude Fable 5 f014a61480 #133 Kommentare fest inline im Lesemodus (Slide-in-Panel ablösen)
Kommentare erscheinen jetzt fest im Lesefluss zwischen Backlinks und
lokalem Graph statt in einem ein-/ausblendbaren Panel. Der
Kopfleisten-Toggle (Icon + Unread-Badge) entfällt.

Frontend:
- CommentsPanel → CommentsSection (Inline-Sektion, ohne Panel-Chrome/
  Close-Knopf; markiert beim Sichtbarwerden als gelesen). Neue
  Read-only-Variante PublicComments für die anonyme öffentliche Ansicht.
- Umzug auf die äußere Ebene in PageEditorPage (view-Modus, zwischen
  BacklinksPanel und LocalGraphPanel). Das Schreibrecht (collab rw) wird
  per onWriteAccess aus dem inneren PageEditor hochgereicht, damit die
  äußere Ebene den Composer bei commentPolicy=editors korrekt zeigt/
  verbirgt.
- Deep-Link ?comments=1 scrollt jetzt zur Inline-Sektion statt ein Panel
  zu öffnen. Resolve/Unresolve-Knöpfe zusätzlich an mayComment gekoppelt
  (früher nur an isRoot) — Leser sehen keine 403-Knöpfe mehr; Read-only
  blendet alle Aktions-Controls aus.
- CSS comments-panel* → comments-section*; tote Unread-Badge-Regeln raus.

Backend:
- GET /public/:pondSlug/:pageSlug/comments (@Public), read-only. Nutzt den
  vorhandenen resolve()-Pfad (erzwingt ggf. anonymen Lesezugriff → nicht
  öffentliche Seiten 404en) und CommentsService.list. PublicModule
  importiert CommentsModule.

Tests: public.e2e.db.test.ts um anonymen Kommentar-Lesezugriff + 404-Fälle
ergänzt (grün gegen frische Test-DB); comments.spec.ts auf die Inline-UI
umgestellt. typecheck/lint/i18n:check grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 01:22:22 +02:00

139 lines
6.3 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Comments UI (issues #92, #133): the discussion is a fixed inline section in
* the read view (no toggle), between the backlinks and the local graph. The
* pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
* section, and the permission variant (composer hidden with a hint). It
* provisions its own page and grant, so it is repeatable.
*/
let pondId: string;
let pageUrl: string;
// The pack provisions its OWN pond: the shared fixture pond accumulates
// grants from earlier packs in the same CI job (access-rules & friends), so
// "fixture-editor is only a reader here" would not hold there. The seed
// already gives fixture users additional-pond headroom (override 100) —
// never lower it here: in CI the earlier packs' ponds count against it.
test.beforeAll(async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const pondRes = await owner.request.post('/api/v1/ponds', {
data: { name: `Comments stage ${Date.now()}` },
});
if (!pondRes.ok()) {
throw new Error(`create pond failed: ${pondRes.status()} ${await pondRes.text()}`);
}
const pond = (await pondRes.json()) as { id: string; slug: string };
pondId = pond.id;
const created = await owner.request.post(`/api/v1/ponds/${pondId}/pages`, {
data: { title: 'Discussion' },
});
expect(created.ok()).toBe(true);
const page = (await created.json()) as { slug: string };
pageUrl = `/p/${pond.slug}/${page.slug}`;
// fixture-editor is exactly a reader in this fresh pond — nothing else.
const member = await owner.request.post(`/api/v1/ponds/${pondId}/members`, {
data: { usernameOrEmail: 'fixture-editor', role: 'reader' },
});
expect(member.ok()).toBe(true);
await owner.close();
});
test.afterAll(async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
if (pondId) await owner.request.delete(`/api/v1/ponds/${pondId}`);
await owner.close();
});
test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => {
// The reader starts the thread — the section is inline in the read view.
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const readerPage = await reader.newPage();
await readerPage.goto(pageUrl);
const readerPanel = readerPage.locator('.comments-section');
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
await readerPanel.locator('.comments-composer button[type="submit"]').click();
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
// The owner opens the page and sees the thread inline, replies, resolves.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ownerPage = await owner.newPage();
await ownerPage.goto(pageUrl);
const ownerPanel = ownerPage.locator('.comments-section');
await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
await ownerPanel
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
.click();
await ownerPanel.locator('.comments-thread textarea').fill('Yes, shipping it.');
await ownerPanel.locator('.comments-thread button[type="submit"]').click();
await expect(ownerPanel.locator('.comments-thread__replies .comment__body')).toContainText(
'shipping',
);
// Resolve collapses the thread into the resolved section.
await ownerPanel
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
.first()
.click();
const resolvedSection = ownerPanel.locator('.comments-section__resolved');
await expect(resolvedSection).toBeVisible();
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
// Collapsed: the thread body is hidden until the section is opened.
await expect(resolvedSection.locator('.comment__body strong')).toBeHidden();
await resolvedSection.locator('summary').click();
await expect(resolvedSection.locator('.comment__body strong')).toBeVisible();
// Unresolve restores it to the open list.
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click();
await expect(ownerPanel.locator('.comments-section__resolved')).toHaveCount(0);
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
// Author edits and deletes own reply.
const replyItem = ownerPanel.locator('.comments-thread__replies .comment');
await replyItem.locator('.comments-link-button', { hasText: /edit|bearbeit/i }).click();
await replyItem.locator('textarea').fill('Yes — shipped.');
await replyItem.locator('button[type="submit"]').click();
await expect(replyItem.locator('.comment__body')).toContainText('shipped.');
await expect(replyItem.locator('.comment__edited')).toBeVisible();
await replyItem.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(ownerPanel.locator('.comments-thread__replies .comment')).toHaveCount(0);
// Cleanup: the reader deletes their own (now reply-free) root.
await readerPage.reload();
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(readerPanel.locator('.comments-section__empty')).toBeVisible();
await reader.close();
await owner.close();
});
test('the composer hides with a hint when the policy bars readers', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const patched = await owner.request.patch(`/api/v1/ponds/${pondId}`, {
data: { commentPolicy: 'editors' },
});
expect(patched.ok()).toBe(true);
await owner.close();
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const page = await reader.newPage();
await page.goto(pageUrl);
const panel = page.locator('.comments-section');
await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-composer')).toHaveCount(0);
await reader.close();
const ownerAgain = await contextForUser(browser, BASE_URL, 'fixture-user');
await ownerAgain.request.patch(`/api/v1/ponds/${pondId}`, {
data: { commentPolicy: 'readers' },
});
await ownerAgain.close();
});