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:
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
|
|||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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));
|
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 ---
|
// --- TRAINING LABELS ---
|
||||||
|
|
||||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
||||||
|
|||||||
@@ -1240,4 +1240,60 @@ class DocumentControllerTest {
|
|||||||
.andExpect(jsonPath("$.errors[0].message").value(
|
.andExpect(jsonPath("$.errors[0].message").value(
|
||||||
org.hamcrest.Matchers.containsString("not found")));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user