From 48492330a706cbd0610cf4c7baf43fda95a64d32 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 28 Apr 2026 21:36:54 +0200 Subject: [PATCH] test(person): optimistic-lock test exercises real listener saveAllAndFlush path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sara #3 / Felix #5 (PR #366 review). The previous version stubbed eventPublisher.publishEvent to throw, which proved the catch-and-translate syntax but skipped the listener entirely. The test could not have detected a regression where the listener swallowed the exception or re-wrapped it with a non-OptimisticLocking type. Replace with a real PersonMentionPropagationListener instance backed by a mocked TranscriptionBlockRepository whose saveAllAndFlush throws ObjectOptimisticLockingFailureException (the actual Spring exception Hibernate raises). The publisher mock routes the event to the real listener via doAnswer so the call chain is the production one: PersonService.updatePerson → publishEvent → listener.onPersonDisplayNameChanged → blockRepository.saveAllAndFlush throws → exception bubbles through the synchronous event dispatcher → PersonService catches → DomainException. Refs #362 #366 Co-Authored-By: Claude Opus 4.7 --- .../service/PersonServiceTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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 a90ac002..4e5bf99d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -13,15 +13,19 @@ 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.dao.OptimisticLockingFailureException; +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; @@ -300,16 +304,38 @@ class PersonServiceTest { } @Test - void updatePerson_throwsConflict_whenListenerSignalsOptimisticLockFailure() { + 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.findByMentionedPersons_PersonId(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)); - doThrow(new OptimisticLockingFailureException("simulated concurrent block save")) - .when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class)); + doAnswer(inv -> { + realListener.onPersonDisplayNameChanged(inv.getArgument(0)); + return null; + }).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class)); PersonUpdateDTO dto = new PersonUpdateDTO(); dto.setPersonType(PersonType.PERSON);