From a1035171c2c185c70a2957836a60fa4587bcf4bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 14:31:55 +0200 Subject: [PATCH 1/4] fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server mapped DocumentSearchResult items as { document: Document }[] but the API returns flat DocumentListItem[] — so i.document was always undefined, crashing the reader homepage with a 500. Fix the type + mapping in +page.server.ts, add createdAt/updatedAt to DocumentListItem (needed by ReaderRecentDocs for relative-time display), and update the component to accept DocumentListItem instead of Document. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentListItem.java | 7 ++- .../document/DocumentService.java | 4 +- .../document/DocumentControllerTest.java | 4 +- .../document/DocumentSearchResultTest.java | 4 +- frontend/src/lib/generated/api.ts | 4 ++ .../shared/dashboard/ReaderRecentDocs.svelte | 6 +-- frontend/src/routes/+page.server.ts | 8 ++-- frontend/src/routes/page.server.spec.ts | 45 +++++++++++++++++++ 8 files changed, 69 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java index 7cbc6496..bf8c19a3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java @@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.tag.Tag; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -32,5 +33,9 @@ public record DocumentListItem( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - SearchMatchData matchData + SearchMatchData matchData, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index cfbbf848..65bd7cd7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -767,7 +767,9 @@ public class DocumentService { doc.getSummary(), completionPct, contributors, - match + match, + doc.getCreatedAt(), + doc.getUpdatedAt() ); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index f7c24541..359211ba 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) @@ -153,7 +153,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 1dd09fed..9effa3bc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -16,7 +16,7 @@ class DocumentSearchResultTest { return new DocumentListItem( docId, "Test", "test.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), SearchMatchData.empty()); + 0, List.of(), SearchMatchData.empty(), null, null); } @Test @@ -66,7 +66,7 @@ class DocumentSearchResultTest { DocumentListItem item = new DocumentListItem( id, "T", "t.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 75, List.of(actor), SearchMatchData.empty()); + 75, List.of(actor), SearchMatchData.empty(), null, null); DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9a9a5408..c2888d97 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2407,6 +2407,10 @@ export interface components { completionPercentage: number; contributors: components["schemas"]["ActivityActorDTO"][]; matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; }; DocumentSearchResult: { items: components["schemas"]["DocumentListItem"][]; diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 9f54d6b5..5721accc 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -3,15 +3,15 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTimeDe } from '$lib/shared/relativeTime'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; interface Props { - documents: Document[]; + documents: DocumentListItem[]; } const { documents }: Props = $props(); -function isNew(doc: Document): boolean { +function isNew(doc: DocumentListItem): boolean { return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b217452f..ed229042 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; type Geschichte = components['schemas']['Geschichte']; function settled(res: PromiseSettledResult | undefined): T | null { @@ -53,8 +53,8 @@ export async function load({ fetch, parent }) { const readerStats = settled(statsRes); const topPersons = settled(topPersonsRes) ?? []; - const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); - const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); + const recentDocs = searchData?.items ?? []; const recentStories = settled(recentStoriesRes) ?? []; const drafts = settled(draftsRes) ?? []; @@ -167,7 +167,7 @@ export async function load({ fetch, parent }) { incompleteTotal: 0, readerStats: null, topPersons: [] as PersonSummaryDTO[], - recentDocs: [] as Document[], + recentDocs: [] as DocumentListItem[], recentStories: [] as Geschichte[], drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b87ce9a8..27ce1e30 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -394,6 +394,51 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.isReader).toBe(false); }); + it('maps search result items directly to recentDocs without wrapping in a .document property', async () => { + const searchItem = { + id: 'd1', + title: 'Liebesbrief', + originalFilename: 'letter.pdf', + completionPercentage: 80, + receivers: [], + tags: [], + contributors: [], + matchData: { titleOffsets: [], senderMatched: false } + }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 1, totalPersons: 1 } + }) // stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } + }) // search + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/'), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.recentDocs).toHaveLength(1); + expect(result.recentDocs[0]).toBeDefined(); + expect(result.recentDocs[0].id).toBe('d1'); + } + }); + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { const okStats = { response: { ok: true, status: 200 }, -- 2.49.1 From 2e0f85c3606b3d0e2f6d732d6564aa0136adc386 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 15:08:04 +0200 Subject: [PATCH 2/4] fix(review): address reviewer concerns from PR #661 - Replace brittle createdAt===updatedAt isNew() check with a 7-day recency window (created within last 7 days = new) - Add createdAt/updatedAt to searchItem fixture in page.server.spec.ts and assert they are propagated to recentDocs - Replace null timestamps in DocumentListItem test fixtures with a fixed LocalDateTime to satisfy the @Schema(required) contract Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentControllerTest.java | 6 +++-- .../document/DocumentSearchResultTest.java | 7 ++++-- .../shared/dashboard/ReaderRecentDocs.svelte | 2 +- .../dashboard/ReaderRecentDocs.svelte.spec.ts | 24 ++++++++++++------- .../dashboard/ReaderRecentDocs.svelte.test.ts | 12 +++++----- frontend/src/routes/page.server.spec.ts | 6 ++++- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 359211ba..fe15ba3b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,8 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData, null, null)))); + 0, List.of(), matchData, + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0))))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) @@ -153,7 +154,8 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData, null, null)))); + 0, List.of(), matchData, + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0))))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 9effa3bc..a487e272 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.springframework.data.domain.PageRequest; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -16,7 +17,8 @@ class DocumentSearchResultTest { return new DocumentListItem( docId, "Test", "test.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), SearchMatchData.empty(), null, null); + 0, List.of(), SearchMatchData.empty(), + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)); } @Test @@ -66,7 +68,8 @@ class DocumentSearchResultTest { DocumentListItem item = new DocumentListItem( id, "T", "t.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 75, List.of(actor), SearchMatchData.empty(), null, null); + 75, List.of(actor), SearchMatchData.empty(), + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 5721accc..98985f95 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -12,7 +12,7 @@ interface Props { const { documents }: Props = $props(); function isNew(doc: DocumentListItem): boolean { - return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); + return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000; } diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index c13c92b1..51dd5d8e 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -88,8 +88,14 @@ describe('ReaderRecentDocs', () => { expect(thumb!.className).toMatch(/rounded-/); }); - it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => { - render(ReaderRecentDocs, { documents: [baseDoc] }); + it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => { + const recentDoc: Document = { + ...baseDoc, + id: 'doc-recent', + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() + }; + render(ReaderRecentDocs, { documents: [recentDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); const cls = ((await badge.element()) as HTMLElement).className; @@ -98,7 +104,7 @@ describe('ReaderRecentDocs', () => { expect(cls).toMatch(/\btext-ink\b/); }); - it('shows no badge when updatedAt differs from createdAt', async () => { + it('shows no badge when document was created more than 7 days ago', async () => { render(ReaderRecentDocs, { documents: [updatedDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).not.toBeInTheDocument(); @@ -106,14 +112,14 @@ describe('ReaderRecentDocs', () => { await expect.element(updatedBadge).not.toBeInTheDocument(); }); - it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { - const sameInstantDoc: Document = { + it('shows "Neu" badge when document was created 6 days ago', async () => { + const almostOldDoc: Document = { ...baseDoc, - id: 'doc-same-instant', - createdAt: '2025-01-01T12:00:00Z', - updatedAt: '2025-01-01T12:00:00.000Z' + id: 'doc-almost-old', + createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() }; - render(ReaderRecentDocs, { documents: [sameInstantDoc] }); + render(ReaderRecentDocs, { documents: [almostOldDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); }); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts index d15e6f7f..6c93c55e 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts @@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => { .toHaveAttribute('href', '/documents'); }); - it('renders the New badge when createdAt equals updatedAt', async () => { + it('renders the New badge when document was created within the last 7 days', async () => { + const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const laterUpdate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); render(ReaderRecentDocs, { props: { - documents: [ - makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' }) - ] + documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })] } }); await expect.element(page.getByText('Neu')).toBeVisible(); }); - it('hides the New badge when document was updated after creation', async () => { + it('hides the New badge when document was created more than 7 days ago', async () => { render(ReaderRecentDocs, { props: { documents: [ makeDoc({ createdAt: '2026-04-15T10:00:00Z', - updatedAt: '2026-04-15T11:00:00Z' + updatedAt: '2026-04-15T10:00:00Z' }) ] } diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 27ce1e30..a04cf1fe 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -403,7 +403,9 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate receivers: [], tags: [], contributors: [], - matchData: { titleOffsets: [], senderMatched: false } + matchData: { titleOffsets: [], senderMatched: false }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-10T08:00:00Z' }; const mockGet = vi .fn() @@ -436,6 +438,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.recentDocs).toHaveLength(1); expect(result.recentDocs[0]).toBeDefined(); expect(result.recentDocs[0].id).toBe('d1'); + expect(result.recentDocs[0].createdAt).toBe('2026-05-01T10:00:00Z'); + expect(result.recentDocs[0].updatedAt).toBe('2026-05-10T08:00:00Z'); } }); -- 2.49.1 From d9e01ef1ff5712dc40e4e22964921792d2facc2a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:25:46 +0200 Subject: [PATCH 3/4] fix(review): regenerate api.ts and fix spec type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual edits to api.ts with a proper `npm run generate:api` run — the generated output is identical for DocumentListItem (createdAt/updatedAt were already correct), so this just removes the drift risk flagged in review. Fix ReaderRecentDocs.svelte.spec.ts to use DocumentListItem instead of Document for all test fixtures, matching the component's actual prop type. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 10 +++---- frontend/src/lib/generated/api.ts | 6 ++--- .../dashboard/ReaderRecentDocs.svelte.spec.ts | 27 ++++++++++++------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a408615d..9738729a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,9 +23,9 @@ "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", "@inlang/paraglide-js": "^2.5.0", - "@playwright/test": "^1.58.2", - "@sveltejs/adapter-node": "^5.4.0", - "@sveltejs/kit": "^2.48.5", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", @@ -43,7 +43,7 @@ "globals": "^16.5.0", "openapi-typescript": "^7.8.0", "patch-package": "^8.0.0", - "playwright": "^1.56.1", + "playwright": "^1.60.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.1", @@ -52,7 +52,7 @@ "tailwindcss": "^4.1.17", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", - "vite": "^7.2.2", + "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index c2888d97..62f94fd8 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2205,10 +2205,10 @@ export interface components { totalStories: number; }; PersonSummaryDTO: { + title?: string; /** Format: uuid */ id?: string; displayName?: string; - title?: string; firstName?: string; lastName?: string; /** Format: int64 */ @@ -2315,8 +2315,6 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2325,6 +2323,8 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index 51dd5d8e..7fa8ab9d 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -5,24 +5,33 @@ import { page } from 'vitest/browser'; import ReaderRecentDocs from './ReaderRecentDocs.svelte'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; afterEach(() => { cleanup(); }); -const baseDoc: Document = { +const baseDoc: DocumentListItem = { id: 'doc1', title: 'Brief an Hans', originalFilename: 'brief.pdf', - status: 'UPLOADED', - metadataComplete: true, - scriptType: 'HANDWRITING_KURRENT', + completionPercentage: 0, + receivers: [], + tags: [], + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, createdAt: '2025-01-01T12:00:00Z', updatedAt: '2025-01-01T12:00:00Z' }; -const updatedDoc: Document = { +const updatedDoc: DocumentListItem = { ...baseDoc, id: 'doc2', title: 'Urkunde 1920', @@ -89,7 +98,7 @@ describe('ReaderRecentDocs', () => { }); it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => { - const recentDoc: Document = { + const recentDoc: DocumentListItem = { ...baseDoc, id: 'doc-recent', createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), @@ -113,7 +122,7 @@ describe('ReaderRecentDocs', () => { }); it('shows "Neu" badge when document was created 6 days ago', async () => { - const almostOldDoc: Document = { + const almostOldDoc: DocumentListItem = { ...baseDoc, id: 'doc-almost-old', createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), @@ -125,7 +134,7 @@ describe('ReaderRecentDocs', () => { }); it('renders sender name text when sender is present', async () => { - const docWithSender: Document = { + const docWithSender: DocumentListItem = { ...baseDoc, sender: { id: 'p1', -- 2.49.1 From 2e0eb40aec398324f93e538bd6919ea693be3e56 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:40:10 +0200 Subject: [PATCH 4/4] test(debounce): fix flaky onExit-cancels-debounce test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test raced a real 150 ms setTimeout: fill('Walter') started the debounce, then focus + keyboard(Escape) had to complete before 150 ms elapsed. Under CI load the Playwright CDP round-trips exceeded 150 ms, letting the debounce fire first. Fix: install vi.useFakeTimers() after the stable-state setup (so vi.waitFor()'s real-timer polling still works), freeze the Walter debounce, let Escape trigger onExit/cancel, then advance fake time with vi.advanceTimersByTimeAsync() — no real-wall-clock race. Co-Authored-By: Claude Sonnet 4.6 --- .../PersonMentionEditor.svelte.spec.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 3b58a62f..a0486e6d 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -409,19 +409,24 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const fetchesBeforeEscape = fetchMock.mock.calls.length; - // Trigger a new debounced search (queues runSearch after 150 ms), then - // immediately Escape *while focus is back in the editor* so Tiptap's - // suggestion-plugin Escape handler fires onExit before the debounce. - // Without onExit cancelling the pending debounce, runSearch executes - // against the now-unmounted dropdown's state. - await page.getByRole('searchbox').fill('Walter'); - // Focus the editor so the Escape lands on Tiptap's suggestion handler. - (page.getByRole('textbox').element() as HTMLElement).focus(); - await userEvent.keyboard('{Escape}'); - - // Wait past the debounce window. If onExit did not cancel the pending - // debounce, a fetch with q=Walter would still fire here. - await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + // Freeze setTimeout so the 150 ms debounce cannot fire before Escape + // triggers onExit. We install fake timers only now — after the setup + // above — so that vi.waitFor()'s real-timer polling still worked. + vi.useFakeTimers(); + try { + // fill() dispatches the input event synchronously via CDP; by the + // time the await resolves, onSearch('Walter') has run and the fake + // debounce timer is set. + await page.getByRole('searchbox').fill('Walter'); + // Focus the editor so the Escape lands on Tiptap's suggestion handler. + (page.getByRole('textbox').element() as HTMLElement).focus(); + await userEvent.keyboard('{Escape}'); + // onExit has now called debouncedSearch.cancel(). Advance past the + // debounce window — the cancelled timer must not fire. + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS); + } finally { + vi.useRealTimers(); + } const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter( -- 2.49.1