feat(documents): make density endpoint filter-reactive (#385)
Density bars now recompute when other filters change so the chart always matches the list it sits above. Selectable filters: q, senderId, receiverId, tag (multi), tagQ, status, tagOp. 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. Architectural shift: drop the native SQL GROUP BY in favour of in-memory grouping over the existing Specification-driven findAll. This composes for free with all the search predicates (FTS-rank-then-filter, sender/receiver, tag-with-descendants, tagQ partial match, status, tagOp) and keeps the density implementation a thin layer on top of searchDocuments. At the current archive size (~5k docs) this stays well under the p95 200ms target; Cache-Control: max-age=300 absorbs repeated browse loads. - Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection. - Replaces DocumentService.getDensity(LocalDate, LocalDate) with the filter-aware overload. - Endpoint accepts the same query params as /api/documents/search minus paging+sort+from+to. - DocumentDensityIntegrationTest rewritten as @SpringBootTest covering no-filter / sender / tag / status / sender+tag combos via real PostgreSQL. - DocumentServiceTest unit tests updated to the new signature. - DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -392,9 +392,16 @@ public class DocumentController {
|
||||
|
||||
@GetMapping("/density")
|
||||
public ResponseEntity<DocumentDensityResult> 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<String> 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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -240,28 +240,4 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, 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<Object[]> 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();
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<MonthBucket> 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<String> tags, String tagQ,
|
||||
DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) {
|
||||
return new DocumentDensityResult(List.of(), null, null);
|
||||
}
|
||||
}
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, null, null,
|
||||
sender, receiver, tags, tagQ, status, tagOperator);
|
||||
|
||||
List<LocalDate> 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<String, Integer> 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<MonthBucket> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Tag> tags) {
|
||||
save(suffix, date, sender, tags, DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object[]> 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user