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,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