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); }); });