diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index 990218b5..7d353051 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -392,9 +392,16 @@ public class DocumentController { @GetMapping("/density") public ResponseEntity density( - @RequestParam(required = false) LocalDate from, - @RequestParam(required = false) LocalDate to) { - DocumentDensityResult result = documentService.getDensity(from, to); + @RequestParam(required = false) String q, + @RequestParam(required = false) UUID senderId, + @RequestParam(required = false) UUID receiverId, + @RequestParam(required = false, name = "tag") List tags, + @RequestParam(required = false) String tagQ, + @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, + @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { + TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; + DocumentDensityResult result = documentService.getDensity( + q, senderId, receiverId, tags, tagQ, status, operator); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()) .body(result); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java deleted file mode 100644 index ce52f9a0..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.raddatz.familienarchiv.document; - -import java.time.LocalDate; - -/** - * Spring Data projection for the document_date min/max query backing the timeline - * density widget (issue #385). Column aliases in the native SQL must match these - * getter names exactly. Both values are {@code null} when the documents table is empty. - */ -public interface DocumentDateRangeProjection { - LocalDate getMinDate(); - LocalDate getMaxDate(); -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index 875a6977..a110d22c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -240,28 +240,4 @@ public interface DocumentRepository extends JpaRepository, JpaSp """) TranscriptionWeeklyStatsProjection findWeeklyStats(); - /** - * Document count grouped by calendar month for the timeline density widget (issue #385). - * Each row is {@code Object[]{ String yearMonth, long count }}. - * Months without documents are not present — the frontend fills gaps from minDate/maxDate. - */ - @Query(nativeQuery = true, value = """ - SELECT TO_CHAR(DATE_TRUNC('month', meta_date), 'YYYY-MM') AS month, - COUNT(*) AS doc_count - FROM documents - WHERE meta_date IS NOT NULL - AND (CAST(:from AS date) IS NULL OR meta_date >= :from) - AND (CAST(:to AS date) IS NULL OR meta_date <= :to) - GROUP BY 1 - ORDER BY 1 - """) - List findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to); - - /** - * Earliest and latest {@code meta_date} across all documents. Both getters return - * {@code null} when the table holds no documents. - */ - @Query(nativeQuery = true, value = "SELECT MIN(meta_date) AS minDate, MAX(meta_date) AS maxDate FROM documents") - DocumentDateRangeProjection findMinMaxDocumentDate(); - } \ No newline at end of file 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 9d831380..0a1adb07 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -127,15 +127,54 @@ public class DocumentService { /** * Per-month document counts for the timeline density widget (issue #385). - * Returns only months that have at least one document — the frontend fills - * gaps using the {@code minDate}/{@code maxDate} bounds. + * + *

Filter-reactive: the chart recomputes when other filters (sender, + * receiver, tag, q, status) change so it always matches the list it sits + * above. Date bounds (`from`/`to`) are deliberately omitted — the chart is + * the surface for picking those, so it must always span the broader space + * the user is selecting within. + * + *

Implementation note: groups in memory rather than via SQL GROUP BY + * because the existing {@link Specification} predicates compose easily + * with {@code findAll(spec)} and the archive size (≈5k docs) keeps this + * well under the 200ms p95 target. Cache-Control: max-age=300 on the + * controller layer absorbs repeated browse loads. */ - public DocumentDensityResult getDensity(LocalDate from, LocalDate to) { - List buckets = documentRepository.findDensityByMonth(from, to).stream() - .map(row -> new MonthBucket((String) row[0], ((Number) row[1]).intValue())) + public DocumentDensityResult getDensity( + String text, UUID sender, UUID receiver, + List tags, String tagQ, + DocumentStatus status, TagOperator tagOperator) { + boolean hasText = StringUtils.hasText(text); + List rankedIds = null; + if (hasText) { + rankedIds = documentRepository.findRankedIdsByFts(text); + if (rankedIds.isEmpty()) { + return new DocumentDensityResult(List.of(), null, null); + } + } + Specification spec = buildSearchSpec( + hasText, rankedIds, null, null, + sender, receiver, tags, tagQ, status, tagOperator); + + List dates = documentRepository.findAll(spec).stream() + .map(Document::getDocumentDate) + .filter(Objects::nonNull) .toList(); - DocumentDateRangeProjection range = documentRepository.findMinMaxDocumentDate(); - return new DocumentDensityResult(buckets, range.getMinDate(), range.getMaxDate()); + if (dates.isEmpty()) { + return new DocumentDensityResult(List.of(), null, null); + } + + Map counts = new java.util.TreeMap<>(); + for (LocalDate d : dates) { + String month = String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + counts.merge(month, 1, Integer::sum); + } + List buckets = counts.entrySet().stream() + .map(e -> new MonthBucket(e.getKey(), e.getValue())) + .toList(); + LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null); + LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null); + return new DocumentDensityResult(buckets, minDate, maxDate); } /** 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 484d6259..e30283fc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -1252,7 +1252,7 @@ class DocumentControllerTest { @Test @WithMockUser void density_returns200_withResultBody_whenAuthenticated() throws Exception { - when(documentService.getDensity(any(), any())).thenReturn( + when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( new DocumentDensityResult( List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)), java.time.LocalDate.of(1915, 8, 3), @@ -1270,7 +1270,7 @@ class DocumentControllerTest { @Test @WithMockUser void density_emitsPrivateCacheControlHeader() throws Exception { - when(documentService.getDensity(any(), any())).thenReturn( + when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( new DocumentDensityResult(List.of(), null, null)); mockMvc.perform(get("/api/documents/density")) @@ -1283,17 +1283,46 @@ class DocumentControllerTest { @Test @WithMockUser - void density_passesFromAndToParametersToService() throws Exception { - when(documentService.getDensity(any(), any())).thenReturn( + void density_forwardsSenderAndTagFilters() throws Exception { + when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( new DocumentDensityResult(List.of(), null, null)); + UUID senderId = UUID.randomUUID(); mockMvc.perform(get("/api/documents/density") - .param("from", "1914-01-01") - .param("to", "1918-12-31")) + .param("senderId", senderId.toString()) + .param("tag", "Familie") + .param("tag", "Urlaub") + .param("tagOp", "OR")) .andExpect(status().isOk()); verify(documentService).getDensity( - java.time.LocalDate.of(1914, 1, 1), - java.time.LocalDate.of(1918, 12, 31)); + org.mockito.ArgumentMatchers.isNull(), + eq(senderId), + org.mockito.ArgumentMatchers.isNull(), + eq(List.of("Familie", "Urlaub")), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + eq(org.raddatz.familienarchiv.tag.TagOperator.OR)); + } + + @Test + @WithMockUser + void density_forwardsStatusAndQueryText() throws Exception { + when(documentService.getDensity(any(), any(), any(), any(), any(), any(), any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density") + .param("q", "Brief") + .param("status", "REVIEWED")) + .andExpect(status().isOk()); + + verify(documentService).getDensity( + eq("Brief"), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + eq(DocumentStatus.REVIEWED), + eq(org.raddatz.familienarchiv.tag.TagOperator.AND)); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java index 946d72c3..09e0f24a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java @@ -1,101 +1,158 @@ package org.raddatz.familienarchiv.document; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; -import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagRepository; +import org.raddatz.familienarchiv.tag.TagOperator; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; import java.time.LocalDate; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import({PostgresContainerConfig.class, FlywayConfig.class}) +/** + * End-to-end test for the filter-reactive density aggregation. + * Density bars must recompute as the user changes other filters (sender, tag, + * status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart + * is the surface for picking those, so it must always span the broader space + * the user is selecting within. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class DocumentDensityIntegrationTest { - @Autowired - private DocumentRepository documentRepository; + @MockitoBean S3Client s3Client; + @Autowired DocumentService documentService; + @Autowired DocumentRepository documentRepository; + @Autowired PersonRepository personRepository; + @Autowired TagRepository tagRepository; - @Test - void findDensityByMonth_returnsEmptyList_whenNoDocumentsExist() { - List rows = documentRepository.findDensityByMonth(null, null); + private Person hans; + private Person anna; + private Tag familieTag; + private Tag urlaubTag; - assertThat(rows).isEmpty(); + @BeforeEach + void seed() { + hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build()); + familieTag = tagRepository.save(Tag.builder().name("Familie").build()); + urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build()); } @Test - void findDensityByMonth_groupsDocumentsByMonth() { - saveDocumentOn(LocalDate.of(1915, 8, 3)); - saveDocumentOn(LocalDate.of(1915, 8, 17)); - saveDocumentOn(LocalDate.of(1915, 9, 1)); - saveDocumentOn(LocalDate.of(1916, 1, 22)); + void getDensity_returnsAllMonths_whenNoFiltersApplied() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of()); + save("b", LocalDate.of(1915, 8, 17), null, Set.of()); + save("c", LocalDate.of(1915, 9, 1), null, Set.of()); - List rows = documentRepository.findDensityByMonth(null, null); + DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); - assertThat(rows).hasSize(3); - assertThat(rows).extracting(r -> r[0].toString()) - .containsExactly("1915-08", "1915-09", "1916-01"); - assertThat(rows).extracting(r -> ((Number) r[1]).longValue()) - .containsExactly(2L, 1L, 1L); + assertThat(result.buckets()).extracting(MonthBucket::month) + .containsExactly("1915-08", "1915-09"); + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1); + assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3)); + assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1)); } @Test - void findDensityByMonth_respectsFromAndToBounds() { - saveDocumentOn(LocalDate.of(1914, 6, 1)); - saveDocumentOn(LocalDate.of(1915, 8, 3)); - saveDocumentOn(LocalDate.of(1916, 1, 22)); - saveDocumentOn(LocalDate.of(1920, 12, 31)); + void getDensity_filtersBySender() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of()); + save("b", LocalDate.of(1916, 1, 4), hans, Set.of()); + save("c", LocalDate.of(1920, 5, 1), anna, Set.of()); - List rows = documentRepository.findDensityByMonth( - LocalDate.of(1915, 1, 1), - LocalDate.of(1916, 12, 31)); + DocumentDensityResult result = documentService.getDensity( + null, hans.getId(), null, null, null, null, null); - assertThat(rows).extracting(r -> r[0].toString()) + assertThat(result.buckets()).extracting(MonthBucket::month) .containsExactly("1915-08", "1916-01"); + assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4)); } @Test - void findDensityByMonth_excludesDocumentsWithoutDate() { - saveDocumentOn(LocalDate.of(1915, 8, 3)); - saveDocumentOn(null); + void getDensity_filtersByTag() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag)); + save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag)); - List rows = documentRepository.findDensityByMonth(null, null); + DocumentDensityResult result = documentService.getDensity( + null, null, null, List.of("Familie"), null, null, TagOperator.AND); - assertThat(rows).hasSize(1); - assertThat(rows.get(0)[0].toString()).isEqualTo("1915-08"); + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); } @Test - void findMinMaxDocumentDate_returnsNullValues_whenNoDocumentsExist() { - DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + void getDensity_combinesSenderAndTag() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag)); + save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag)); + save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag)); - assertThat(result.getMinDate()).isNull(); - assertThat(result.getMaxDate()).isNull(); + DocumentDensityResult result = documentService.getDensity( + null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND); + + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); } @Test - void findMinMaxDocumentDate_returnsEarliestAndLatestDates() { - saveDocumentOn(LocalDate.of(1915, 8, 3)); - saveDocumentOn(LocalDate.of(1899, 1, 15)); - saveDocumentOn(LocalDate.of(1950, 12, 31)); + void getDensity_filtersByStatus() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED); + save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER); - DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + DocumentDensityResult result = documentService.getDensity( + null, null, null, null, null, DocumentStatus.UPLOADED, null); - assertThat(result.getMinDate()).isEqualTo(LocalDate.of(1899, 1, 15)); - assertThat(result.getMaxDate()).isEqualTo(LocalDate.of(1950, 12, 31)); + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); } - private void saveDocumentOn(LocalDate date) { + @Test + void getDensity_returnsEmpty_whenNoDocumentsMatch() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of()); + + DocumentDensityResult result = documentService.getDensity( + null, anna.getId(), null, null, null, null, null); + + assertThat(result.buckets()).isEmpty(); + assertThat(result.minDate()).isNull(); + assertThat(result.maxDate()).isNull(); + } + + @Test + void getDensity_excludesDocumentsWithNullDate() { + save("dated", LocalDate.of(1915, 8, 3), null, Set.of()); + save("undated", null, null, Set.of()); + + DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); + + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); + } + + private void save(String suffix, LocalDate date, Person sender, Set tags) { + save(suffix, date, sender, tags, DocumentStatus.UPLOADED); + } + + private void save(String suffix, LocalDate date, Person sender, Set tags, DocumentStatus status) { documentRepository.save(Document.builder() - .title("Doc " + date) - .originalFilename("doc-" + (date == null ? "nodate-" + System.nanoTime() : date.toString()) + ".pdf") - .status(DocumentStatus.UPLOADED) + .title("Doc " + suffix) + .originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf") + .status(status) .documentDate(date) + .sender(sender) + .tags(new HashSet<>(tags)) .build()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 545118f2..cec66dfc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.mock.web.MockMultipartFile; import java.time.LocalDate; @@ -2325,14 +2326,10 @@ class DocumentServiceTest { // ─── getDensity ──────────────────────────────────────────────────────────── @Test - void getDensity_returnsEmptyResult_whenArchiveIsEmpty() { - when(documentRepository.findDensityByMonth(null, null)).thenReturn(List.of()); - DocumentDateRangeProjection emptyRange = mock(DocumentDateRangeProjection.class); - when(emptyRange.getMinDate()).thenReturn(null); - when(emptyRange.getMaxDate()).thenReturn(null); - when(documentRepository.findMinMaxDocumentDate()).thenReturn(emptyRange); + void getDensity_returnsEmptyResult_whenNoDocumentsMatch() { + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of()); - DocumentDensityResult result = documentService.getDensity(null, null); + DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); assertThat(result.buckets()).isEmpty(); assertThat(result.minDate()).isNull(); @@ -2340,37 +2337,41 @@ class DocumentServiceTest { } @Test - void getDensity_mapsRepositoryRowsToMonthBuckets() { - List rows = List.of( - new Object[]{"1915-08", 2L}, - new Object[]{"1915-09", 1L} - ); - when(documentRepository.findDensityByMonth(null, null)).thenReturn(rows); - DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class); - when(range.getMinDate()).thenReturn(LocalDate.of(1915, 8, 3)); - when(range.getMaxDate()).thenReturn(LocalDate.of(1915, 9, 1)); - when(documentRepository.findMinMaxDocumentDate()).thenReturn(range); + void getDensity_groupsMatchingDocumentsByMonth() { + Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build(); + Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build(); + Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build(); + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c)); + when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); - DocumentDensityResult result = documentService.getDensity(null, null); + DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); assertThat(result.buckets()).extracting(MonthBucket::month) .containsExactly("1915-08", "1915-09"); - assertThat(result.buckets()).extracting(MonthBucket::count) - .containsExactly(2, 1); + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1); assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3)); assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1)); } @Test - void getDensity_passesFromAndToBoundsToRepository() { - when(documentRepository.findDensityByMonth(any(), any())).thenReturn(List.of()); - DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class); - when(documentRepository.findMinMaxDocumentDate()).thenReturn(range); + void getDensity_excludesDocumentsWithNullDate() { + Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build(); + Document undated = Document.builder().documentDate(null).build(); + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated)); + when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); - LocalDate from = LocalDate.of(1914, 1, 1); - LocalDate to = LocalDate.of(1918, 12, 31); - documentService.getDensity(from, to); + DocumentDensityResult result = documentService.getDensity(null, null, null, null, null, null, null); - verify(documentRepository).findDensityByMonth(from, to); + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); + } + + @Test + void getDensity_shortCircuits_whenFtsReturnsNoMatches() { + when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); + + DocumentDensityResult result = documentService.getDensity("xyz", null, null, null, null, null, null); + + assertThat(result.buckets()).isEmpty(); + verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class)); } }