All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Importing layout.css in test-setup.ts activated Tailwind's responsive
breakpoint classes (hidden lg:flex, hidden md:block, etc.), making
42 elements invisible at the default narrow Playwright test viewport.
Revert the CSS import. Instead, add inline style attributes to the three
components whose tests measure computed properties (min-height, font-size)
— these values match what the Tailwind classes produce, so the real app
appearance is unchanged.
Also fix goto mock leakage in the geschichten/[id] delete-failure test:
the delete-success test's goto('/geschichten') call was not cleared before
the failure test ran. Add beforeEach(vi.clearAllMocks) to reset mock state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
8.7 KiB
TypeScript
278 lines
8.7 KiB
TypeScript
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import { page, userEvent } from 'vitest/browser';
|
|
|
|
vi.mock('$app/navigation', () => ({
|
|
beforeNavigate: () => {},
|
|
afterNavigate: () => {},
|
|
goto: vi.fn(),
|
|
invalidate: vi.fn(),
|
|
invalidateAll: vi.fn(),
|
|
preloadCode: vi.fn(),
|
|
preloadData: vi.fn(),
|
|
pushState: vi.fn(),
|
|
replaceState: vi.fn(),
|
|
disableScrollHandling: vi.fn(),
|
|
onNavigate: () => () => {}
|
|
}));
|
|
|
|
vi.mock('$lib/shared/cookies', () => ({
|
|
csrfFetch: vi.fn()
|
|
}));
|
|
|
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
|
import { csrfFetch } from '$lib/shared/cookies';
|
|
import { goto } from '$app/navigation';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
const { default: GeschichtePage } = await import('./+page.svelte');
|
|
|
|
afterEach(cleanup);
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
type GeschichteView = components['schemas']['GeschichteView'];
|
|
|
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
|
id: 'g1',
|
|
title: 'Die Reise nach Berlin',
|
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
|
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
|
|
});
|
|
|
|
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
|
geschichte: baseGeschichte(),
|
|
canBlogWrite: false,
|
|
...overrides
|
|
});
|
|
|
|
describe('geschichten/[id] page', () => {
|
|
it('renders the geschichte title as the level-1 heading', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData() }
|
|
});
|
|
|
|
await expect
|
|
.element(page.getByRole('heading', { level: 1, name: /reise nach berlin/i }))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it('renders the author full name from firstName + lastName', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData() }
|
|
});
|
|
|
|
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
|
});
|
|
|
|
it('falls back to author email when no name is set', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: {
|
|
data: baseData({
|
|
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
|
|
});
|
|
|
|
it('renders an empty author when author is absent', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) }
|
|
});
|
|
|
|
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
});
|
|
|
|
it('renders the publishedAt date suffix when publishedAt is set', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData() }
|
|
});
|
|
|
|
await expect.element(page.getByText(/veröffentlicht am/i)).toBeVisible();
|
|
});
|
|
|
|
it('omits the publishedAt suffix when publishedAt is null', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: {
|
|
data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) })
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('omits the persons section when there are no linked persons', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData() }
|
|
});
|
|
|
|
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the persons section when there are linked persons', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: {
|
|
data: baseData({
|
|
geschichte: baseGeschichte({
|
|
persons: [
|
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
|
]
|
|
})
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible();
|
|
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
|
|
await expect.element(page.getByText('Karl Müller')).toBeVisible();
|
|
});
|
|
|
|
it('omits the documents section when there are no linked documents', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData() }
|
|
});
|
|
|
|
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the documents section when there are linked journey items', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: {
|
|
data: baseData({
|
|
geschichte: baseGeschichte({
|
|
items: [
|
|
{
|
|
id: 'item1',
|
|
position: 0,
|
|
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' },
|
|
note: 'Brief aus 1923'
|
|
}
|
|
]
|
|
})
|
|
})
|
|
}
|
|
});
|
|
|
|
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
|
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
|
|
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
|
|
});
|
|
|
|
it('renders edit and delete actions when canBlogWrite is true', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData({ canBlogWrite: true }) }
|
|
});
|
|
|
|
await expect
|
|
.element(page.getByRole('link', { name: /bearbeiten/i }))
|
|
.toHaveAttribute('href', '/geschichten/g1/edit');
|
|
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
|
|
});
|
|
|
|
it('hides edit and delete actions when canBlogWrite is false', async () => {
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
|
props: { data: baseData({ canBlogWrite: false }) }
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => {
|
|
vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
|
const confirmService = createConfirmService();
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, confirmService]]),
|
|
props: { data: baseData({ canBlogWrite: true }) }
|
|
});
|
|
|
|
// Trigger delete — opens confirm dialog
|
|
const deleteBtn = page.getByRole('button', { name: /löschen/i });
|
|
await userEvent.click(deleteBtn);
|
|
|
|
// Settle the confirmation dialog
|
|
confirmService.settle(true);
|
|
|
|
// Wait for the async delete to complete, then check goto was called
|
|
await vi.waitFor(() => {
|
|
expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten');
|
|
});
|
|
});
|
|
|
|
it('delete failure: shows error message when DELETE returns non-ok', async () => {
|
|
vi.mocked(csrfFetch).mockResolvedValue(
|
|
new Response(JSON.stringify({ code: 'FORBIDDEN' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
);
|
|
const confirmService = createConfirmService();
|
|
render(GeschichtePage, {
|
|
context: new Map([[CONFIRM_KEY, confirmService]]),
|
|
props: { data: baseData({ canBlogWrite: true }) }
|
|
});
|
|
|
|
// Trigger delete — opens confirm dialog
|
|
const deleteBtn = page.getByRole('button', { name: /löschen/i });
|
|
await userEvent.click(deleteBtn);
|
|
|
|
// Settle the confirmation dialog
|
|
confirmService.settle(true);
|
|
|
|
// Wait for the error to appear inline
|
|
await expect.element(page.getByRole('alert')).toBeVisible();
|
|
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
|
});
|
|
});
|