feat(#145): add status filter to GET /api/documents/search

Dashboard "Recently Added" widget calls ?status=REVIEWED&size=5.
Null status is a no-op — existing callers without the param are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 00:21:48 +01:00
parent 968993c48e
commit 147d1f2de5
6 changed files with 77 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.RequirePermission;
@@ -184,8 +185,9 @@ public class DocumentController {
@RequestParam(required = false) LocalDate to, @RequestParam(required = false) LocalDate to,
@RequestParam(required = false) UUID senderId, @RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId, @RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags) { @RequestParam(required = false, name = "tag") List<String> tags,
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags)); @RequestParam(required = false) DocumentStatus status) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
} }
// --- VERSIONS --- // --- VERSIONS ---

View File

@@ -7,6 +7,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -55,6 +56,11 @@ public class DocumentSpecifications {
}; };
} }
// Filtert nach Status
public static Specification<Document> hasStatus(DocumentStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
// Filtert nach Schlagworten (UND-Verknüpfung) // Filtert nach Schlagworten (UND-Verknüpfung)
public static Specification<Document> hasTags(List<String> tags) { public static Specification<Document> hasTags(List<String> tags) {
return (root, query, cb) -> { return (root, query, cb) -> {

View File

@@ -261,12 +261,13 @@ public class DocumentService {
} }
// 1. Allgemeine Suche (für das Suchfeld im Frontend) // 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) { public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
Specification<Document> spec = Specification.where(hasText(text)) Specification<Document> spec = Specification.where(hasText(text))
.and(isBetween(from, to)) .and(isBetween(from, to))
.and(hasSender(sender)) .and(hasSender(sender))
.and(hasReceiver(receiver)) .and(hasReceiver(receiver))
.and(hasTags(tags)); .and(hasTags(tags))
.and(hasStatus(status));
// Neueste zuerst (nach Erstellungsdatum) // Neueste zuerst (nach Erstellungsdatum)
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt")); return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));

View File

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
@@ -27,6 +28,7 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -56,13 +58,32 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_returns200_whenAuthenticated() throws Exception { void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList()); .thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
}
@Test
@WithMockUser
void search_withInvalidStatus_returns400() throws Exception {
mockMvc.perform(get("/api/documents/search").param("status", "INVALID"))
.andExpect(status().isBadRequest());
}
// ─── POST /api/documents ───────────────────────────────────────────────── // ─── POST /api/documents ─────────────────────────────────────────────────
@Test @Test

View File

@@ -249,4 +249,24 @@ class DocumentSpecificationsTest {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt")))); List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
// ─── hasStatus ────────────────────────────────────────────────────────────
@Test
void hasStatus_returnsAllDocuments_whenStatusIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(null)));
assertThat(result).hasSize(3);
}
@Test
void hasStatus_returnsOnlyMatchingDocuments_whenStatusIsSet() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.PLACEHOLDER)));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
}
@Test
void hasStatus_returnsEmpty_whenNoDocumentMatchesStatus() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
assertThat(result).isEmpty();
}
} }

View File

@@ -1191,4 +1191,26 @@ class DocumentServiceTest {
Object result = method.invoke(null, (String) null); Object result = method.invoke(null, (String) null);
assertThat(result).isNull(); assertThat(result).isNull();
} }
// ─── searchDocuments — status filter ─────────────────────────────────────
@Test
void searchDocuments_passesStatusSpecificationToRepository() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
@Test
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, null);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
} }