From 3e65b2feb351d7015c30a10ba33908926bdf94ac Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 12:27:21 +0100 Subject: [PATCH] feat: add admin backfill-versions endpoint for documents without history Adds POST /api/admin/backfill-versions which creates an initial snapshot (editorName="Datenimport", changedFields=[]) for every document that has no version entry yet, using the document's createdAt as the version timestamp. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AdminController.java | 12 ++++ .../familienarchiv/dto/BackfillResult.java | 7 ++ .../repository/DocumentRepository.java | 3 + .../service/DocumentService.java | 4 ++ .../service/DocumentVersionService.java | 20 ++++++ .../controller/AdminControllerTest.java | 61 ++++++++++++++++ .../service/DocumentVersionServiceTest.java | 70 +++++++++++++++++++ 7 files changed, 177 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java index 83645759..0f6f5c14 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java @@ -1,7 +1,10 @@ package org.raddatz.familienarchiv.controller; +import org.raddatz.familienarchiv.dto.BackfillResult; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.MassImportService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor; public class AdminController { private final MassImportService massImportService; + private final DocumentService documentService; + private final DocumentVersionService documentVersionService; @PostMapping("/trigger-import") public ResponseEntity triggerMassImport() { @@ -29,4 +34,11 @@ public class AdminController { public ResponseEntity importStatus() { return ResponseEntity.ok(massImportService.getStatus()); } + + @PostMapping("/backfill-versions") + public ResponseEntity backfillVersions() { + int count = documentVersionService.backfillMissingVersions( + documentService.getDocumentsWithoutVersions()); + return ResponseEntity.ok(new BackfillResult(count)); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java new file mode 100644 index 00000000..3178d68e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java @@ -0,0 +1,7 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BackfillResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 370ceb29..d2d6047b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -34,6 +34,9 @@ public interface DocumentRepository extends JpaRepository, JpaSp List findByTags_Id(UUID tagId); + @Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)") + List findDocumentsWithoutVersions(); + @Query("SELECT DISTINCT d FROM Document d " + "JOIN d.receivers r " + "WHERE " + diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 303fda53..521b8199 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -257,6 +257,10 @@ public class DocumentService { .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); } + public List getDocumentsWithoutVersions() { + return documentRepository.findDocumentsWithoutVersions(); + } + public List getDocumentsBySender(UUID senderId) { return documentRepository.findBySenderId(senderId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java index 70720551..a0092eec 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java @@ -56,6 +56,26 @@ public class DocumentVersionService { .build()); } + @Transactional + public int backfillMissingVersions(List docs) { + int count = 0; + for (Document doc : docs) { + List existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId()); + if (!existing.isEmpty()) continue; + LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now(); + versionRepository.save(DocumentVersion.builder() + .documentId(doc.getId()) + .savedAt(savedAt) + .editorId(null) + .editorName("Datenimport") + .snapshot(serializeSnapshot(doc)) + .changedFields("[]") + .build()); + count++; + } + return count; + } + public List getSummaries(UUID documentId) { return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream() .map(v -> new DocumentVersionSummary( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java new file mode 100644 index 00000000..b37eb3d7 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java @@ -0,0 +1,61 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.DocumentVersionService; +import org.raddatz.familienarchiv.service.MassImportService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AdminController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class AdminControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean MassImportService massImportService; + @MockitoBean DocumentService documentService; + @MockitoBean DocumentVersionService documentVersionService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + @Test + void backfillVersions_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "USER") + void backfillVersions_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void backfillVersions_returns200_withCount_whenAdmin() throws Exception { + when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); + when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); + + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(1)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java index cb75cfd0..90b20dd9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java @@ -304,6 +304,76 @@ class DocumentVersionServiceTest { .isInstanceOf(DomainException.class); } + // ─── backfillMissingVersions ────────────────────────────────────────────── + + @Test + void backfill_createsVersion_withEditorNameDatenimport() { + Document doc = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport"); + } + + @Test + void backfill_usesDocumentCreatedAt_asSavedAt() { + LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0); + Document doc = Document.builder() + .id(UUID.randomUUID()).title("T").createdAt(createdAt).build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt); + } + + @Test + void backfill_setsChangedFieldsEmpty() { + Document doc = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).isEqualTo("[]"); + } + + @Test + void backfill_skipsDocuments_thatAlreadyHaveVersions() { + Document doc = minimalDocument(); + DocumentVersion existing = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}") + .changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing)); + + int count = versionService.backfillMissingVersions(List.of(doc)); + + verify(versionRepository, never()).save(any()); + assertThat(count).isZero(); + } + + @Test + void backfill_returnsCountOfCreatedVersions() { + Document d1 = minimalDocument(); + Document d2 = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + int count = versionService.backfillMissingVersions(List.of(d1, d2)); + + assertThat(count).isEqualTo(2); + } + // ─── helpers ────────────────────────────────────────────────────────────── private void authenticateAs(String username) {