test(lesereisen): TDD red — tighten factories, add journey/selector/ssr tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,16 @@ describe('GeschichtenCard', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
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 () => {
|
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||||
render(GeschichtenCard, {
|
render(GeschichtenCard, {
|
||||||
geschichten: [
|
geschichten: [
|
||||||
|
|||||||
@@ -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 <img onerror> 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('<img src=x onerror="window.__xss=1">');
|
||||||
|
expect(out).not.toContain('onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('plainExcerpt', () => {
|
describe('plainExcerpt', () => {
|
||||||
it('returns full text when under the limit', () => {
|
it('returns full text when under the limit', () => {
|
||||||
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
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');
|
const { default: GeschichtePage } = await import('./+page.svelte');
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const baseGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
id: 'g1',
|
id: 'g1',
|
||||||
title: 'Die Reise nach Berlin',
|
title: 'Die Reise nach Berlin',
|
||||||
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||||
publishedAt: '2026-04-15T10:00:00Z' as string | null,
|
type: 'STORY',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as {
|
status: 'PUBLISHED',
|
||||||
firstName?: string;
|
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||||
lastName?: string;
|
persons: [],
|
||||||
email: string;
|
items: [],
|
||||||
} | null,
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
persons: [] as { id: string; displayName: string }[],
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
items: [] as { id: string; documentId?: string; position: number; note?: string }[],
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +58,7 @@ describe('geschichten/[id] page', () => {
|
|||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
|
||||||
author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -65,10 +66,10 @@ describe('geschichten/[id] page', () => {
|
|||||||
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
|
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, {
|
render(GeschichtePage, {
|
||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
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();
|
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 () => {
|
it('omits the publishedAt suffix when publishedAt is null', async () => {
|
||||||
render(GeschichtePage, {
|
render(GeschichtePage, {
|
||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
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();
|
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
||||||
@@ -108,8 +111,8 @@ describe('geschichten/[id] page', () => {
|
|||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
persons: [
|
persons: [
|
||||||
{ id: 'p1', displayName: 'Helene Schmidt' },
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||||
{ id: 'p2', displayName: 'Karl Müller' }
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -136,7 +139,14 @@ describe('geschichten/[id] page', () => {
|
|||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
geschichte: baseGeschichte({
|
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('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('button', { name: /löschen/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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
76
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
76
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,32 +20,34 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte');
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const baseData = {
|
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||||
initialPersons: [] as { id: string; displayName: string }[]
|
initialPersons: [] as { id: string; displayName: string }[],
|
||||||
};
|
selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
describe('geschichten/new page', () => {
|
describe('geschichten/new page', () => {
|
||||||
it('renders the page heading', async () => {
|
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();
|
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a button (BackButton component)', async () => {
|
it('renders a button (BackButton component)', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||||
|
|
||||||
const buttons = document.querySelectorAll('button');
|
const buttons = document.querySelectorAll('button');
|
||||||
expect(buttons.length).toBeGreaterThan(0);
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render an error banner by default', async () => {
|
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();
|
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the GeschichteEditor child component', async () => {
|
it('renders the GeschichteEditor when selectedType is STORY', async () => {
|
||||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } });
|
||||||
|
|
||||||
// Editor renders inputs/textarea — verify at least one form input is present
|
// Editor renders inputs/textarea — verify at least one form input is present
|
||||||
const inputs = document.querySelectorAll('input, textarea');
|
const inputs = document.querySelectorAll('input, textarea');
|
||||||
@@ -55,12 +57,51 @@ describe('geschichten/new page', () => {
|
|||||||
it('passes initialPersons through to the editor', async () => {
|
it('passes initialPersons through to the editor', async () => {
|
||||||
render(GeschichtenNewPage, {
|
render(GeschichtenNewPage, {
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: baseData({
|
||||||
|
selectedType: 'STORY',
|
||||||
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
|
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => {
|
|||||||
window.history.replaceState({}, '', originalHref);
|
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 () => {
|
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: makeData({
|
data: makeData({
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ describe('geschichten/+ page', () => {
|
|||||||
// No "·" separator before date when no publishedAt
|
// No "·" separator before date when no publishedAt
|
||||||
const titleHeading = document.querySelector('h2');
|
const titleHeading = document.querySelector('h2');
|
||||||
const card = titleHeading?.closest('li');
|
const card = titleHeading?.closest('li');
|
||||||
// The middle paragraph (author line) should not contain "·"
|
|
||||||
expect(card?.textContent).toContain('Anna Schmidt');
|
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 () => {
|
it('omits the body excerpt when body is empty', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user