feat(documents): add metadataComplete flag and enrichment queue endpoints

Adds a metadata_complete column (default true for existing rows) to drive
the enrichment queue. New drop-zone uploads always start as false; createDocument
uses an explicit DTO flag or a heuristic (any of date/sender/receivers present →
true); the mass importer applies the same heuristic per row.

New endpoints: GET /api/documents/incomplete-count, /incomplete, /incomplete/next.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 13:25:57 +01:00
parent 2bfbf45eba
commit 0ce18e1eed
9 changed files with 306 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
@@ -212,6 +213,78 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
}
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
@Test
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getIncompleteCount_returns200_withCount() throws Exception {
when(documentService.getIncompleteCount()).thenReturn(3L);
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
@Test
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isUnauthorized());
}
@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));
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@Test
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getNextIncomplete_returns200_whenNextExists() throws Exception {
UUID excludeId = UUID.randomUUID();
Document next = Document.builder()
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Nächster"));
}
@Test
@WithMockUser
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
UUID excludeId = UUID.randomUUID();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -11,7 +12,10 @@ import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -344,6 +348,162 @@ class DocumentServiceTest {
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
}
// ─── getIncompleteCount ───────────────────────────────────────────────────
@Test
void getIncompleteCount_delegatesToRepository() {
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
}
// ─── 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));
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
// ─── findNextIncompleteDocument ───────────────────────────────────────────
@Test
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
UUID currentId = UUID.randomUUID();
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.of(next));
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
}
@Test
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
UUID currentId = UUID.randomUUID();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.empty());
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
}
// ─── storeDocument metadataComplete ──────────────────────────────────────
@Test
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().isMetadataComplete()).isFalse();
}
@Test
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
documentService.storeDocument(file);
assertThat(existing.isMetadataComplete()).isTrue();
}
// ─── createDocument metadataComplete ─────────────────────────────────────
@Test
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setMetadataComplete(true);
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
@Test
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
}
@Test
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
// ─── updateDocument metadataComplete ─────────────────────────────────────
@Test
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setMetadataComplete(true);
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isTrue();
}
@Test
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
// metadataComplete not set → null
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isFalse();
}
@Test
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
UUID id1 = UUID.randomUUID();