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 c8383c34..eb4c6873 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -143,6 +143,70 @@ class DocumentControllerTest { .value("Er schrieb einen langen Brief")); } + // ─── /api/documents/search pagination ───────────────────────────────────── + + @Test + @WithMockUser + void search_responseExposesPagingFields() throws Exception { + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + + mockMvc.perform(get("/api/documents/search")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageNumber").exists()) + .andExpect(jsonPath("$.pageSize").exists()) + .andExpect(jsonPath("$.totalPages").exists()) + .andExpect(jsonPath("$.totalElements").exists()); + } + + @Test + @WithMockUser + void search_returns400_whenSizeExceedsMax() throws Exception { + // Locks @Validated on the controller — removing it silently reopens the + // DoS window where a client could request all 1500 docs + enrichment. + mockMvc.perform(get("/api/documents/search").param("size", "101")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void search_returns400_whenSizeBelowMin() throws Exception { + mockMvc.perform(get("/api/documents/search").param("size", "0")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void search_returns400_whenPageNegative() throws Exception { + mockMvc.perform(get("/api/documents/search").param("page", "-1")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void search_returns400_whenPageAboveMax() throws Exception { + // Guards against page * size overflow into negative SQL OFFSET + mockMvc.perform(get("/api/documents/search").param("page", "200000")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser + void search_passesPageRequestToService() throws Exception { + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(DocumentSearchResult.of(List.of())); + + mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25")) + .andExpect(status().isOk()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture()); + org.springframework.data.domain.Pageable pageable = captor.getValue(); + org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2); + org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25); + } + // ─── POST /api/documents ───────────────────────────────────────────────── @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 41c0479b..aef18793 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1323,6 +1323,104 @@ class DocumentServiceTest { assertThat(result).isNull(); } + // ─── searchDocuments — pagination ──────────────────────────────────────── + + @Test + void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() { + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null, + org.springframework.data.domain.PageRequest.of(1, 50)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)); + verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); + } + + @Test + void searchDocuments_fastPath_propagatesPageableToDatabase() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null, + org.springframework.data.domain.PageRequest.of(3, 25)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); + assertThat(captor.getValue().getPageNumber()).isEqualTo(3); + assertThat(captor.getValue().getPageSize()).isEqualTo(25); + } + + @Test + void searchDocuments_fastPath_returnsPageableTotalsOnResult() { + // The service MUST report the full match count from Page.getTotalElements(), + // not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong. + Document d = Document.builder().id(UUID.randomUUID()).title("T").build(); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L)); + + DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, + org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null, + org.springframework.data.domain.PageRequest.of(0, 50)); + + assertThat(result.totalElements()).isEqualTo(120L); + assertThat(result.pageNumber()).isZero(); + assertThat(result.pageSize()).isEqualTo(50); + assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50) + assertThat(result.items()).hasSize(1); // only the slice is enriched + } + + @Test + void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() { + // Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items + // back with totalElements = 120. + List all = new java.util.ArrayList<>(); + for (int i = 0; i < 120; i++) { + Person p = Person.builder() + .id(UUID.randomUUID()) + .firstName("F" + i) + .lastName(String.format("L%03d", i)) + .build(); + all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build()); + } + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(all); + + DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, + org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null, + org.springframework.data.domain.PageRequest.of(1, 50)); + + assertThat(result.totalElements()).isEqualTo(120L); + assertThat(result.pageNumber()).isEqualTo(1); + assertThat(result.pageSize()).isEqualTo(50); + assertThat(result.totalPages()).isEqualTo(3); + assertThat(result.items()).hasSize(50); + // Page 1 (offset 50) under ascending sender sort should start at L050 + assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050"); + } + + @Test + void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() { + // Guards the JPA edge case where page * size > totalElements. + // Must not throw, must return empty content + correct totalElements. + List all = new java.util.ArrayList<>(); + for (int i = 0; i < 30; i++) { + Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build(); + all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build()); + } + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(all); + + DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null, + org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null, + org.springframework.data.domain.PageRequest.of(10, 50)); + + assertThat(result.items()).isEmpty(); + assertThat(result.totalElements()).isEqualTo(30L); + } + // ─── searchDocuments — status filter ───────────────────────────────────── @Test