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:
Marcel
2026-05-07 23:06:47 +02:00
parent 59a2faa145
commit e92e9e452e
7 changed files with 232 additions and 136 deletions

View File

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

View File

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

View File

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