From 1060be7def107b0938b758e839c122ce93d203bc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:52:36 +0200 Subject: [PATCH] feat(documents): add GET /api/documents/density endpoint (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated read endpoint backing the timeline density widget. Optional from/to LocalDate query params narrow the aggregation. Response carries Cache-Control: private, max-age=300 so repeated browse sessions skip the aggregation query (per Tobias' devops review). No @RequirePermission needed — inherits the global anyRequest().authenticated() rule. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentController.java | 12 ++++ .../document/DocumentControllerTest.java | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) 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 add2a9f4..990218b5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document; import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; +import java.util.concurrent.TimeUnit; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService; import org.raddatz.familienarchiv.user.UserService; import org.springframework.data.domain.Sort; import org.springframework.security.core.Authentication; +import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -388,6 +390,16 @@ public class DocumentController { return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable)); } + @GetMapping("/density") + public ResponseEntity density( + @RequestParam(required = false) LocalDate from, + @RequestParam(required = false) LocalDate to) { + DocumentDensityResult result = documentService.getDensity(from, to); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()) + .body(result); + } + // --- TRAINING LABELS --- public record TrainingLabelRequest(String label, boolean enrolled) {} 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 ad07afb7..484d6259 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -1240,4 +1240,60 @@ class DocumentControllerTest { .andExpect(jsonPath("$.errors[0].message").value( org.hamcrest.Matchers.containsString("not found"))); } + + // ─── GET /api/documents/density ─────────────────────────────────────────── + + @Test + void density_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void density_returns200_withResultBody_whenAuthenticated() throws Exception { + when(documentService.getDensity(any(), any())).thenReturn( + new DocumentDensityResult( + List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)), + java.time.LocalDate.of(1915, 8, 3), + java.time.LocalDate.of(1915, 9, 1))); + + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.buckets").isArray()) + .andExpect(jsonPath("$.buckets[0].month").value("1915-08")) + .andExpect(jsonPath("$.buckets[0].count").value(2)) + .andExpect(jsonPath("$.minDate").value("1915-08-03")) + .andExpect(jsonPath("$.maxDate").value("1915-09-01")); + } + + @Test + @WithMockUser + void density_emitsPrivateCacheControlHeader() throws Exception { + when(documentService.getDensity(any(), any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("max-age=300"))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("private"))); + } + + @Test + @WithMockUser + void density_passesFromAndToParametersToService() throws Exception { + when(documentService.getDensity(any(), any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density") + .param("from", "1914-01-01") + .param("to", "1918-12-31")) + .andExpect(status().isOk()); + + verify(documentService).getDensity( + java.time.LocalDate.of(1914, 1, 1), + java.time.LocalDate.of(1918, 12, 31)); + } }