diff --git a/frontend/src/routes/admin/page.server.spec.ts b/frontend/src/routes/admin/page.server.spec.ts new file mode 100644 index 00000000..a8edb053 --- /dev/null +++ b/frontend/src/routes/admin/page.server.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { load } from './+page.server'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { createApiClient } from '$lib/api.server'; + +const adminUser = { groups: [{ permissions: ['ADMIN'] }] }; +const readOnlyUser = { groups: [{ permissions: ['READ_ALL'] }] }; + +function mockApiReturning(users: unknown[], groups: unknown[], tags: unknown[]) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: true }, data: users }) + .mockResolvedValueOnce({ response: { ok: true }, data: groups }) + .mockResolvedValueOnce({ response: { ok: true }, data: tags }) + } as ReturnType); +} + +beforeEach(() => vi.clearAllMocks()); + +// ─── permission check ───────────────────────────────────────────────────────── + +describe('admin load — permission check', () => { + it('throws 403 when user has no ADMIN permission', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: readOnlyUser } }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 403 when user is undefined', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 403 when user has no groups', async () => { + await expect( + load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } }) + ).rejects.toMatchObject({ status: 403 }); + }); +}); + +// ─── happy path ─────────────────────────────────────────────────────────────── + +describe('admin load — happy path', () => { + it('returns users, groups, and tags for an admin user', async () => { + mockApiReturning( + [{ id: 'u1', username: 'alice' }], + [{ id: 'g1', name: 'Editors' }], + [{ id: 't1', name: 'Familie' }] + ); + + const result = await load({ + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: adminUser } + }); + + expect(result.users).toHaveLength(1); + expect(result.groups).toHaveLength(1); + expect(result.tags).toHaveLength(1); + }); + + it('returns empty arrays when API returns no data', async () => { + mockApiReturning([], [], []); + + const result = await load({ + fetch: vi.fn() as unknown as typeof fetch, + locals: { user: adminUser } + }); + + expect(result.users).toEqual([]); + expect(result.groups).toEqual([]); + expect(result.tags).toEqual([]); + }); +}); diff --git a/frontend/src/routes/documents/[id]/page.server.spec.ts b/frontend/src/routes/documents/[id]/page.server.spec.ts new file mode 100644 index 00000000..2d15c61c --- /dev/null +++ b/frontend/src/routes/documents/[id]/page.server.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); +vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://test-backend:8080' } })); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeCommentsResponse(comments: unknown[]) { + return { + ok: true, + json: vi.fn().mockResolvedValue(comments) + }; +} + +// ─── happy path ─────────────────────────────────────────────────────────────── + +describe('document detail load — happy path', () => { + it('returns document and comments on success', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { id: '123', title: 'Testbrief' } + }) + } as ReturnType); + + const mockFetch = vi.fn().mockResolvedValue(makeCommentsResponse([{ id: 'c1', body: 'Hi' }])); + + const result = await load({ + params: { id: '123' }, + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.document.title).toBe('Testbrief'); + expect(result.comments).toHaveLength(1); + }); + + it('returns empty comments when the comments fetch fails', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { id: '123', title: 'Testbrief' } + }) + } as ReturnType); + + // fetch throws a network error for the comments endpoint + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const result = await load({ + params: { id: '123' }, + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.document.title).toBe('Testbrief'); + expect(result.comments).toEqual([]); + }); + + it('returns empty comments when the comments response is not ok', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { id: '123', title: 'Testbrief' } + }) + } as ReturnType); + + const mockFetch = vi.fn().mockResolvedValue({ ok: false }); + + const result = await load({ + params: { id: '123' }, + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.comments).toEqual([]); + }); +}); + +// ─── error paths ────────────────────────────────────────────────────────────── + +describe('document detail load — error paths', () => { + it('throws 404 when document does not exist', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: false, status: 404 }, + error: null + }) + } as ReturnType); + + const mockFetch = vi.fn().mockResolvedValue({ ok: false }); + + await expect( + load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch }) + ).rejects.toMatchObject({ status: 404 }); + }); + + it('throws 403 when document is forbidden', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: false, status: 403 }, + error: null + }) + } as ReturnType); + + const mockFetch = vi.fn().mockResolvedValue({ ok: false }); + + await expect( + load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch }) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('redirects to /login on 401', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ + response: { ok: false, status: 401 }, + error: null + }) + } as ReturnType); + + const mockFetch = vi.fn().mockResolvedValue({ ok: false }); + + await expect( + load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch }) + ).rejects.toMatchObject({ location: '/login' }); + }); +}); diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts new file mode 100644 index 00000000..e250ee78 --- /dev/null +++ b/frontend/src/routes/page.server.spec.ts @@ -0,0 +1,128 @@ +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/'); + 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; +} + +// ─── happy path ─────────────────────────────────────────────────────────────── + +describe('home page load — happy path', () => { + it('returns documents and persons on success', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: [{ id: 'd1', title: 'Brief' }] + }) + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: [{ id: 'p1', firstName: 'Hans', lastName: 'Müller' }] + }) + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 3 } }) + } as ReturnType); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.documents).toHaveLength(1); + expect(result.incompleteCount).toBe(3); + expect(result.error).toBeNull(); + }); + + it('passes search params from the URL to the API', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); + vi.mocked(createApiClient).mockReturnValue({ + GET: mockGet + } as ReturnType); + + await load({ + url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + const firstCall = mockGet.mock.calls[0]; + expect(firstCall[1].params.query.q).toBe('Urlaub'); + expect(firstCall[1].params.query.from).toBe('2020-01-01'); + }); + + it('returns incompleteCount 0 when the incomplete-count API fails', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: false }, data: null }) + } as ReturnType); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.incompleteCount).toBe(0); + }); +}); + +// ─── 401 redirect ───────────────────────────────────────────────────────────── + +describe('home page load — auth redirect', () => { + it('redirects to /login when documents API returns 401', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null }) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) + } as ReturnType); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ location: '/login' }); + }); + + it('redirects to /login when persons API returns 401', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null }) + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) + } as ReturnType); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ location: '/login' }); + }); +}); + +// ─── network error fallback ─────────────────────────────────────────────────── + +describe('home 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).toBe('Daten konnten nicht geladen werden.'); + expect(result.documents).toEqual([]); + expect(result.incompleteCount).toBe(0); + }); +}); diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts new file mode 100644 index 00000000..e1e8f493 --- /dev/null +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { load } from './+page.server'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { createApiClient } from '$lib/api.server'; + +const mockFetch = vi.fn() as unknown as typeof fetch; + +beforeEach(() => vi.clearAllMocks()); + +// ─── happy path ─────────────────────────────────────────────────────────────── + +describe('person detail load — happy path', () => { + it('returns person, sentDocuments, and receivedDocuments on success', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: { id: 'p1', firstName: 'Hans', lastName: 'Müller' } + }) + .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + } as ReturnType); + + const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + + expect(result.person.firstName).toBe('Hans'); + expect(result.sentDocuments).toHaveLength(1); + expect(result.receivedDocuments).toEqual([]); + }); + + it('returns empty arrays when sent/received document APIs fail', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: { id: 'p1', firstName: 'Anna', lastName: 'Schmidt' } + }) + .mockResolvedValueOnce({ response: { ok: false }, data: null }) + .mockResolvedValueOnce({ response: { ok: false }, data: null }) + } as ReturnType); + + const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + + expect(result.sentDocuments).toEqual([]); + expect(result.receivedDocuments).toEqual([]); + }); +}); + +// ─── error paths ────────────────────────────────────────────────────────────── + +describe('person detail load — error paths', () => { + it('throws 404 when person does not exist', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: false, status: 404 }, error: null }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + } as ReturnType); + + await expect(load({ params: { id: 'missing' }, fetch: mockFetch })).rejects.toMatchObject({ + status: 404 + }); + }); + + it('throws 403 when person is not accessible', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ response: { ok: false, status: 403 }, error: null }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + } as ReturnType); + + await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({ + status: 403 + }); + }); +});