chore: resolve merge conflicts with main
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m32s
CI / Backend Unit Tests (pull_request) Failing after 2m17s
CI / E2E Tests (pull_request) Failing after 2h43m0s
CI / Backend Unit Tests (push) Failing after 14m52s
CI / E2E Tests (push) Failing after 3h14m47s

Kept our version of accessibility.spec.ts (color-contrast rule enabled,
exclusion comment removed) over main's disabled version — the contrast
fixes in this branch make the exclusion unnecessary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #149.
This commit is contained in:
Marcel
2026-03-28 19:51:32 +01:00
25 changed files with 1083 additions and 10 deletions

View File

@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
</script>
<svelte:head>
<title>{m.page_title_error()}</title>
</svelte:head>
<div class="px-4 py-12 text-center font-sans">
<p class="font-sans text-6xl font-bold text-ink">{page.status}</p>
<p class="mt-2 font-sans text-sm text-ink-2">{page.error?.message ?? 'Internal Error'}</p>
</div>

View File

@@ -67,6 +67,10 @@ $effect(() => {
});
</script>
<svelte:head>
<title>{m.page_title_home()}</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<SearchFilterBar
bind:q={q}

View File

@@ -202,6 +202,7 @@ $effect(() => {
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
aria-label={m.doc_file_upload_label()}
class="sr-only"
onchange={handleFileSelect}
/>

View File

@@ -44,6 +44,7 @@ let {
oninput={onSearch}
onfocus={onfocus}
onblur={onblur}
aria-label={m.docs_search_placeholder()}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
/>

View File

@@ -11,6 +11,10 @@ let { data, form } = $props();
let activeTab = $state('users');
</script>
<svelte:head>
<title>{m.page_title_admin()}</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-serif text-3xl text-ink">{m.admin_heading()}</h1>

View File

@@ -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<typeof createApiClient>);
}
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([]);
});
});

View File

@@ -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<typeof createApiClient>);
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<typeof createApiClient>);
// 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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
await expect(
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
).rejects.toMatchObject({ location: '/login' });
});
});

View File

@@ -9,6 +9,10 @@ const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
<svelte:head>
<title>{m.page_title_login()}</title>
</svelte:head>
<div class="relative flex min-h-screen flex-col bg-canvas">
<!-- Language switcher -->
<div class="absolute top-4 right-4 flex items-center gap-1">

View File

@@ -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<string, string | string[]> = {}) {
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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);
});
});

View File

@@ -23,6 +23,10 @@ function handleSearch() {
}
</script>
<svelte:head>
<title>{m.page_title_persons()}</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div

View File

@@ -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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
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<typeof createApiClient>);
await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({
status: 403
});
});
});