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:
@@ -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