From 5bdd26c792a3b2ebb65cd832c1e3a6bcd9eb51c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 12:11:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(#145):=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20full-table=20scan,=20a11y,=20grid,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentService.getRecentActivity: replace findAll(Sort)+stream().limit() with findAll(PageRequest) so LIMIT is pushed to the database - +page.svelte: collapse two-column grid to single column when mentions is empty - DashboardNeedsMetadata: raise "show all" link from text-xs (12px) to text-sm (14px) and add hover:underline for WCAG 1.4.1 - DashboardRecentDocuments: add comment explaining why T12:00:00 noon-anchor is absent (updatedAt is a full ISO datetime, not a date-only string) - DocumentServiceTest: update getRecentActivity tests to assert PageRequest usage instead of findAll(Sort) - DocumentRepositoryTest: add @DataJpaTest verifying findAll(PageRequest) returns only size rows, not the full table - DocumentControllerTest: add test for default size=5 when param is omitted - NotificationServiceTest: add test documenting that type+read=true falls through to the type-only query (intentional) - page.server.spec.ts: replace stale tests with full dashboard-mode coverage - DashboardMentions.svelte.spec.ts: add tests for REPLY type and absent documentId - DashboardResumeStrip.svelte.spec.ts: add corrupt localStorage test Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentService.java | 5 +- .../controller/DocumentControllerTest.java | 11 ++ .../repository/DocumentRepositoryTest.java | 17 ++ .../service/DocumentServiceTest.java | 23 ++- .../service/NotificationServiceTest.java | 18 ++ .../DashboardMentions.svelte.spec.ts | 17 ++ .../components/DashboardNeedsMetadata.svelte | 2 +- .../DashboardRecentDocuments.svelte | 1 + .../DashboardResumeStrip.svelte.spec.ts | 7 + frontend/src/routes/+page.svelte | 2 +- frontend/src/routes/page.server.spec.ts | 160 +++++++++++------- 11 files changed, 193 insertions(+), 70 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index eae924da..e1c7434d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -262,8 +262,9 @@ public class DocumentService { // 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC) public List getRecentActivity(int size) { - return documentRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt")) - .stream().limit(size).toList(); + return documentRepository.findAll( + PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt")) + ).getContent(); } // 1. Allgemeine Suche (für das Suchfeld im Frontend) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 47a384a7..1166d873 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -427,6 +427,17 @@ class DocumentControllerTest { .andExpect(jsonPath("$[1].title").value("Beta")); } + @Test + @WithMockUser + void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception { + when(documentService.getRecentActivity(5)).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/recent-activity")) + .andExpect(status().isOk()); + + verify(documentService).getRecentActivity(5); + } + // ─── GET /api/documents/{id}/versions ──────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 04cab9d1..845646b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -152,6 +152,23 @@ class DocumentRepositoryTest { assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1); } + // ─── findAll (PageRequest) — recent activity ────────────────────────────── + + @Test + void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() { + for (int i = 0; i < 10; i++) { + documentRepository.save(Document.builder() + .title("Doc " + i).originalFilename("doc" + i + ".pdf") + .status(DocumentStatus.PLACEHOLDER).build()); + } + + Page result = documentRepository.findAll( + PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt"))); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(10); + } + // ─── findByMetadataCompleteFalse (Pageable) ─────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 0eed78a1..fb86d2a8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1218,21 +1218,30 @@ class DocumentServiceTest { @Test void getRecentActivity_returnsMostRecentlyUpdatedDocuments() { - java.time.LocalDateTime oldest = java.time.LocalDateTime.of(2024, 1, 1, 0, 0); - java.time.LocalDateTime middle = java.time.LocalDateTime.of(2024, 6, 1, 0, 0); - java.time.LocalDateTime newest = java.time.LocalDateTime.of(2024, 12, 1, 0, 0); - Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build(); Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build(); Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build(); - // findAll(Sort) returns documents already sorted DESC by updatedAt - when(documentRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt"))) - .thenReturn(List.of(doc3, doc2, doc1)); + Page page = new PageImpl<>(List.of(doc3, doc2)); + when(documentRepository.findAll(any(Pageable.class))).thenReturn(page); List result = documentService.getRecentActivity(2); assertThat(result).hasSize(2); assertThat(result).containsExactly(doc3, doc2); } + + @Test + void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() { + Page page = new PageImpl<>(List.of()); + when(documentRepository.findAll(any(Pageable.class))).thenReturn(page); + + documentService.getRecentActivity(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(documentRepository).findAll(captor.capture()); + assertThat(captor.getValue().getPageSize()).isEqualTo(3); + assertThat(captor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java index 56c8b5eb..892d35d8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -401,6 +401,24 @@ class NotificationServiceTest { .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); } + @Test + void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() { + // read=true with a type filter falls through to the type-only branch — + // it returns all notifications of that type (both read and unread). + // The read=true filter is intentionally not supported on the backend; + // callers that need only-read results must filter client-side. + when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5)); + + verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any()); + verify(notificationRepository, never()) + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); + } + // ─── private helpers ────────────────────────────────────────────────────── private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) { diff --git a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts b/frontend/src/lib/components/DashboardMentions.svelte.spec.ts index 5192ff73..c0dd67df 100644 --- a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardMentions.svelte.spec.ts @@ -65,4 +65,21 @@ describe('DashboardMentions', () => { render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] }); await expect.element(page.getByText('Maria Müller')).toBeInTheDocument(); }); + + it('shows "replied" label for REPLY type', async () => { + render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] }); + const widget = page.getByTestId('dashboard-mentions'); + await expect.element(widget).toBeInTheDocument(); + const link = page.getByRole('link'); + await expect.element(link).toBeInTheDocument(); + }); + + it('renders a span instead of a link when documentId is absent', async () => { + render(DashboardMentions, { + mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })] + }); + await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument(); + const links = page.getByRole('link'); + await expect.element(links).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/components/DashboardNeedsMetadata.svelte b/frontend/src/lib/components/DashboardNeedsMetadata.svelte index 9176829b..7cde5a87 100644 --- a/frontend/src/lib/components/DashboardNeedsMetadata.svelte +++ b/frontend/src/lib/components/DashboardNeedsMetadata.svelte @@ -29,7 +29,7 @@ let { incompleteDocs }: Props = $props(); {/each} diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte index 8477d4e1..5f3e7dae 100644 --- a/frontend/src/lib/components/DashboardRecentDocuments.svelte +++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte @@ -16,6 +16,7 @@ interface Props { let { recentDocs }: Props = $props(); function formatDate(dateStr: string): string { + // updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here return new Intl.DateTimeFormat(getLocale(), { day: 'numeric', month: 'long', diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts index 9d930da4..2fac46b0 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts @@ -40,4 +40,11 @@ describe('DashboardResumeStrip', () => { const link = page.getByRole('link'); await expect.element(link).toHaveAttribute('href', '/documents/doc-456'); }); + + it('renders nothing when localStorage contains malformed JSON', async () => { + localStorage.setItem('familienarchiv.lastVisited', '{not valid json'); + render(DashboardResumeStrip, {}); + const strip = page.getByTestId('resume-strip'); + await expect.element(strip).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 87f4f57e..1e8d64e3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -100,7 +100,7 @@ $effect(() => { {/if} -
+
diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index e250ee78..63d7abd6 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -19,40 +19,115 @@ function makeUrl(params: Record = {}) { return url; } -// ─── happy path ─────────────────────────────────────────────────────────────── +// ─── dashboard mode (no search filters) ────────────────────────────────────── -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); +describe('home page load — dashboard mode', () => { + it('sets isDashboard true and fetches all three widget APIs', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [{ id: 'n1' }] } + }) // notifications + .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } 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(); + expect(result.isDashboard).toBe(true); + expect(result.mentions).toHaveLength(1); + expect(result.incompleteDocs).toHaveLength(1); + expect(result.recentDocs).toHaveLength(1); + expect(result.documents).toEqual([]); }); - it('passes search params from the URL to the API', async () => { + it('defaults mentions to [] when notifications API rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons + .mockRejectedValueOnce(new Error('network')) // notifications + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.mentions).toEqual([]); + }); + + it('defaults incompleteDocs to [] when incomplete API rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons + .mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications + .mockRejectedValueOnce(new Error('network')) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.incompleteDocs).toEqual([]); + }); + + it('defaults recentDocs to [] when recent-activity API rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons + .mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete + .mockRejectedValueOnce(new Error('network')); // recent + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.recentDocs).toEqual([]); + }); +}); + +// ─── search mode (any filter active) ───────────────────────────────────────── + +describe('home page load — search mode', () => { + it('sets isDashboard false and skips widget APIs when q is set', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [{ id: 'd1' }] }) // search docs + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'Urlaub' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.isDashboard).toBe(false); + expect(result.documents).toHaveLength(1); + expect(result.mentions).toEqual([]); + expect(result.incompleteDocs).toEqual([]); + expect(result.recentDocs).toEqual([]); + // Only two API calls — no widget calls + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('passes search params from the URL to the documents 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); + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); await load({ url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), @@ -63,46 +138,14 @@ describe('home page load — happy path', () => { 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); - - 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); - - 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 } }) + GET: vi.fn().mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null }) } as ReturnType); await expect( @@ -123,6 +166,5 @@ describe('home page load — network error fallback', () => { expect(result.error).toBe('Daten konnten nicht geladen werden.'); expect(result.documents).toEqual([]); - expect(result.incompleteCount).toBe(0); }); });