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