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:
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);
|
||||
|
||||
const baseData = {
|
||||
initialPersons: [] as { id: string; displayName: string }[]
|
||||
};
|
||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user