Compare commits

...

11 Commits

Author SHA1 Message Date
Marcel
ab7fe81b2a ci: track act_runner config with Docker socket mount
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m21s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m14s
Documents the NAS runner configuration needed for Testcontainers.
Must be deployed to the runner host alongside the act_runner binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:01:24 +02:00
Marcel
1333e690dd fix(ci): expose Docker socket env vars for Testcontainers in backend job
DOCKER_HOST makes the socket explicit rather than relying on runner
config propagation; TESTCONTAINERS_RYUK_DISABLED=true avoids Ryuk
watchdog start failures in nested container environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:00:43 +02:00
Marcel
42fda7675a fix(ci): add TZ=Europe/Berlin to frontend test step
date-buckets.spec.ts midnight tests pass timezone-aware dates (+02:00)
which are 22:00 UTC the prior day; setHours(0,0,0,0) uses local TZ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:00:03 +02:00
Marcel
fb5f47f593 fix(pdf-controls): add focus-visible ring to all PdfControls buttons (WCAG 2.1 §2.4.7)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m56s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 3m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:54:27 +02:00
Marcel
d4abe994a3 fix(a11y): increase all PdfControls buttons to 44×44px touch targets
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 3m11s
Add min-h-[44px] min-w-[44px] to all five PDF viewer buttons (prev,
next, zoom in, zoom out, annotation toggle) and widen icon-only
padding from p-1 to p-2. Adds aria-pressed to the annotation toggle
for correct toggle semantics (WCAG 2.2 §2.5.8 + ARIA 1.2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:49:18 +02:00
Marcel
cd1c0b210e test(typeahead): note resetKey smoke-test limitation in spec comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m19s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
a239c16c31 fix(documents): sync filter display state with URL on navigation
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:

1. +page.server.ts now resolves sender/receiver display names in parallel with
   the document search (UUID validation + silent 404 drop), so initialSenderName
   / initialReceiverName land in server data ready for the UI to use.

2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
   (incremented via untrack on every navigation) down to SearchFilterBar.
   The untrack() prevents the effect from re-running due to its own navKey write.

3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
   already had a void resetKey guard added in the previous commit.

Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
8a8205ad8d fix(person-typeahead): add resetKey prop to clear term on navigation reset
When the user types in the sender/receiver typeahead without selecting a
person and then clicks ×-reset (navigating back to /documents), the
manually-typed term was not cleared because initialName stayed '' between
navigations — the existing $effect tracking initialName never fired.

Adding `resetKey` (incremented by the page on every navigation) forces
the effect to re-run via `void resetKey`, clearing searchTerm=initialName
even when initialName is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
0430383e1c fix(date-input): re-derive display when value prop changes externally
`display` was initialised once and never updated, so the text box would
show a stale German date after the parent reset `value` (e.g. × reset
button or timeline drag). A guarded `$effect` re-derives `display` from
`value` whenever the two are out of sync while preserving mid-typing
partial dates (germanToIso returns '' for incomplete input, which matches
value='' during typing → no spurious re-derive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
e2d74ff880 ci: add npm run build step to unit-tests job
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
The prerender fix only prevents regression if the build is actually run in
CI. Without this gate, a future prerendered route that becomes unreachable
behind auth would fail silently until someone runs the build manually.

Fits after the test step in the existing unit-tests job — no new job needed
since node_modules is already cached for the Playwright container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
Marcel
586eea009b fix(build): add prerender entry for /hilfe/transkription
The SvelteKit prerender crawler cannot reach this route because
hooks.server.ts redirects all non-public paths to /login before the
crawler follows links. Explicitly listing the route in kit.prerender.entries
tells SvelteKit to render it directly without crawling.

Also removes a misleading comment that claimed the auth hook guards
prerendered static files — it does not. Prerendered HTML is served as a
static file by the reverse proxy; hooks.server.ts only runs for SSR requests.

Closes #472

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
15 changed files with 344 additions and 27 deletions

View File

@@ -39,6 +39,12 @@ jobs:
- name: Run unit and component tests
run: npm test
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Build frontend
run: npm run build
working-directory: frontend
- name: Upload screenshots
if: always()
@@ -74,6 +80,8 @@ jobs:
runs-on: ubuntu-latest
env:
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
DOCKER_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- uses: actions/checkout@v4

View File

@@ -35,7 +35,7 @@ let {
onclick={onPrev}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
@@ -52,7 +52,7 @@ let {
onclick={onNext}
disabled={!isLoaded || currentPage >= totalPages}
aria-label="Weiter"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
@@ -65,7 +65,7 @@ let {
<button
onclick={onZoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
@@ -75,7 +75,7 @@ let {
<button
onclick={onZoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
@@ -89,7 +89,8 @@ let {
<button
onclick={onToggleAnnotations}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
aria-pressed={showAnnotations}
class="flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded px-3 py-2 font-sans text-xs transition focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-primary'}"
>

View File

@@ -65,3 +65,111 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
expect(annotationBtn!.className).not.toContain('text-accent');
});
});
describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
it('annotation toggle button has focus-visible:ring-2 focus ring', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('focus-visible:ring-2');
});
it('icon-only nav/zoom buttons each have focus-visible:ring-2 focus ring', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('focus-visible:ring-2');
}
});
});
describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
it('annotation toggle button has min-h-[44px] touch target', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-h-[44px]');
});
it('annotation toggle button has min-w-[44px] touch target', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-w-[44px]');
});
it('annotation toggle reflects pressed state via aria-pressed', async () => {
const { container: c1 } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn1!.getAttribute('aria-pressed')).toBe('false');
cleanup();
const { container: c2 } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: true
});
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn2!.getAttribute('aria-pressed')).toBe('true');
});
it('icon-only nav/zoom buttons each have min-h-[44px] touch target', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('min-h-[44px]');
}
});
it('icon-only nav/zoom buttons each have min-w-[44px] touch target', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('min-w-[44px]');
}
});
});

View File

@@ -21,6 +21,7 @@ interface Props {
restrictToCorrespondentsOf?: string;
excludePersonId?: string;
badge?: 'additive' | 'replace';
resetKey?: number;
onchange?: (value: string) => void;
onfocused?: () => void;
}
@@ -39,17 +40,20 @@ let {
restrictToCorrespondentsOf,
excludePersonId,
badge,
resetKey = 0,
onchange,
onfocused
}: Props = $props();
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
// $effect is the correct pattern here — writable $derived is read-only and won't work.
// eslint-disable-next-line svelte/prefer-writable-derived
let searchTerm = $state(initialName);
// Sync display text when the selected person changes externally (e.g. swap, navigation).
// Sync display text when initialName changes OR when resetKey increments (navigation reset).
// resetKey is incremented by the page on every SvelteKit navigation so that a manually-typed
// term that was never committed (no person selected) gets cleared even if initialName stays ''.
$effect(() => {
void resetKey;
searchTerm = initialName;
});

View File

@@ -270,6 +270,33 @@ describe('PersonTypeahead correspondent mode', () => {
});
});
// ─── resetKey ─────────────────────────────────────────────────────────────────
describe('PersonTypeahead resetKey', () => {
// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
// navigation (prop update on a live instance) cannot be isolated at this level.
it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
mockFetchWithPersons([]);
const { rerender } = render(PersonTypeahead, {
name: 'senderId',
label: 'Absender',
initialName: '',
resetKey: 0
});
const input = page.getByPlaceholder('Namen tippen...');
// User types something without selecting a person
await input.fill('Max');
await waitForDebounce();
await expect.element(input).toHaveValue('Max');
// Navigation resets: initialName stays '', but resetKey increments
await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
await expect.element(input).toHaveValue('');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonTypeahead click outside', () => {

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
@@ -24,6 +25,16 @@ let {
let display = $state(isoToGerman(value ?? ''));
// Re-derive display when value changes externally (e.g. timeline drag, reset nav).
// Guard prevents overwriting while the user is mid-typing a partial date:
// germanToIso returns '' for partial input, matching value '' → no re-derive.
$effect(() => {
const externalIso = value ?? '';
if (germanToIso(untrack(() => display)) !== externalIso) {
display = isoToGerman(externalIso);
}
});
// ─── Validation helper ────────────────────────────────────────────────────
function isCalendarValid(iso: string): boolean {
if (!iso) return false;

View File

@@ -183,6 +183,26 @@ describe('DateInput clearing the date', () => {
});
});
// ─── External value changes ───────────────────────────────────────────────────
describe('DateInput external value changes', () => {
it('clears display when value prop is reset to empty externally', async () => {
const { rerender } = render(DateInput, { value: '1920-01-01' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('01.01.1920');
await rerender({ value: '' });
await expect.element(input).toHaveValue('');
});
it('updates display when value prop changes to a new date externally', async () => {
const { rerender } = render(DateInput, { value: '1920-01-01' });
const input = page.getByRole('textbox');
await expect.element(input).toHaveValue('01.01.1920');
await rerender({ value: '1945-05-08' });
await expect.element(input).toHaveValue('08.05.1945');
});
});
// ─── Hidden input ─────────────────────────────────────────────────────────────
describe('DateInput hidden input for form submission', () => {

View File

@@ -20,6 +20,7 @@ let {
showAdvanced = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
navKey = 0,
isLoading = false,
onSearch,
onSearchImmediate,
@@ -39,6 +40,7 @@ let {
showAdvanced?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
navKey?: number;
isLoading?: boolean;
onSearch: () => void;
onSearchImmediate?: () => void;
@@ -197,6 +199,7 @@ $effect(() => {
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={initialSenderName}
resetKey={navKey}
onchange={onSearch}
/>
</div>
@@ -212,6 +215,7 @@ $effect(() => {
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={initialReceiverName}
resetKey={navKey}
onchange={onSearch}
/>
</div>

View File

@@ -3,6 +3,20 @@ import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function resolvePersonName(id: string, fetch: typeof globalThis.fetch): Promise<string> {
if (!UUID_RE.test(id)) return '';
try {
const res = await fetch(`/api/persons/${id}`);
if (!res.ok) return '';
const person = await res.json();
return person.displayName ?? '';
} catch {
return '';
}
}
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
@@ -34,25 +48,30 @@ export async function load({ url, fetch }) {
const api = createApiClient(fetch);
let result;
let initialSenderName = '';
let initialReceiverName = '';
try {
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,
page,
size: PAGE_SIZE
[result, [initialSenderName, initialReceiverName]] = await Promise.all([
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,
page,
size: PAGE_SIZE
}
}
}
});
}),
Promise.all([resolvePersonName(senderId, fetch), resolvePersonName(receiverId, fetch)])
]);
} catch {
return {
items: [] as DocumentSearchItem[],
@@ -65,6 +84,8 @@ export async function load({ url, fetch }) {
to,
senderId,
receiverId,
initialSenderName: '',
initialReceiverName: '',
tags,
sort,
dir,
@@ -94,6 +115,8 @@ export async function load({ url, fetch }) {
to,
senderId,
receiverId,
initialSenderName,
initialReceiverName,
tags,
sort,
dir,

View File

@@ -22,6 +22,9 @@ let from = $state(untrack(() => data.from || ''));
let to = $state(untrack(() => data.to || ''));
let senderId = $state(untrack(() => data.senderId || ''));
let receiverId = $state(untrack(() => data.receiverId || ''));
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
let navKey = $state(0);
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.tags || []).map((name: string) => ({ name })))
);
@@ -207,12 +210,17 @@ async function editAllMatching() {
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
// navKey increments on every navigation so PersonTypeahead clears manually-typed
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
$effect(() => {
if (!qFocused) q = data.q || '';
from = data.from || '';
to = data.to || '';
senderId = data.senderId || '';
receiverId = data.receiverId || '';
initialSenderName = data.initialSenderName ?? '';
initialReceiverName = data.initialReceiverName ?? '';
untrack(() => navKey++);
tagNames = (data.tags || []).map((name: string) => ({ name }));
sort = data.sort || 'DATE';
dir = data.dir || 'desc';
@@ -247,6 +255,9 @@ $effect(() => {
bind:dir={dir}
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName}
navKey={navKey}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}

View File

@@ -167,3 +167,72 @@ describe('documents page load — network error fallback', () => {
expect(result.items).toEqual([]);
});
});
// ─── person name resolution ───────────────────────────────────────────────────
describe('documents page load — person name resolution', () => {
function makeSearchMock() {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
}
it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => {
makeSearchMock();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ displayName: 'Max Mustermann' })
});
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
fetch: mockFetch as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('Max Mustermann');
});
it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => {
makeSearchMock();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ displayName: 'Anna Musterfrau' })
});
const result = await load({
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
fetch: mockFetch as unknown as typeof fetch
});
expect(result.initialReceiverName).toBe('Anna Musterfrau');
});
it('returns empty string when senderId is not a valid UUID', async () => {
makeSearchMock();
const mockFetch = vi.fn();
const result = await load({
url: makeUrl({ senderId: 'not-a-uuid' }),
fetch: mockFetch as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('');
expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/persons/'));
});
it('returns empty string when person fetch returns 404', async () => {
makeSearchMock();
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
fetch: mockFetch as unknown as typeof fetch
});
expect(result.initialSenderName).toBe('');
});
});

View File

@@ -23,6 +23,8 @@ function makeData(overrides: Record<string, unknown> = {}) {
to: '',
senderId: '',
receiverId: '',
initialSenderName: '',
initialReceiverName: '',
tags: [],
sort: 'DATE',
dir: 'desc',
@@ -136,6 +138,22 @@ describe('documents page — URL building', () => {
});
});
// ─── Sender / receiver name display ──────────────────────────────────────────
describe('documents page — sender/receiver display', () => {
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
render(Page, {
data: makeData({
senderId: '11111111-1111-1111-1111-111111111111',
initialSenderName: 'Max Mustermann'
})
});
// Advanced filters are auto-shown when senderId is set
const inputs = page.getByPlaceholder('Namen tippen...');
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
});
});
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
describe('documents page — timeline density widget', () => {

View File

@@ -1,3 +1 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true;

View File

@@ -6,7 +6,10 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
kit: {
adapter: adapter(),
prerender: { entries: ['/hilfe/transkription'] }
}
};
export default config;

12
runner-config.yaml Normal file
View File

@@ -0,0 +1,12 @@
# runner-config.yaml — only the relevant section
container:
# passed as DOCKER_HOST inside the job container
docker_host: "unix:///var/run/docker.sock"
# whitelists the socket path so workflows can mount it
valid_volumes:
- "/var/run/docker.sock"
# appended to `docker run` when the runner spawns a job container
options: "-v /var/run/docker.sock:/var/run/docker.sock"
# keep network mode default (bridge) — Testcontainers handles its own networking
force_pull: false