diff --git a/frontend/src/routes/documents/+page.server.spec.ts b/frontend/src/routes/documents/+page.server.spec.ts new file mode 100644 index 00000000..d345023e --- /dev/null +++ b/frontend/src/routes/documents/+page.server.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/documents'); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + value.forEach((v) => url.searchParams.append(key, v)); + } else { + url.searchParams.set(key, value); + } + } + return url; +} + +// ─── search params forwarding ───────────────────────────────────────────────── + +describe('documents page load — search params', () => { + it('passes q, from, to to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }) + }) + }) + ); + }); + + it('passes senderId and receiverId to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ senderId: 'p-1', receiverId: 'p-2' }) + }) + }) + ); + }); + + it('passes sort, dir, tagQ to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }) + }) + }) + ); + }); + + it('returns items and total from the search result', async () => { + const item = { + document: { id: 'd1' }, + matchData: {}, + completionPercentage: 0, + contributors: [] + }; + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [item], total: 42 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'test' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(42); + }); + + it('returns filter values in the result for pre-filling the UI', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.q).toBe('Urlaub'); + expect(result.from).toBe('1920-01-01'); + expect(result.sort).toBe('TITLE'); + expect(result.dir).toBe('asc'); + }); +}); + +// ─── 401 redirect ───────────────────────────────────────────────────────────── + +describe('documents page load — auth redirect', () => { + it('redirects to /login when search API returns 401', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 401 }, data: null }) + } as ReturnType); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ location: '/login' }); + }); +}); + +// ─── network error fallback ─────────────────────────────────────────────────── + +describe('documents page load — network error fallback', () => { + it('returns error string instead of throwing when API call throws', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockRejectedValue(new Error('Network failure')) + } as ReturnType); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.error).toBeTruthy(); + expect(result.items).toEqual([]); + }); +}); diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts new file mode 100644 index 00000000..2d2c7a09 --- /dev/null +++ b/frontend/src/routes/documents/+page.server.ts @@ -0,0 +1,91 @@ +import { redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import type { components } from '$lib/generated/api'; + +type DocumentSearchItem = components['schemas']['DocumentSearchItem']; + +const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const; +type ValidSort = (typeof VALID_SORTS)[number]; +const VALID_DIRS = ['asc', 'desc'] as const; +type ValidDir = (typeof VALID_DIRS)[number]; + +export async function load({ url, fetch }) { + const q = url.searchParams.get('q') || ''; + const from = url.searchParams.get('from') || ''; + const to = url.searchParams.get('to') || ''; + const senderId = url.searchParams.get('senderId') || ''; + const receiverId = url.searchParams.get('receiverId') || ''; + const tags = url.searchParams.getAll('tag'); + const rawSort = url.searchParams.get('sort') ?? 'DATE'; + const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort) + ? (rawSort as ValidSort) + : 'DATE'; + const rawDir = url.searchParams.get('dir') ?? 'desc'; + const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir) + ? (rawDir as ValidDir) + : 'desc'; + const tagQ = url.searchParams.get('tagQ') || ''; + const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; + + const api = createApiClient(fetch); + + try { + const result = await api.GET('/api/documents/search', { + params: { + query: { + q: q || undefined, + from: from || undefined, + to: to || undefined, + senderId: senderId || undefined, + receiverId: receiverId || undefined, + tag: tags.length ? tags : undefined, + tagQ: tagQ && !tags.length ? tagQ : undefined, + tagOp: tagOp === 'OR' ? 'OR' : undefined, + sort, + dir: dir || undefined + } + } + }); + + if (result.response.status === 401) { + throw redirect(302, '/login'); + } + + const items: DocumentSearchItem[] = (result.data?.items ?? []) as DocumentSearchItem[]; + const total: number = result.data?.total ?? 0; + + return { + items, + total, + q, + from, + to, + senderId, + receiverId, + tags, + sort, + dir, + tagQ, + tagOp, + error: null as string | null + }; + } catch (e) { + if ((e as { status?: number }).status) throw e; + console.error('Error loading documents:', e); + return { + items: [] as DocumentSearchItem[], + total: 0, + q, + from, + to, + senderId, + receiverId, + tags, + sort, + dir, + tagQ, + tagOp, + error: 'Daten konnten nicht geladen werden.' as string | null + }; + } +}