All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m49s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m7s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
Intro, letter titles, curator annotations, and interludes rendered at 12-14px — copied from the scaled mockup sizes in LR-2. Narrative text is now 16-18px, meta lines and links 14px; the LR-2 impl-ref table is updated to match. Closes #800 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
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: JourneyReader } = await import('./JourneyReader.svelte');
|
|
|
|
afterEach(cleanup);
|
|
|
|
declare global {
|
|
interface Window {
|
|
__xss_journey?: number;
|
|
}
|
|
}
|
|
|
|
type GeschichteView = components['schemas']['GeschichteView'];
|
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
|
|
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
|
id: 'g1',
|
|
title: 'Lesereise Berlin',
|
|
body: null as unknown as undefined,
|
|
type: 'JOURNEY',
|
|
status: 'PUBLISHED',
|
|
persons: [],
|
|
items: [],
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
...overrides
|
|
});
|
|
|
|
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
|
|
id,
|
|
position,
|
|
document: {
|
|
id: `d${id}`,
|
|
title,
|
|
datePrecision: 'DAY',
|
|
documentDate: '1923-05-15',
|
|
receiverCount: 0
|
|
},
|
|
note
|
|
});
|
|
|
|
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
|
|
id,
|
|
position,
|
|
document: undefined,
|
|
note
|
|
});
|
|
|
|
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
|
|
|
describe('JourneyReader', () => {
|
|
it('renders intro paragraph when body is non-empty', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
|
});
|
|
|
|
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
|
|
});
|
|
|
|
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
|
});
|
|
|
|
const intro = document.querySelector('p');
|
|
expect(intro!.className).toContain('text-lg');
|
|
expect(intro!.className).not.toContain('text-sm');
|
|
});
|
|
|
|
it('omits intro paragraph when body is null', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: { geschichte: baseGeschichte({ body: undefined }) }
|
|
});
|
|
|
|
// Only empty state should render
|
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
|
});
|
|
|
|
it('omits intro paragraph when body is only whitespace', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: { geschichte: baseGeschichte({ body: ' ' }) }
|
|
});
|
|
|
|
// Whitespace-only body must NOT produce a visible intro paragraph.
|
|
// The only rendered content should be the empty-state message.
|
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
|
const paragraphs = document.querySelectorAll('p:not([data-testid])');
|
|
expect(paragraphs.length).toBe(0);
|
|
});
|
|
|
|
it('renders empty-state message when items array is empty', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: { geschichte: baseGeschichte({ items: [] }) }
|
|
});
|
|
|
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
|
});
|
|
|
|
it('renders both intro and empty-state when body is set but items is empty', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: {
|
|
geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] })
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
|
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
|
});
|
|
|
|
it('renders document items (JourneyItemCard)', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: {
|
|
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] })
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
|
});
|
|
|
|
it('renders interlude items (JourneyInterlude)', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: {
|
|
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] })
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
|
|
});
|
|
|
|
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: {
|
|
geschichte: baseGeschichte({
|
|
items: [
|
|
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
|
|
docItem('item2', 'Echter Brief', 1)
|
|
]
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Echter Brief')).toBeVisible();
|
|
// Empty-state must NOT render when valid items exist
|
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
|
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
|
render(JourneyReader, {
|
|
context: ctx(),
|
|
props: {
|
|
geschichte: baseGeschichte({
|
|
body: '<img src=x onerror="window.__xss_journey=1">'
|
|
})
|
|
}
|
|
});
|
|
|
|
expect(window.__xss_journey).toBeUndefined();
|
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
|
});
|
|
});
|