From 315b368f880e277cf6c95e8797e6b5959e984b3c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:29:41 +0100 Subject: [PATCH] feat: add DocumentVersion entity, repository, service, and migration Creates the document_versions table (V9) with JSONB snapshot and changed_fields columns. DocumentVersionService records a version on every create/update, resolves the editor name from the security context, and computes changedFields by diffing against the previous snapshot. Refs #38 Co-Authored-By: Claude Sonnet 4.6 --- .../dto/DocumentVersionSummary.java | 14 + .../familienarchiv/model/DocumentVersion.java | 50 +++ .../repository/DocumentVersionRepository.java | 17 + .../service/DocumentVersionService.java | 209 +++++++++++ .../migration/V9__add_document_versions.sql | 11 + .../service/DocumentVersionServiceTest.java | 329 ++++++++++++++++++ 6 files changed, 630 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java create mode 100644 backend/src/main/resources/db/migration/V9__add_document_versions.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java new file mode 100644 index 00000000..8ad065d2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record DocumentVersionSummary( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime savedAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String editorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List changedFields +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java new file mode 100644 index 00000000..8d9e4d22 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java @@ -0,0 +1,50 @@ +package org.raddatz.familienarchiv.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "document_versions") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentVersion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Column(name = "saved_at", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime savedAt; + + @Column(name = "editor_id") + private UUID editorId; + + @Column(name = "editor_name", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String editorName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String snapshot; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "changed_fields", columnDefinition = "jsonb", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String changedFields; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java new file mode 100644 index 00000000..b1f58ee2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.DocumentVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DocumentVersionRepository extends JpaRepository { + + List findByDocumentIdOrderBySavedAtAsc(UUID documentId); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java new file mode 100644 index 00000000..8d595478 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java @@ -0,0 +1,209 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.DocumentVersionRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DocumentVersionService { + + private final DocumentVersionRepository versionRepository; + private final UserService userService; + private final ObjectMapper objectMapper; + + @Transactional + public void recordVersion(Document doc) { + AppUser editor = resolveCurrentUser(); + String editorName = buildEditorName(editor); + UUID editorId = editor != null ? editor.getId() : null; + + String snapshot = serializeSnapshot(doc); + List previous = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId()); + String changedFields = computeChangedFields(doc, previous); + + versionRepository.save(DocumentVersion.builder() + .documentId(doc.getId()) + .savedAt(LocalDateTime.now()) + .editorId(editorId) + .editorName(editorName) + .snapshot(snapshot) + .changedFields(changedFields) + .build()); + } + + public List getSummaries(UUID documentId) { + return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream() + .map(v -> new DocumentVersionSummary( + v.getId(), + v.getSavedAt(), + v.getEditorName(), + parseChangedFields(v.getChangedFields()))) + .toList(); + } + + public DocumentVersion getVersion(UUID documentId, UUID versionId) { + return versionRepository.findByIdAndDocumentId(versionId, documentId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, + "Version not found: " + versionId)); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private AppUser resolveCurrentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + try { + return userService.findByUsername(auth.getName()); + } catch (Exception e) { + log.warn("Could not resolve editor for version snapshot: {}", e.getMessage()); + return null; + } + } + + private String buildEditorName(AppUser user) { + if (user == null) return "Unknown"; + String first = user.getFirstName(); + String last = user.getLastName(); + if (first != null && !first.isBlank() && last != null && !last.isBlank()) { + return first + " " + last; + } + return user.getUsername(); + } + + private String serializeSnapshot(Document doc) { + try { + return objectMapper.writeValueAsString(doc); + } catch (Exception e) { + log.error("Failed to serialize document snapshot for {}", doc.getId(), e); + return "{}"; + } + } + + private String computeChangedFields(Document current, List previousVersions) { + if (previousVersions.isEmpty()) { + return "[]"; + } + DocumentVersion last = previousVersions.get(previousVersions.size() - 1); + try { + Map previousMap = objectMapper.readValue( + last.getSnapshot(), new TypeReference<>() {}); + List changed = new ArrayList<>(); + + checkScalar(changed, "title", current.getTitle(), previousMap); + checkScalar(changed, "documentDate", + current.getDocumentDate() != null ? current.getDocumentDate().toString() : null, + previousMap); + checkScalar(changed, "location", current.getLocation(), previousMap); + checkScalar(changed, "documentLocation", current.getDocumentLocation(), previousMap); + checkScalar(changed, "transcription", current.getTranscription(), previousMap); + checkScalar(changed, "summary", current.getSummary(), previousMap); + checkSender(changed, current, previousMap); + checkReceivers(changed, current, previousMap); + checkTags(changed, current, previousMap); + + return objectMapper.writeValueAsString(changed); + } catch (Exception e) { + log.warn("Could not compute changedFields for document {}", current.getId(), e); + return "[]"; + } + } + + private void checkScalar(List changed, String field, String currentValue, + Map previousMap) { + Object prev = previousMap.get(field); + String prevStr = prev != null ? prev.toString() : null; + if (!Objects.equals(currentValue, prevStr)) { + changed.add(field); + } + } + + @SuppressWarnings("unchecked") + private void checkSender(List changed, Document current, Map previousMap) { + String currentId = current.getSender() != null + ? current.getSender().getId().toString() : null; + Object prevSender = previousMap.get("sender"); + String prevId = null; + if (prevSender instanceof Map senderMap) { + Object id = senderMap.get("id"); + prevId = id != null ? id.toString() : null; + } + if (!Objects.equals(currentId, prevId)) { + changed.add("sender"); + } + } + + @SuppressWarnings("unchecked") + private void checkReceivers(List changed, Document current, Map previousMap) { + Set currentIds = current.getReceivers() != null + ? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet()) + : Set.of(); + Object prevReceivers = previousMap.get("receivers"); + Set prevIds = Set.of(); + if (prevReceivers instanceof List list) { + prevIds = list.stream() + .filter(r -> r instanceof Map) + .map(r -> ((Map) r).get("id")) + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toSet()); + } + if (!currentIds.equals(prevIds)) { + changed.add("receivers"); + } + } + + @SuppressWarnings("unchecked") + private void checkTags(List changed, Document current, Map previousMap) { + Set currentNames = current.getTags() != null + ? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet()) + : Set.of(); + Object prevTags = previousMap.get("tags"); + Set prevNames = Set.of(); + if (prevTags instanceof List list) { + prevNames = list.stream() + .filter(t -> t instanceof Map) + .map(t -> ((Map) t).get("name")) + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toSet()); + } + if (!currentNames.equals(prevNames)) { + changed.add("tags"); + } + } + + private List parseChangedFields(String json) { + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/backend/src/main/resources/db/migration/V9__add_document_versions.sql b/backend/src/main/resources/db/migration/V9__add_document_versions.sql new file mode 100644 index 00000000..5d1353b3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_document_versions.sql @@ -0,0 +1,11 @@ +CREATE TABLE document_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + saved_at TIMESTAMP NOT NULL DEFAULT now(), + editor_id UUID REFERENCES users(id) ON DELETE SET NULL, + editor_name VARCHAR(200) NOT NULL, + snapshot JSONB NOT NULL, + changed_fields JSONB NOT NULL DEFAULT '[]' +); + +CREATE INDEX ON document_versions (document_id, saved_at DESC); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java new file mode 100644 index 00000000..992cf6d4 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java @@ -0,0 +1,329 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.DocumentVersionRepository; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DocumentVersionServiceTest { + + @Mock DocumentVersionRepository versionRepository; + @Mock UserService userService; + + private DocumentVersionService versionService; + + @BeforeEach + void setUp() { + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + versionService = new DocumentVersionService(versionRepository, userService, mapper); + } + + @BeforeEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + // ─── recordVersion — editor name ───────────────────────────────────────── + + @Test + void recordVersion_usesFirstAndLastName_whenBothPresent() { + authenticateAs("emma"); + when(userService.findByUsername("emma")).thenReturn( + AppUser.builder().id(UUID.randomUUID()).username("emma") + .firstName("Emma").lastName("Müller").build()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("Emma Müller"); + } + + @Test + void recordVersion_usesUsername_whenNamesAreBlank() { + authenticateAs("otto99"); + when(userService.findByUsername("otto99")).thenReturn( + AppUser.builder().id(UUID.randomUUID()).username("otto99") + .firstName(null).lastName(null).build()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("otto99"); + } + + // ─── recordVersion — snapshot ───────────────────────────────────────────── + + @Test + void recordVersion_savesSnapshotContainingTitle() { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Wichtiger Brief") + .originalFilename("brief.pdf") + .build(); + + versionService.recordVersion(doc); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getSnapshot()).contains("Wichtiger Brief"); + assertThat(captor.getValue().getDocumentId()).isEqualTo(doc.getId()); + } + + // ─── recordVersion — changedFields ──────────────────────────────────────── + + @Test + void recordVersion_changedFieldsIsEmpty_forFirstVersion() { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).isEqualTo("[]"); + } + + @Test + void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()) + .documentId(oldDoc.getId()) + .snapshot(oldSnapshot) + .changedFields("[]") + .savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1") + .build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(oldDoc.getId())) + .thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(oldDoc.getId()).title("Neu").build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("title"); + } + + @Test + void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("Same").location("Hamburg").build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("location"); + assertThat(captor.getValue().getChangedFields()).doesNotContain("title"); + } + + @Test + void recordVersion_tracksSenderChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); + Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Person newSender = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build(); + Document updated = Document.builder().id(docId).title("T").sender(newSender).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("sender"); + } + + @Test + void recordVersion_tracksReceiverChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); + Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("T").receivers(Set.of()).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("receivers"); + } + + @Test + void recordVersion_tracksTagChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build(); + Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("T").tags(Set.of()).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("tags"); + } + + // ─── getSummaries ───────────────────────────────────────────────────────── + + @Test + void getSummaries_returnsListWithParsedChangedFields() { + UUID docId = UUID.randomUUID(); + DocumentVersion v = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId) + .savedAt(LocalDateTime.now()).editorName("Emma Müller") + .snapshot("{}").changedFields("[\"title\",\"location\"]") + .build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(v)); + + List summaries = versionService.getSummaries(docId); + + assertThat(summaries).hasSize(1); + assertThat(summaries.get(0).editorName()).isEqualTo("Emma Müller"); + assertThat(summaries.get(0).changedFields()).containsExactlyInAnyOrder("title", "location"); + assertThat(summaries.get(0).id()).isEqualTo(v.getId()); + } + + // ─── getVersion ─────────────────────────────────────────────────────────── + + @Test + void getVersion_returnsVersion_whenFound() { + UUID docId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + DocumentVersion v = DocumentVersion.builder() + .id(versionId).documentId(docId).snapshot("{}") + .changedFields("[]").editorName("x").savedAt(LocalDateTime.now()).build(); + when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.of(v)); + + assertThat(versionService.getVersion(docId, versionId)).isEqualTo(v); + } + + @Test + void getVersion_throwsNotFound_whenVersionBelongsToOtherDocument() { + UUID docId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> versionService.getVersion(docId, versionId)) + .isInstanceOf(DomainException.class); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private void authenticateAs(String username) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(username, null, List.of())); + } + + private AppUser stubUser(String username) { + return AppUser.builder().id(UUID.randomUUID()).username(username) + .firstName(null).lastName(null).build(); + } + + private Document minimalDocument() { + return Document.builder() + .id(UUID.randomUUID()) + .title("Test") + .originalFilename("test.pdf") + .documentDate(LocalDate.of(1940, 5, 1)) + .build(); + } +}