import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } 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 => ({ 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: 'FULL', documentDate: '1923-05-15' }, 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.' }), canBlogWrite: false } }); await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible(); }); it('omits intro paragraph when body is null', async () => { render(JourneyReader, { context: ctx(), props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false } }); // 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: ' ' }), canBlogWrite: false } }); // 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: [] }), canBlogWrite: false } }); 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: [] }), canBlogWrite: false } }); 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)] }), canBlogWrite: false } }); 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)] }), canBlogWrite: false } }); await expect.element(page.getByText('Eine Pause.')).toBeVisible(); expect(document.body.textContent).toContain('❦'); }); 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) ] }), canBlogWrite: false } }); 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('clicking delete button calls ondelete prop', async () => { const ondelete = vi.fn().mockResolvedValue(undefined); render(JourneyReader, { context: ctx(), props: { geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }), canBlogWrite: true, ondelete } }); await userEvent.click(page.getByRole('button', { name: /löschen/i })); expect(ondelete).toHaveBeenCalledOnce(); }); 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: '' }), canBlogWrite: false } }); expect(window.__xss_journey).toBeUndefined(); expect(document.body.textContent).toContain('