diff --git a/.gitignore b/.gitignore index 1306e1bd..60d3f1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,11 @@ scripts/large-data.sql .claude/worktrees/ .claude/scheduled_tasks.lock +# Run artifacts from verification tooling +proofshot-artifacts/ + +# Root-level Node.js tooling artifacts +node_modules/ + # Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift. frontend/yarn.lock diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 7aecf88f..e99acfdd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException; import lombok.extern.slf4j.Slf4j; +// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix. @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java index 9a3a729d..035c0b9b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java @@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication; import java.util.UUID; +// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here. public final class SecurityUtils { private SecurityUtils() {} diff --git a/frontend/.gitignore b/frontend/.gitignore index b05dec47..8617ce82 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -36,6 +36,10 @@ src/lib/paraglide_bak* e2e/.auth/ **/test-results/** +**/test-results.locked/ + +# Stale SvelteKit build artifacts +**/.svelte-kit.old/ # Proofshot browser verification artifacts proofshot-artifacts/ diff --git a/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts b/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts deleted file mode 100644 index 75782266..00000000 --- a/frontend/.svelte-kit.old/types/src/routes/.stammbaum-stale/$types.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type * as Kit from '@sveltejs/kit'; - -type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; -type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string; -type RouteParams = { }; -type RouteId = '/stammbaum'; -type MaybeWithVoid = {} extends T ? T | void : T; -export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; -type OutputDataShape = MaybeWithVoid> & Partial> & Record> -type EnsureDefined = T extends null | undefined ? {} : T; -type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; -export type Snapshot = Kit.Snapshot; -type PageServerParentData = EnsureDefined; -type PageParentData = EnsureDefined; - -export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; -export type PageServerLoadEvent = Parameters[0]; -export type ActionData = unknown; -export type PageServerData = Expand>>>>>; -export type PageData = Expand & EnsureDefined>; -export type Action | void = Record | void> = Kit.Action -export type Actions | void = Record | void> = Kit.Actions -export type PageProps = { params: RouteParams; data: PageData; form: ActionData } -export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json deleted file mode 100644 index 08a11262..00000000 --- a/frontend/e2e/.auth/user.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "cookies": [ - { - "name": "PARAGLIDE_LOCALE", - "value": "de", - "domain": "localhost", - "path": "/", - "expires": 1812352142.362504, - "httpOnly": false, - "secure": false, - "sameSite": "Lax" - }, - { - "name": "auth_token", - "value": "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDphZG1pbjEyMw%3D%3D", - "domain": "localhost", - "path": "/", - "expires": 1777878542.943668, - "httpOnly": true, - "secure": false, - "sameSite": "Strict" - } - ], - "origins": [] -} \ No newline at end of file diff --git a/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts b/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts index 2fecf383..23be0202 100644 --- a/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page, userEvent } from 'vitest/browser'; +import { page } from 'vitest/browser'; import ChronikErrorCard from './ChronikErrorCard.svelte'; @@ -10,7 +10,7 @@ describe('ChronikErrorCard', () => { it('renders the default error message', async () => { render(ChronikErrorCard, { onRetry: vi.fn() }); await expect - .element(page.getByText('Die Chronik konnte nicht geladen werden.')) + .element(page.getByText('Die Aktivitäten konnten nicht geladen werden.')) .toBeInTheDocument(); }); @@ -27,7 +27,8 @@ describe('ChronikErrorCard', () => { it('calls onRetry when the retry button is clicked', async () => { const onRetry = vi.fn(); render(ChronikErrorCard, { onRetry }); - await userEvent.click(page.getByText('Erneut versuchen')); + const btn = (await page.getByText('Erneut versuchen').element()) as HTMLElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onRetry).toHaveBeenCalledTimes(1); }); diff --git a/frontend/src/lib/activity/ChronikRow.svelte b/frontend/src/lib/activity/ChronikRow.svelte index a324066e..91651de6 100644 --- a/frontend/src/lib/activity/ChronikRow.svelte +++ b/frontend/src/lib/activity/ChronikRow.svelte @@ -159,15 +159,8 @@ const rowHref: string = $derived(

{#if variant === 'comment'} - + +

{ it('shows drop hint text', async () => { render(BulkDropZone, { onFilesAdded: vi.fn() }); - await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Dateien ablegen/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts index a9b11698..c5562630 100644 --- a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts @@ -116,8 +116,8 @@ describe('TranscriptionBlock — interactions', () => { it('calls onFocus when textarea is focused', async () => { const onFocus = vi.fn(); renderBlock({ onFocus }); - const textarea = page.getByRole('textbox'); - await textarea.click(); + const textboxEl = (await page.getByRole('textbox').element()) as HTMLElement; + textboxEl.dispatchEvent(new FocusEvent('focus', { bubbles: false })); expect(onFocus).toHaveBeenCalled(); }); @@ -152,16 +152,20 @@ describe('TranscriptionBlock — reorder controls', () => { it('calls onMoveUp when up arrow clicked', async () => { const onMoveUp = vi.fn(); renderBlock({ onMoveUp, isFirst: false }); - const btn = page.getByRole('button', { name: 'Nach oben' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach oben' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveUp).toHaveBeenCalled(); }); it('calls onMoveDown when down arrow clicked', async () => { const onMoveDown = vi.fn(); renderBlock({ onMoveDown, isLast: false }); - const btn = page.getByRole('button', { name: 'Nach unten' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach unten' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveDown).toHaveBeenCalled(); }); }); @@ -227,16 +231,17 @@ describe('TranscriptionBlock — delete confirmation', () => { describe('TranscriptionBlock — quote selection', () => { it('shows quote hint after text is selected in the editor', async () => { renderBlock({ text: 'Breslau, den 12. August' }); - await page.getByRole('textbox').click(); - // Select all text in the contenteditable via the native Selection API. - // Tiptap fires selectionUpdate which the block forwards as onSelectionChange. - const editorEl = document.querySelector('[role="textbox"]') as HTMLElement; + // Native .focus() activates ProseMirror's DOMObserver so it listens for selectionchange. + const editorEl = (await page.getByRole('textbox').element()) as HTMLElement; + editorEl.focus(); + // Let ProseMirror's focus handler complete before we overwrite the selection. + await new Promise((r) => setTimeout(r, 0)); const range = document.createRange(); range.selectNodeContents(editorEl); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - editorEl.dispatchEvent(new Event('input', { bubbles: true })); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + document.dispatchEvent(new Event('selectionchange')); await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts index 701cb288..ec53e911 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import TranscriptionEditView from './TranscriptionEditView.svelte'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; @@ -148,24 +148,28 @@ describe('TranscriptionEditView — auto-save debounce', () => { }); it('passes the block mentionedPersons array as the 3rd save argument', async () => { - vi.useFakeTimers(); const onSaveBlock = vi.fn().mockResolvedValue(undefined); const blockWithMention = { ...block1, + // text must contain the @displayName token so deserialize() creates a mention node; + // fill() replaces the whole content with plain text and would destroy the node + text: '@Auguste Raddatz', mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] }; renderView({ blocks: [blockWithMention], onSaveBlock }); - const textarea = page.getByRole('textbox').first(); - await textarea.fill('Hallo @Auguste Raddatz'); + // type() focuses the element (cursor at position 0) then inserts without replacing the + // existing mention node. Fake timers interfere with keyboard CDP so use real timers + // + vi.waitFor to catch the 1500 ms debounce. + await userEvent.type(page.getByRole('textbox').first(), 'Hallo '); - vi.advanceTimersByTime(1500); - await vi.runAllTimersAsync(); - - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ - { personId: 'p-aug', displayName: 'Auguste Raddatz' } - ]); - vi.useRealTimers(); + await vi.waitFor( + () => + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]), + { timeout: 3000 } + ); }); it('resets debounce timer on rapid successive changes', async () => { @@ -238,8 +242,9 @@ describe('TranscriptionEditView — flush on blur', () => { const textarea = page.getByRole('textbox').first(); await textarea.fill('Blur text'); - // Blur before 1500ms debounce fires — locator.blur() not available, use native DOM - const el = document.querySelector('textarea') as HTMLTextAreaElement; + // Blur before 1500ms debounce fires — locator.blur() not available, use native DOM. + // PersonMentionEditor uses a contenteditable div (role=textbox), not a