From 7f23e88b69c1a1145dcc988dc8d8764e2f77c499 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 00:40:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(documents):=20address=20review=20cycle=202?= =?UTF-8?q?=20=E2=80=94=20a11y,=20CSS=20injection,=20debounce=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContributorStack: text-xs for WCAG 1.4.4 (was text-[10px]), safeColor() validation to block CSS injection via actor.color, role="img" aria-label on empty placeholder, {#each} keyed by index - ContributorStack spec: update empty-state assertion to getByRole('img') - DocumentRow spec: add stopPropagation regression test for tag click - documents/page.svelte.spec.ts: new — debounce, URL building, initial state Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/ContributorStack.svelte | 15 ++- .../ContributorStack.svelte.spec.ts | 4 +- .../lib/components/DocumentRow.svelte.spec.ts | 13 ++ .../src/routes/documents/page.svelte.spec.ts | 121 ++++++++++++++++++ 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 frontend/src/routes/documents/page.svelte.spec.ts diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index 4e417fc2..3204a7dc 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -11,21 +11,26 @@ interface Props { let { contributors, hasMore }: Props = $props(); const safeContributors = $derived(contributors ?? []); + +function safeColor(color: string): string { + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3'; +} {#if safeContributors.length === 0} {:else} - {#each safeContributors as actor, i (actor.initials + '-' + actor.color)} + {#each safeContributors as actor, i (i)} {actor.initials} @@ -33,7 +38,7 @@ const safeContributors = $derived(contributors ?? []); {/each} {#if hasMore} diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts index 877167cd..4bb847c9 100644 --- a/frontend/src/lib/components/ContributorStack.svelte.spec.ts +++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts @@ -45,6 +45,8 @@ describe('ContributorStack', () => { it('renders empty placeholder when no contributors', async () => { render(ContributorStack, { contributors: [], hasMore: false }); - await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument(); + await expect + .element(page.getByRole('img', { name: 'Noch niemand angefangen' })) + .toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts index 0f85fb94..a5b335e4 100644 --- a/frontend/src/lib/components/DocumentRow.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts @@ -145,6 +145,19 @@ describe('DocumentRow – tags', () => { await page.getByRole('button', { name: 'Urlaub & Reise' }).click(); expect(goto).toHaveBeenCalledWith('/documents?tag=Urlaub%20%26%20Reise'); }); + + it('tag click does not navigate to the document detail page', async () => { + const item = makeItem({ + document: { + ...makeItem().document, + tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }] + } + }); + render(DocumentRow, { item }); + const before = window.location.href; + await page.getByRole('button', { name: 'Familie' }).click(); + expect(window.location.href).toBe(before); + }); }); // ─── ProgressRing & ContributorStack ───────────────────────────────────────── diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts new file mode 100644 index 00000000..59af31bf --- /dev/null +++ b/frontend/src/routes/documents/page.svelte.spec.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ navigating: { to: null } })); + +import Page from './+page.svelte'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const SEARCH_LABEL = 'Titel, Personen, Tags durchsuchen…'; + +function makeData(overrides: Record = {}) { + return { + items: [], + total: 0, + q: '', + from: '', + to: '', + senderId: '', + receiverId: '', + tags: [], + sort: 'DATE', + dir: 'desc', + tagQ: '', + tagOp: 'AND', + canWrite: false, + error: null, + ...overrides + }; +} + +// ─── Initial state from server data ─────────────────────────────────────────── + +describe('documents page — initial state', () => { + it('pre-fills the search input from data.q', async () => { + render(Page, { data: makeData({ q: 'Geburtstag' }) }); + await expect + .element(page.getByRole('textbox', { name: SEARCH_LABEL })) + .toHaveValue('Geburtstag'); + }); + + it('leaves the search input empty when data.q is not set', async () => { + render(Page, { data: makeData() }); + await expect.element(page.getByRole('textbox', { name: SEARCH_LABEL })).toHaveValue(''); + }); +}); + +// ─── URL building via triggerSearch ─────────────────────────────────────────── + +describe('documents page — URL building', () => { + beforeEach(() => vi.useFakeTimers()); + + it('calls goto with /documents?q=… after the 500 ms debounce', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('Urlaub'); + + expect(goto).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).toContain('q=Urlaub'); + expect(url).toMatch(/^\/documents\?/); + }); + + it('omits q from the URL when the search field is empty', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill(''); + + vi.advanceTimersByTime(500); + + const [url] = vi.mocked(goto).mock.calls[0] ?? ['']; + expect(url).not.toContain('q='); + }); + + it('second keystroke within 500 ms cancels the first timer — goto called only once', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('U'); + vi.advanceTimersByTime(200); + await input.fill('Urlaub'); + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledOnce(); + }); + + it('passes keepFocus and noScroll options to goto', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('Brief'); + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ keepFocus: true, noScroll: true }) + ); + }); +});