diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
index d9a3a30f..517eb1da 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
@@ -15,11 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
- /** A concurrent edit on a referenced transcription block prevented the rename
- * from committing (optimistic-lock conflict). The whole rename rolls back; the
- * client should refetch and retry. 409 */
- PERSON_RENAME_CONFLICT,
-
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java
deleted file mode 100644
index 1b748c1a..00000000
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.raddatz.familienarchiv.model;
-
-import java.util.UUID;
-
-/**
- * Published by PersonService when a save changes Person.getDisplayName() — i.e.
- * any mutation to the fields that DisplayNameFormatter consumes (title,
- * firstName, lastName). Listeners on the transcription side rewrite block text
- * and sidecar entries that reference the old name.
- *
- *
This is the first custom application event in the codebase. The previous
- * only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
- * built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
- * the same shape: record-typed event in model/, listener in the consuming
- * domain's service/ package, synchronous @EventListener inside the publisher's
- * transaction unless the workload genuinely needs to defer.
- */
-public record PersonDisplayNameChangedEvent(
- UUID personId,
- String oldDisplayName,
- String newDisplayName
-) {
-}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
index 79b232d6..2ca2033e 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
@@ -26,5 +26,6 @@ public class PersonMention {
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ // Archival: the text the transcriber typed after @. Never updated on person rename.
private String displayName;
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java
deleted file mode 100644
index a3c03206..00000000
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.raddatz.familienarchiv.service;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When
- * Person.getDisplayName() flips during a rename, this listener rewrites every
- * transcription block whose sidecar references the renamed person — both the
- * literal "@OldName" inside block.text and the displayName carried in the
- * {@link PersonMention} entries.
- *
- *
Synchronous on purpose: the rename and the propagation must commit as one
- * transaction so a half-applied rewrite never reaches the archive. If the
- * archive grows past tens of thousands of blocks, switch to
- * {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation
- * change.
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class PersonMentionPropagationListener {
-
- private final TranscriptionBlockRepository blockRepository;
-
- @EventListener
- @Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT)
- public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
- List blocks =
- blockRepository.findByPersonIdWithMentionsFetched(event.personId());
- if (blocks.isEmpty()) {
- return;
- }
-
- String oldNeedle = "@" + event.oldDisplayName();
- String newNeedle = "@" + event.newDisplayName();
- Pattern boundary = Pattern.compile(
- Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
- String replacement = Matcher.quoteReplacement(newNeedle);
-
- for (TranscriptionBlock block : blocks) {
- rewriteBlockText(block, boundary, replacement);
- for (PersonMention mention : block.getMentionedPersons()) {
- if (mention.getPersonId().equals(event.personId())) {
- mention.setDisplayName(event.newDisplayName());
- }
- }
- }
-
- blockRepository.saveAllAndFlush(blocks);
-
- log.info("Propagated rename {} → {} across {} block(s) for person {}",
- event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId());
- }
-
- // Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
- // (catches @Hans-Peter when renaming Hans) AND not followed by " "
- // (catches @Hans Müller when renaming the single-name @Hans). False negatives —
- // e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
- // conservative trade-off; the alternative (corruption) is irrecoverable.
- private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) {
- if (block.getText() != null) {
- block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
- }
- }
-}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
index 33873459..e1adbdba 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -13,14 +12,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +30,6 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
- private final ApplicationEventPublisher eventPublisher;
public List findAll(String q) {
if (q == null) {
@@ -161,7 +156,6 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
- String oldDisplayName = person.getDisplayName();
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
@@ -170,17 +164,7 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
- Person saved = personRepository.save(person);
- String newDisplayName = saved.getDisplayName();
- if (!Objects.equals(oldDisplayName, newDisplayName)) {
- try {
- eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
- } catch (OptimisticLockingFailureException e) {
- throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
- "A referenced transcription block was modified concurrently — rename rolled back");
- }
- }
- return saved;
+ return personRepository.save(person);
}
@Transactional
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
index 41a4a0ac..9de8a3a1 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
@@ -333,21 +333,6 @@ class PersonControllerTest {
.andExpect(jsonPath("$.lastName").value("Müller"));
}
- @Test
- @WithMockUser(authorities = "WRITE_ALL")
- void updatePerson_returns409_whenRenameConflict() throws Exception {
- UUID id = UUID.randomUUID();
- when(personService.updatePerson(eq(id), any()))
- .thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
- "Concurrent block edit during rename"));
-
- mockMvc.perform(put("/api/persons/{id}", id)
- .contentType(MediaType.APPLICATION_JSON)
- .content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}"))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT"));
- }
-
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
@Test
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java
deleted file mode 100644
index c6b89716..00000000
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java
+++ /dev/null
@@ -1,227 +0,0 @@
-package org.raddatz.familienarchiv.service;
-
-import jakarta.persistence.EntityManager;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.raddatz.familienarchiv.PostgresContainerConfig;
-import org.raddatz.familienarchiv.config.FlywayConfig;
-import org.raddatz.familienarchiv.model.Document;
-import org.raddatz.familienarchiv.model.DocumentAnnotation;
-import org.raddatz.familienarchiv.model.DocumentStatus;
-import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
-import org.raddatz.familienarchiv.repository.AnnotationRepository;
-import org.raddatz.familienarchiv.repository.DocumentRepository;
-import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
-import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
-import org.springframework.context.annotation.Import;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.UUID;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-@DataJpaTest
-@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
-@Import({PostgresContainerConfig.class, FlywayConfig.class})
-class PersonMentionPropagationListenerTest {
-
- @Autowired TranscriptionBlockRepository blockRepository;
- @Autowired DocumentRepository documentRepository;
- @Autowired AnnotationRepository annotationRepository;
- @Autowired PersonRepository personRepository;
- @Autowired EntityManager em;
-
- private PersonMentionPropagationListener listener;
-
- private UUID documentId;
- private UUID annotationId;
-
- @BeforeEach
- void setUp() {
- listener = new PersonMentionPropagationListener(blockRepository);
-
- Document doc = documentRepository.save(Document.builder()
- .title("Letter").originalFilename("letter.pdf")
- .status(DocumentStatus.UPLOADED).build());
- documentId = doc.getId();
- DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
- .documentId(documentId).pageNumber(1)
- .x(0.1).y(0.2).width(0.3).height(0.4)
- .color("#00C7B1").build());
- annotationId = annotation.getId();
- }
-
- private TranscriptionBlock saveBlock(String text, List mentions) {
- return blockRepository.saveAndFlush(TranscriptionBlock.builder()
- .annotationId(annotationId).documentId(documentId)
- .text(text).sortOrder(0)
- .mentionedPersons(mentions).build());
- }
-
- private UUID savedPersonId(String firstName, String lastName) {
- Person p = personRepository.save(Person.builder()
- .firstName(firstName).lastName(lastName).build());
- return p.getId();
- }
-
- @Test
- void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Liebe Tante @Auguste Raddatz, danke für den Brief.",
- List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getDisplayName)
- .containsExactly("Augusta Raddatz");
- }
-
- @Test
- void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() {
- UUID hansPeterId = savedPersonId("Hans-Peter", "Müller");
- UUID hansId = savedPersonId("Hans", "Müller");
- TranscriptionBlock saved = saveBlock(
- "Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.",
- List.of(
- new PersonMention(hansPeterId, "Hans-Peter Müller"),
- new PersonMention(hansId, "Hans Müller")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
- .containsExactlyInAnyOrder(
- org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"),
- org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt"));
- }
-
- @Test
- void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() {
- UUID hansMüllerId = savedPersonId("Hans", "Müller");
- UUID hansId = savedPersonId(null, "Hans");
- TranscriptionBlock saved = saveBlock(
- "@Hans Müller schrieb. Auch @Hans hat geschrieben.",
- List.of(
- new PersonMention(hansMüllerId, "Hans Müller"),
- new PersonMention(hansId, "Hans")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
- .containsExactlyInAnyOrder(
- org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"),
- org.assertj.core.groups.Tuple.tuple(hansId, "Henry"));
- }
-
- @Test
- void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.",
- List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getDisplayName)
- .containsExactly("Augusta Raddatz");
- }
-
- @Test
- void propagatesAcross200Blocks_inUnderFiveSeconds_latencyFloor() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- List blockIds = new ArrayList<>();
- for (int i = 0; i < 200; i++) {
- TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder()
- .annotationId(annotationId).documentId(documentId)
- .text("Block " + i + " mentions @Auguste Raddatz here.")
- .sortOrder(i)
- .mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz")))
- .build());
- blockIds.add(saved.getId());
- }
- blockRepository.flush();
- em.clear();
-
- long start = System.nanoTime();
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- long elapsedMs = (System.nanoTime() - start) / 1_000_000;
-
- assertThat(elapsedMs)
- .as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor")
- .isLessThan(5000L);
-
- em.clear();
- TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow();
- assertThat(first.getText()).contains("@Augusta Raddatz");
- }
-
- @Test
- void doesNotThrow_whenBlockTextIsNull() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- assertThatCode(() -> listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")))
- .doesNotThrowAnyException();
- }
-
- @Test
- void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Plain text without any mentions.",
- List.of());
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions.");
- assertThat(reloaded.getMentionedPersons()).isEmpty();
- }
-}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
index b502ac7c..24827177 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -10,22 +9,14 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
-import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.server.ResponseStatusException;
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -33,14 +24,16 @@ 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.*;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersonServiceTest {
@Mock PersonRepository personRepository;
@Mock PersonNameAliasRepository aliasRepository;
- @Mock ApplicationEventPublisher eventPublisher;
@InjectMocks PersonService personService;
// ─── getById ─────────────────────────────────────────────────────────────
@@ -252,121 +245,6 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Anna Alt");
}
- // ─── updatePerson (display-name change event) ────────────────────────────
-
- @Test
- void updatePerson_publishesEvent_whenTitleChanges() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).title("Herr").firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).build();
- String oldName = existing.getDisplayName();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
-
- personService.updatePerson(id, dto);
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class);
- verify(eventPublisher).publishEvent(captor.capture());
-
- PersonDisplayNameChangedEvent event = captor.getValue();
- assertThat(event.personId()).isEqualTo(id);
- assertThat(event.oldDisplayName()).isEqualTo(oldName);
- assertThat(event.newDisplayName())
- .isNotEqualTo(oldName)
- .contains("Frau");
- }
-
- @Test
- void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).alias("old alias").build();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
- dto.setAlias("new alias");
-
- personService.updatePerson(id, dto);
-
- verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
- }
-
- @Test
- void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() {
- // Wire a real PersonMentionPropagationListener with a mocked block repository
- // that throws on saveAllAndFlush. The publisher mock routes events to the
- // listener so the catch path traverses the same call chain as production:
- // PersonService → publishEvent → listener → saveAllAndFlush throws → catch.
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).build();
-
- TranscriptionBlock referencingBlock = TranscriptionBlock.builder()
- .id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID())
- .text("Brief von @Auguste Raddatz").sortOrder(0)
- .mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz"))))
- .build();
-
- TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class);
- when(blockRepo.findByPersonIdWithMentionsFetched(id))
- .thenReturn(List.of(referencingBlock));
- when(blockRepo.saveAllAndFlush(any()))
- .thenThrow(new ObjectOptimisticLockingFailureException(
- TranscriptionBlock.class, referencingBlock.getId()));
-
- PersonMentionPropagationListener realListener =
- new PersonMentionPropagationListener(blockRepo);
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
- doAnswer(inv -> {
- realListener.onPersonDisplayNameChanged(inv.getArgument(0));
- return null;
- }).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Augusta"); dto.setLastName("Raddatz");
-
- assertThatThrownBy(() -> personService.updatePerson(id, dto))
- .isInstanceOf(DomainException.class)
- .matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT)
- .matches(e -> ((DomainException) e).getStatus().value() == 409);
- }
-
- @Test
- void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).notes("first note").build();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
- dto.setNotes("revised note");
-
- personService.updatePerson(id, dto);
-
- verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
- }
-
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test