feat(documents): add GET /api/documents/density endpoint (#385)

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 21:52:36 +02:00
parent fbf4725e97
commit 1060be7def
2 changed files with 68 additions and 0 deletions

View File

@@ -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<DocumentDensityResult> 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) {}

View File

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