From 8fea94cb61bab79b396a4d19795ded5e68326692 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:57:28 +0200 Subject: [PATCH] =?UTF-8?q?test(lesereisen):=20TDD=20red=20=E2=80=94=20tig?= =?UTF-8?q?hten=20factories,=20add=20journey/selector/ssr=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichtenCard.svelte.spec.ts | 10 +++ .../src/lib/shared/utils/extractText.spec.ts | 12 +++ .../geschichten/[id]/page.svelte.test.ts | 73 +++++++++++++----- .../geschichten/new/page.server.test.ts | 76 +++++++++++++++++++ .../geschichten/new/page.svelte.test.ts | 61 ++++++++++++--- .../routes/geschichten/page.svelte.spec.ts | 13 ++++ .../routes/geschichten/page.svelte.test.ts | 3 +- 7 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 frontend/src/routes/geschichten/new/page.server.test.ts diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index 90e3bdb7..2aeefc5a 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -121,6 +121,16 @@ describe('GeschichtenCard', () => { expect(link.getAttribute('href')).toBe('/geschichten?personId=p1'); }); + it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => { + render(GeschichtenCard, { + geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }], + personId: 'p1', + personName: 'Franz', + canWrite: false + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + it('renders a plain-text excerpt without HTML markup', async () => { render(GeschichtenCard, { geschichten: [ diff --git a/frontend/src/lib/shared/utils/extractText.spec.ts b/frontend/src/lib/shared/utils/extractText.spec.ts index 404ac5cb..89f12985 100644 --- a/frontend/src/lib/shared/utils/extractText.spec.ts +++ b/frontend/src/lib/shared/utils/extractText.spec.ts @@ -48,6 +48,18 @@ describe('extractText', () => { }); }); +// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project. +// The browser project's DOMParser would silently take the safe branch → false green. +// This test fires the regex fallback specifically (Node has no DOMParser). +describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => { + it('does not emit onerror= in output when given an payload (security regression)', () => { + // plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser). + // SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk. + const out = plainExcerpt(''); + expect(out).not.toContain('onerror='); + }); +}); + describe('plainExcerpt', () => { it('returns full text when under the limit', () => { expect(plainExcerpt('

short

', 80)).toBe('short'); diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index a251ea80..10172d12 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -3,23 +3,26 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import type { components } from '$lib/generated/api'; const { default: GeschichtePage } = await import('./+page.svelte'); afterEach(cleanup); -const baseGeschichte = (overrides: Record = {}) => ({ +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ id: 'g1', title: 'Die Reise nach Berlin', body: '

Im Jahr 1923 fuhr Helene...

', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - items: [] as { id: string; documentId?: string; position: number; note?: string }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); @@ -55,9 +58,7 @@ describe('geschichten/[id] page', () => { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +66,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +87,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +111,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -136,7 +139,14 @@ describe('geschichten/[id] page', () => { props: { data: baseData({ geschichte: baseGeschichte({ - items: [{ id: 'item1', documentId: 'd1', position: 0, note: 'Brief aus 1923' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, + note: 'Brief aus 1923' + } + ] }) }) } @@ -168,4 +178,31 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/new/page.server.test.ts b/frontend/src/routes/geschichten/new/page.server.test.ts new file mode 100644 index 00000000..81663ba9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/page.server.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://backend:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: () => ({ + GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null }) + }) +})); + +import { load } from './+page.server'; + +function makeEvent(search: string, canBlogWrite = true) { + return { + url: new URL(`http://localhost/geschichten/new${search}`), + fetch: vi.fn(), + parent: vi.fn().mockResolvedValue({ canBlogWrite }) + } as never; +} + +describe('geschichten/new load — selectedType validation (security regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns selectedType: STORY for ?type=STORY', async () => { + const result = await load(makeEvent('?type=STORY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => { + const result = await load(makeEvent('?type=JOURNEY')); + expect(result.selectedType).toBe('JOURNEY'); + }); + + it('returns selectedType: null when ?type param is absent', async () => { + const result = await load(makeEvent('')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for invalid ?type param (security regression)', async () => { + const result = await load(makeEvent('?type=ADMIN')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => { + // Strict equality rejects encoded variants; .includes/.startsWith would not. + const result = await load(makeEvent('?type=STORY%00JOURNEY')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => { + // url.searchParams.get() returns the first value; this is intentional and documented. + const result = await load(makeEvent('?type=STORY&type=JOURNEY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => { + const { createApiClient } = await import('$lib/shared/api.server'); + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } }) + } as never); + + const result = await load(makeEvent('?type=STORY&personId=p1')); + expect(result.selectedType).toBe('STORY'); + expect(result.initialPersons).toHaveLength(1); + }); + + it('redirects non-BLOG_WRITE users to /geschichten', async () => { + await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' }); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 8e26a1a2..26a0ad49 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -20,32 +20,34 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte'); afterEach(cleanup); -const baseData = { - initialPersons: [] as { id: string; displayName: string }[] -}; +const baseData = (overrides: Record = {}) => ({ + initialPersons: [] as { id: string; displayName: string }[], + selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null, + ...overrides +}); describe('geschichten/new page', () => { it('renders the page heading', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); }); it('renders a button (BackButton component)', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); const buttons = document.querySelectorAll('button'); expect(buttons.length).toBeGreaterThan(0); }); it('does not render an error banner by default', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); expect(document.querySelector('[role="alert"]')).toBeNull(); }); - it('renders the GeschichteEditor child component', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + it('renders the GeschichteEditor when selectedType is STORY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } }); // Editor renders inputs/textarea — verify at least one form input is present const inputs = document.querySelectorAll('input, textarea'); @@ -55,12 +57,51 @@ describe('geschichten/new page', () => { it('passes initialPersons through to the editor', async () => { render(GeschichtenNewPage, { props: { - data: { + data: baseData({ + selectedType: 'STORY', initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }] - } + }) } }); await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); }); + + it('shows TypeSelector radiogroup when selectedType is null', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + await expect.element(page.getByRole('radiogroup')).toBeVisible(); + }); + + it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); + expect(placeholder).not.toBeNull(); + }); + + it('JOURNEY placeholder offers a return-to-selection link', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const backLink = page.getByRole('link', { name: /andere Auswahl/i }); + await expect.element(backLink).toBeVisible(); + await expect.element(backLink).toHaveAttribute('href', '/geschichten/new'); + }); + + it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + // Select STORY + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await storyCard.click(); + + // Click Weiter + const weiter = page.getByRole('button', { name: /Weiter/i }); + await weiter.click(); + + expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY'); + }); }); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index f5f7621e..5f3a411c 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => { window.history.replaceState({}, '', originalHref); }); + it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'] + }) + }); + + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + }); + it('shows the "+ Person wählen" button even when filters are already active', async () => { render(Page, { data: makeData({ diff --git a/frontend/src/routes/geschichten/page.svelte.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index d5e78ba0..663859b9 100644 --- a/frontend/src/routes/geschichten/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/page.svelte.test.ts @@ -188,8 +188,9 @@ describe('geschichten/+ page', () => { // No "·" separator before date when no publishedAt const titleHeading = document.querySelector('h2'); const card = titleHeading?.closest('li'); - // The middle paragraph (author line) should not contain "·" expect(card?.textContent).toContain('Anna Schmidt'); + // "·" separator must be absent when there is no publishedAt date + expect(card?.textContent).not.toContain('·'); }); it('omits the body excerpt when body is empty', async () => {