Compare commits
9 Commits
b698f9f223
...
2ae830a3c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae830a3c8 | ||
|
|
c23fad7dc8 | ||
|
|
11c0d49907 | ||
|
|
da249369ee | ||
|
|
74b13abf53 | ||
|
|
ad535e314b | ||
|
|
18e5d18cc7 | ||
|
|
35ec7e799f | ||
|
|
77ac9a01b5 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ scripts/large-data.sql
|
||||
.vitest-attachments
|
||||
**/test-results/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||
frontend/yarn.lock
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Grant BLOG_WRITE to every existing group that already holds WRITE_ALL.
|
||||
-- Without this, the Geschichten feature ships dark to production: no group
|
||||
-- has BLOG_WRITE, so the editor controls are invisible and "+ Neue Geschichte"
|
||||
-- is never rendered. The natural mapping is "groups that can already write
|
||||
-- documents and tags can also author family stories." Admins can revoke or
|
||||
-- re-assign via the group editor afterwards.
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission)
|
||||
SELECT DISTINCT gp.group_id, 'BLOG_WRITE'
|
||||
FROM group_permissions gp
|
||||
WHERE gp.permission = 'WRITE_ALL'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM group_permissions existing
|
||||
WHERE existing.group_id = gp.group_id
|
||||
AND existing.permission = 'BLOG_WRITE'
|
||||
);
|
||||
78
frontend/e2e/geschichten.spec.ts
Normal file
78
frontend/e2e/geschichten.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
|
||||
* tracked separately; this file proves the core writer + reader journey works
|
||||
* end-to-end against the real stack.
|
||||
*
|
||||
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
|
||||
* the seeded admin user can author. The auth.setup project handles login.
|
||||
*/
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||
|
||||
test.describe('Geschichten — writer + reader journey', () => {
|
||||
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
|
||||
const title = `E2E story ${stamp()}`;
|
||||
|
||||
// Land on the index — empty state or pre-existing demo data is fine
|
||||
await page.goto('/geschichten');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
|
||||
|
||||
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
|
||||
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
|
||||
await page.waitForURL('/geschichten/new');
|
||||
|
||||
// Fill in title — the body editor is Tiptap and harder to script reliably
|
||||
await page.getByPlaceholder('Titel der Geschichte').fill(title);
|
||||
|
||||
// Save as draft and verify we land on the detail page
|
||||
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
|
||||
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
||||
|
||||
// Capture the new id from the URL
|
||||
const detailUrl = page.url();
|
||||
const id = detailUrl.split('/').pop();
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
// Publish from the edit page
|
||||
await page.getByRole('link', { name: 'Bearbeiten' }).click();
|
||||
await page.waitForURL(/\/edit$/);
|
||||
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
|
||||
await page.waitForURL(detailUrl);
|
||||
|
||||
// Index now shows the published story
|
||||
await page.goto('/geschichten');
|
||||
await expect(page.getByRole('link', { name: title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
|
||||
await page.goto('/geschichten');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Use the first story link in the list (demo data exists; if not, the
|
||||
// previous test seeded one). The link wraps the whole card.
|
||||
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
|
||||
await expect(firstStory).toBeVisible();
|
||||
await firstStory.click();
|
||||
|
||||
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
||||
await expect(page.locator('article')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
|
||||
await page.goto('/geschichten');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
|
||||
// Filter to non-deferred severity. We don't gate the whole PR on a clean
|
||||
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
|
||||
// but any "serious" or "critical" finding from this scan would block merge.
|
||||
const blocking = results.violations.filter(
|
||||
(v) => v.impact === 'serious' || v.impact === 'critical'
|
||||
);
|
||||
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Nur lesen",
|
||||
"admin_perm_annotate_all": "Lesen & Annotieren",
|
||||
"admin_perm_write_all": "Lesen & Schreiben",
|
||||
"admin_perm_blog_write": "Geschichten schreiben",
|
||||
"admin_perm_admin": "Vollzugriff (Admin)",
|
||||
"admin_perm_admin_user": "Benutzer verwalten",
|
||||
"admin_perm_admin_tag": "Schlagworte verwalten",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Read only",
|
||||
"admin_perm_annotate_all": "Read & Annotate",
|
||||
"admin_perm_write_all": "Read & Write",
|
||||
"admin_perm_blog_write": "Write stories",
|
||||
"admin_perm_admin": "Full access (Admin)",
|
||||
"admin_perm_admin_user": "Manage users",
|
||||
"admin_perm_admin_tag": "Manage tags",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Solo lectura",
|
||||
"admin_perm_annotate_all": "Leer y anotar",
|
||||
"admin_perm_write_all": "Leer y escribir",
|
||||
"admin_perm_blog_write": "Escribir historias",
|
||||
"admin_perm_admin": "Acceso completo (Admin)",
|
||||
"admin_perm_admin_user": "Gestionar usuarios",
|
||||
"admin_perm_admin_tag": "Gestionar etiquetas",
|
||||
|
||||
@@ -199,7 +199,7 @@ function getFullName(person: Person): string {
|
||||
{#if canBlogWrite && documentId}
|
||||
<a
|
||||
href="/geschichten/new?documentId={documentId}"
|
||||
class="font-sans text-xs font-medium text-ink/60 hover:text-ink"
|
||||
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||
>
|
||||
{m.geschichten_card_attach_action()}
|
||||
</a>
|
||||
|
||||
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
originalFilename: `${title}.pdf`,
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DocumentMultiSelect — rendering', () => {
|
||||
it('renders an empty chip-input by default', async () => {
|
||||
render(DocumentMultiSelect);
|
||||
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pre-selected documents as chips with their date', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
|
||||
});
|
||||
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits a hidden documentIds input for each pre-selected document', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
|
||||
});
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="hidden"][name="documentIds"]'
|
||||
);
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentMultiSelect — search and select', () => {
|
||||
it('queries /api/documents/search after debounce and shows results', async () => {
|
||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||
render(DocumentMultiSelect);
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||
await waitForDebounce();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
|
||||
);
|
||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a chip when a search result is clicked', async () => {
|
||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||
render(DocumentMultiSelect);
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||
await waitForDebounce();
|
||||
await userEvent.click(page.getByText(/Brief von Eugenie/));
|
||||
|
||||
// After selection the search field clears and the chip is rendered
|
||||
const hidden = document.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"][name="documentIds"]'
|
||||
);
|
||||
expect(hidden?.value).toBe('d1');
|
||||
});
|
||||
|
||||
it('hides already-selected documents from new search results', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{ document: docFactory('d1', 'Already attached') },
|
||||
{ document: docFactory('d2', 'Not attached') }
|
||||
]
|
||||
})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'Already attached')]
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
|
||||
await waitForDebounce();
|
||||
|
||||
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
|
||||
const matches = await page.getByText(/Already attached/).all();
|
||||
expect(matches.length).toBe(1); // chip only, not in dropdown
|
||||
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentMultiSelect — remove', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'Brief A')]
|
||||
});
|
||||
await userEvent.click(page.getByLabelText('Entfernen'));
|
||||
expect(
|
||||
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import GeschichteEditor from './GeschichteEditor.svelte';
|
||||
|
||||
const personFactory = (id: string, displayName: string) => ({
|
||||
id,
|
||||
firstName: displayName.split(' ')[0],
|
||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||
displayName,
|
||||
personType: 'PERSON' as const
|
||||
});
|
||||
|
||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
originalFilename: `${title}.pdf`,
|
||||
status: 'UPLOADED' as const,
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Existing draft',
|
||||
body: '<p>Hello world</p>',
|
||||
status: 'DRAFT' as const,
|
||||
persons: [],
|
||||
documents: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichteEditor — title-required guard', () => {
|
||||
it('disables both DRAFT save buttons when the title is empty', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
|
||||
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
|
||||
expect(draft).toHaveProperty('disabled', true);
|
||||
expect(publish).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('shows the inline error after the title field is blurred while empty', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
|
||||
await userEvent.tab(); // blur
|
||||
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — save bar adapts to status', () => {
|
||||
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — pre-fill', () => {
|
||||
it('renders initial persons as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders initial documents as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the title input from a geschichte prop', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ title: 'My existing story' }),
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
const input = await page.getByPlaceholder('Titel der Geschichte').element();
|
||||
expect((input as HTMLInputElement).value).toBe('My existing story');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — onSubmit payload', () => {
|
||||
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.title).toBe('My title');
|
||||
expect(payload.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||
});
|
||||
|
||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
||||
onSubmit
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.personIds).toEqual(['p1']);
|
||||
expect(payload.documentIds).toEqual(['d1']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { plainExcerpt } from '$lib/utils/stripHtml';
|
||||
import { plainExcerpt } from '$lib/utils/extractText';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
@@ -39,14 +39,14 @@ function authorName(g: Geschichte): string {
|
||||
<header class="mb-5 flex items-center justify-between">
|
||||
<h2
|
||||
id="geschichten-card-heading"
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.geschichten_card_heading()}
|
||||
</h2>
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/geschichten/new?personId={personId}"
|
||||
class="inline-flex items-center font-sans text-sm font-medium text-ink/60 hover:text-ink"
|
||||
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
|
||||
>
|
||||
{m.geschichten_card_write_action()}
|
||||
</a>
|
||||
|
||||
120
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
120
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
|
||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
status: 'PUBLISHED' as const,
|
||||
publishedAt: '2024-04-01T12:00:00',
|
||||
createdAt: '2024-03-01T12:00:00',
|
||||
updatedAt: '2024-04-01T12:00:00',
|
||||
persons: [],
|
||||
documents: [],
|
||||
author: {
|
||||
id: 'u1',
|
||||
email: 'marcel@example.com',
|
||||
firstName: 'Marcel',
|
||||
lastName: 'Raddatz',
|
||||
enabled: true,
|
||||
notifyOnReply: false,
|
||||
notifyOnMention: false,
|
||||
groups: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
color: '#000'
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichtenCard', () => {
|
||||
it('renders nothing when geschichten is empty', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: true
|
||||
});
|
||||
// No heading, no list — the entire <section> should not exist
|
||||
expect(
|
||||
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the section heading and stories when geschichten is non-empty', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Erinnerung an Franz' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A story')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
|
||||
expect(writeLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the write-action link only when canWrite is true', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A story')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: true
|
||||
});
|
||||
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
|
||||
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
|
||||
});
|
||||
|
||||
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const overflow = await page.getByText(/Alle Geschichten zu/).all();
|
||||
expect(overflow).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
|
||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||
});
|
||||
|
||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [
|
||||
makeStory(
|
||||
'g1',
|
||||
'Mit HTML',
|
||||
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
|
||||
)
|
||||
],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
// Body excerpt appears once as plain text — no <strong> rendered, no script
|
||||
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
|
||||
expect(document.body.innerHTML).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/utils/extractText.spec.ts
Normal file
67
frontend/src/lib/utils/extractText.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractText, plainExcerpt } from './extractText';
|
||||
|
||||
describe('extractText', () => {
|
||||
it('returns empty string for null/undefined/empty', () => {
|
||||
expect(extractText(null)).toBe('');
|
||||
expect(extractText(undefined)).toBe('');
|
||||
expect(extractText('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and preserves visible text', () => {
|
||||
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('collapses whitespace within and between blocks', () => {
|
||||
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
|
||||
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
|
||||
});
|
||||
|
||||
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
|
||||
// payload as HTML. It is only required to return *some* string. The fact
|
||||
// that it exists is documented as a non-sanitiser; these tests prevent
|
||||
// silent regressions where the function might somehow leak a tag.
|
||||
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
|
||||
it('drops <script> and surfaces only its text content', () => {
|
||||
const out = extractText('<p>ok</p><script>alert(1)</script>');
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).not.toContain('</script>');
|
||||
});
|
||||
|
||||
it('drops <svg/onload> markup', () => {
|
||||
const out = extractText('<svg/onload=alert(1)>');
|
||||
expect(out).not.toContain('<svg');
|
||||
expect(out).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('drops <iframe srcdoc=…> markup', () => {
|
||||
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
|
||||
expect(out).not.toContain('<iframe');
|
||||
expect(out).not.toContain('srcdoc');
|
||||
});
|
||||
|
||||
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
|
||||
const out = extractText('<a href="javascript:alert(1)">click</a>');
|
||||
expect(out).not.toContain('<a ');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainExcerpt', () => {
|
||||
it('returns full text when under the limit', () => {
|
||||
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||
});
|
||||
|
||||
it('truncates at the boundary with an ellipsis', () => {
|
||||
const html = '<p>' + 'a'.repeat(100) + '</p>';
|
||||
const out = plainExcerpt(html, 20);
|
||||
expect(out.length).toBeLessThanOrEqual(21);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('breaks at a word boundary when possible', () => {
|
||||
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
|
||||
expect(out).toBe('The quick brown…');
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/utils/extractText.ts
Normal file
38
frontend/src/lib/utils/extractText.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* **Not a sanitizer.** This module extracts visible text from a (presumed
|
||||
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
|
||||
* because the Geschichte body is sanitised against the OWASP allow-list
|
||||
* on the server before persistence, and via DOMPurify on render.
|
||||
*
|
||||
* Do not use these helpers to defend against XSS — `safeHtml()` in
|
||||
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
|
||||
* untrusted input that has not been sanitised does not protect against
|
||||
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip tags and return plain text. Uses DOMParser in the browser; on the
|
||||
* server it falls back to a regex that drops angle-bracket sequences.
|
||||
* The fallback is **not** a sanitiser — see module docstring.
|
||||
*/
|
||||
export function extractText(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
if (typeof DOMParser === 'function') {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tags then truncate to `max` chars on a word boundary, appending an
|
||||
* ellipsis when truncated. Used for editorial story excerpts.
|
||||
*/
|
||||
export function plainExcerpt(html: string | null | undefined, max = 80): string {
|
||||
const text = extractText(html);
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plainExcerpt, stripHtml } from './stripHtml';
|
||||
|
||||
describe('stripHtml', () => {
|
||||
it('returns empty string for null/undefined/empty', () => {
|
||||
expect(stripHtml(null)).toBe('');
|
||||
expect(stripHtml(undefined)).toBe('');
|
||||
expect(stripHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and preserves visible text', () => {
|
||||
expect(stripHtml('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('strips nested HTML', () => {
|
||||
expect(stripHtml('<div><p>A</p><p>B</p></div>')).toBe('AB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainExcerpt', () => {
|
||||
it('returns full text when under the limit', () => {
|
||||
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||
});
|
||||
|
||||
it('truncates at the boundary with an ellipsis', () => {
|
||||
const html = '<p>' + 'a'.repeat(100) + '</p>';
|
||||
const out = plainExcerpt(html, 20);
|
||||
expect(out.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('breaks at a word boundary when possible', () => {
|
||||
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
|
||||
expect(out).toBe('The quick brown…');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Strip HTML tags from a string and return the plain text.
|
||||
* Uses DOMParser in the browser, falls back to a regex strip on the server
|
||||
* (where DOMParser is not available without isomorphic-dompurify's JSDOM).
|
||||
*/
|
||||
export function stripHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
if (typeof DOMParser === 'function') {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (doc.body.textContent ?? '').trim();
|
||||
}
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML and truncate to a maximum length, appending an ellipsis when
|
||||
* the source exceeds it. Used for editorial story excerpts.
|
||||
*/
|
||||
export function plainExcerpt(html: string | null | undefined, max = 80): string {
|
||||
const text = stripHtml(html);
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
|
||||
}
|
||||
@@ -27,7 +27,8 @@ $effect(() => {
|
||||
const STANDARD_PERMISSIONS = $derived([
|
||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
|
||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
|
||||
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
|
||||
]);
|
||||
|
||||
const ADMIN_PERMISSIONS = $derived([
|
||||
|
||||
@@ -6,7 +6,8 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
const availableStandard = $derived([
|
||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
|
||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
|
||||
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
|
||||
]);
|
||||
const availableAdmin = $derived([
|
||||
{ value: 'ADMIN', label: m.admin_perm_admin() },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/utils/stripHtml';
|
||||
import { plainExcerpt } from '$lib/utils/extractText';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -58,8 +58,17 @@ async function handleDelete() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="prose font-serif text-lg leading-relaxed text-ink">
|
||||
<!-- Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list -->
|
||||
<!--
|
||||
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
||||
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
||||
and produces a much narrower column inside an already narrow page, which
|
||||
Leonie flagged as unreadable for the senior-author persona.
|
||||
|
||||
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
||||
-->
|
||||
<div
|
||||
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html sanitized}
|
||||
</div>
|
||||
@@ -68,7 +77,7 @@ async function handleDelete() {
|
||||
<!-- Personen -->
|
||||
{#if g.persons && g.persons.length > 0}
|
||||
<section class="mt-10 border-t border-line pt-6">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.geschichten_persons_section()}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
@@ -89,7 +98,7 @@ async function handleDelete() {
|
||||
<!-- Dokumente -->
|
||||
{#if g.documents && g.documents.length > 0}
|
||||
<section class="mt-8 border-t border-line pt-6">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.geschichten_documents_section()}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
|
||||
2794
frontend/yarn.lock
2794
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user