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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> changedFields
|
||||||
|
) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<DocumentVersion, UUID> {
|
||||||
|
|
||||||
|
List<DocumentVersion> findByDocumentIdOrderBySavedAtAsc(UUID documentId);
|
||||||
|
|
||||||
|
Optional<DocumentVersion> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
}
|
||||||
@@ -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<DocumentVersion> 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<DocumentVersionSummary> 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<DocumentVersion> previousVersions) {
|
||||||
|
if (previousVersions.isEmpty()) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
DocumentVersion last = previousVersions.get(previousVersions.size() - 1);
|
||||||
|
try {
|
||||||
|
Map<String, Object> previousMap = objectMapper.readValue(
|
||||||
|
last.getSnapshot(), new TypeReference<>() {});
|
||||||
|
List<String> 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<String> changed, String field, String currentValue,
|
||||||
|
Map<String, Object> 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<String> changed, Document current, Map<String, Object> 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<String> changed, Document current, Map<String, Object> previousMap) {
|
||||||
|
Set<String> currentIds = current.getReceivers() != null
|
||||||
|
? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet())
|
||||||
|
: Set.of();
|
||||||
|
Object prevReceivers = previousMap.get("receivers");
|
||||||
|
Set<String> 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<String> changed, Document current, Map<String, Object> previousMap) {
|
||||||
|
Set<String> currentNames = current.getTags() != null
|
||||||
|
? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet())
|
||||||
|
: Set.of();
|
||||||
|
Object prevTags = previousMap.get("tags");
|
||||||
|
Set<String> 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<String> parseChangedFields(String json) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersionSummary> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user