feat(#145): add IncompleteDocumentDTO and ?size= param to GET /api/documents/incomplete

Dashboard widget calls ?size=3 to cap the list. Response now returns
{id, title} DTO instead of full Document entity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 00:19:06 +01:00
parent 304359f67d
commit 968993c48e
7 changed files with 108 additions and 13 deletions

View File

@@ -13,6 +13,7 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -164,8 +165,9 @@ public class DocumentController {
}
@GetMapping("/incomplete")
public List<Document> getIncomplete() {
return documentService.findIncompleteDocuments();
public List<IncompleteDocumentDTO> getIncomplete(
@RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size);
}
@GetMapping("/incomplete/next")

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record IncompleteDocumentDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
) {}

View File

@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -46,6 +48,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByMetadataCompleteFalse(Sort sort);
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@Query("SELECT DISTINCT d FROM Document d " +

View File

@@ -4,11 +4,13 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -313,8 +315,12 @@ public class DocumentService {
return documentRepository.countByMetadataCompleteFalse();
}
public List<Document> findIncompleteDocuments() {
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return documentRepository.findByMetadataCompleteFalse(pageable)
.stream()
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
.toList();
}
public Optional<Document> findNextIncompleteDocument(UUID currentId) {

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.PermissionAspect;
@@ -25,6 +26,8 @@ import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
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.multipart;
@@ -315,16 +318,39 @@ class DocumentControllerTest {
@Test
@WithMockUser
void getIncomplete_returns200_withList() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
void getIncomplete_returns200_withDTOList() throws Exception {
UUID id = UUID.randomUUID();
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
}
@Test
@WithMockUser
void getIncomplete_withSizeParam_passesItToService() throws Exception {
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(5);
}
@Test
@WithMockUser
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(10);
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@Test

View File

@@ -11,6 +11,10 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.util.List;
import java.util.Optional;
@@ -147,4 +151,25 @@ class DocumentRepositoryTest {
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
}
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
@Test
void findByMetadataCompleteFalse_withPageable_returnsOnlyIncompleteAndRespectsSizeCap() {
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("Incomplete " + i).originalFilename("inc" + i + ".pdf")
.status(DocumentStatus.UPLOADED).metadataComplete(false).build());
}
documentRepository.save(Document.builder()
.title("Complete").originalFilename("complete.pdf")
.status(DocumentStatus.REVIEWED).metadataComplete(true).build());
Page<Document> result = documentRepository.findByMetadataCompleteFalse(
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(result.getContent()).hasSize(3);
assertThat(result.getTotalElements()).isEqualTo(5);
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
}
}

View File

@@ -7,12 +7,16 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
@@ -360,12 +364,30 @@ class DocumentServiceTest {
// ─── findIncompleteDocuments ──────────────────────────────────────────────
@Test
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
void findIncompleteDocuments_returnsDTOsWithIdAndTitle() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Unvollständig").build();
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(doc)));
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(id);
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
}
@Test
void findIncompleteDocuments_passesSizeToPageable() {
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
.thenReturn(Page.empty());
documentService.findIncompleteDocuments(3);
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(documentRepository).findByMetadataCompleteFalse(captor.capture());
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
assertThat(captor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "createdAt"));
}
// ─── findNextIncompleteDocument ───────────────────────────────────────────