Compare commits
10 Commits
a2c633c5de
...
8b498665df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b498665df | ||
|
|
5ebe1f1a5a | ||
|
|
221a6af838 | ||
|
|
404d874b4e | ||
|
|
4bc4267e5a | ||
|
|
bd17532118 | ||
|
|
e021261300 | ||
|
|
e94ffde075 | ||
|
|
29a1df5d9c | ||
|
|
4d288589fa |
@@ -34,11 +34,13 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public Person getPerson(@PathVariable UUID id) {
|
public Person getPerson(@PathVariable UUID id) {
|
||||||
return personService.getById(id);
|
return personService.getById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
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 ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PersonMentionPropagationListener {
|
||||||
|
|
||||||
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
private final PersonService personService;
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
@Transactional
|
||||||
|
public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
|
||||||
|
if (!personService.existsById(event.personId())) {
|
||||||
|
log.warn("Skipping mention propagation for non-existent personId {}", event.personId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TranscriptionBlock> blocks =
|
||||||
|
blockRepository.findByMentionedPersons_PersonId(event.personId());
|
||||||
|
if (blocks.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldNeedle = "@" + event.oldDisplayName();
|
||||||
|
String newNeedle = "@" + event.newDisplayName();
|
||||||
|
|
||||||
|
for (TranscriptionBlock block : blocks) {
|
||||||
|
if (block.getText() != null) {
|
||||||
|
block.setText(block.getText().replace(oldNeedle, newNeedle));
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import org.raddatz.familienarchiv.model.PersonType;
|
|||||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -50,6 +51,10 @@ public class PersonService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean existsById(UUID id) {
|
||||||
|
return personRepository.existsById(id);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Person> findCorrespondents(UUID personId, String q) {
|
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||||
if (q != null && !q.isBlank()) {
|
if (q != null && !q.isBlank()) {
|
||||||
return personRepository.findCorrespondentsWithFilter(personId, q);
|
return personRepository.findCorrespondentsWithFilter(personId, q);
|
||||||
@@ -172,7 +177,12 @@ public class PersonService {
|
|||||||
Person saved = personRepository.save(person);
|
Person saved = personRepository.save(person);
|
||||||
String newDisplayName = saved.getDisplayName();
|
String newDisplayName = saved.getDisplayName();
|
||||||
if (!Objects.equals(oldDisplayName, newDisplayName)) {
|
if (!Objects.equals(oldDisplayName, newDisplayName)) {
|
||||||
eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, 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 saved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_returns200_withEmptyList() throws Exception {
|
void getPersons_returns200_withEmptyList() throws Exception {
|
||||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||||
mockMvc.perform(get("/api/persons"))
|
mockMvc.perform(get("/api/persons"))
|
||||||
@@ -64,7 +71,7 @@ class PersonControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
@@ -100,6 +107,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPerson_returns200_whenFound() throws Exception {
|
void getPerson_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@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 PersonService personService;
|
||||||
|
|
||||||
|
private UUID documentId;
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
personService = mock(PersonService.class);
|
||||||
|
when(personService.existsById(any())).thenReturn(true);
|
||||||
|
listener = new PersonMentionPropagationListener(blockRepository, personService);
|
||||||
|
|
||||||
|
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<PersonMention> 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 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 noOps_whenPersonIdNoLongerExists_orphanedSidecarGuard() {
|
||||||
|
UUID orphanId = UUID.randomUUID();
|
||||||
|
when(personService.existsById(orphanId)).thenReturn(false);
|
||||||
|
|
||||||
|
TranscriptionBlock saved = saveBlock(
|
||||||
|
"Stale reference to @Ghost Name should not be rewritten.",
|
||||||
|
List.of(new PersonMention(orphanId, "Ghost Name")));
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
listener.onPersonDisplayNameChanged(
|
||||||
|
new PersonDisplayNameChangedEvent(orphanId, "Ghost Name", "Resurrected Name"));
|
||||||
|
blockRepository.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getText()).isEqualTo("Stale reference to @Ghost Name should not be rewritten.");
|
||||||
|
assertThat(reloaded.getMentionedPersons())
|
||||||
|
.extracting(PersonMention::getDisplayName)
|
||||||
|
.containsExactly("Ghost Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void propagatesAcross200Blocks_inUnderTwoSeconds_latencyFloor() {
|
||||||
|
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||||
|
List<UUID> 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 2s — merge-blocking regression floor")
|
||||||
|
.isLessThan(2000L);
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow();
|
||||||
|
assertThat(first.getText()).contains("@Augusta Raddatz");
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
|||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -18,6 +19,7 @@ import org.raddatz.familienarchiv.model.PersonType;
|
|||||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -297,6 +299,28 @@ class PersonServiceTest {
|
|||||||
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
|
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsConflict_whenListenerSignalsOptimisticLockFailure() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder()
|
||||||
|
.id(id).firstName("Auguste").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).build();
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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
|
@Test
|
||||||
void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
|
void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|||||||
@@ -542,6 +542,7 @@
|
|||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
|
"error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
|
|||||||
@@ -542,6 +542,7 @@
|
|||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
"error_invalid_person_type": "The specified person type is not valid.",
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
|
"error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
|
|||||||
@@ -542,6 +542,7 @@
|
|||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
|
"error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ErrorCode =
|
|||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
| 'ALIAS_NOT_FOUND'
|
| 'ALIAS_NOT_FOUND'
|
||||||
| 'INVALID_PERSON_TYPE'
|
| 'INVALID_PERSON_TYPE'
|
||||||
|
| 'PERSON_RENAME_CONFLICT'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -79,6 +80,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_alias_not_found();
|
return m.error_alias_not_found();
|
||||||
case 'INVALID_PERSON_TYPE':
|
case 'INVALID_PERSON_TYPE':
|
||||||
return m.error_invalid_person_type();
|
return m.error_invalid_person_type();
|
||||||
|
case 'PERSON_RENAME_CONFLICT':
|
||||||
|
return m.error_person_rename_conflict();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
case 'DOCUMENT_NO_FILE':
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/review-all": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["markAllBlocksReviewed"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{documentId}/transcription-blocks/reorder": {
|
"/api/documents/{documentId}/transcription-blocks/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1611,9 +1627,15 @@ export interface components {
|
|||||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
|
PersonMention: {
|
||||||
|
/** Format: uuid */
|
||||||
|
personId: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
UpdateTranscriptionBlockDTO: {
|
UpdateTranscriptionBlockDTO: {
|
||||||
text?: string;
|
text?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||||
};
|
};
|
||||||
TranscriptionBlock: {
|
TranscriptionBlock: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1623,6 +1645,7 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
mentionedPersons: components["schemas"]["PersonMention"][];
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@@ -1665,7 +1688,8 @@ export interface components {
|
|||||||
CreateRelationshipRequest: {
|
CreateRelationshipRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
relatedPersonId: string;
|
relatedPersonId: string;
|
||||||
relationType: string;
|
/** @enum {string} */
|
||||||
|
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
fromYear?: number;
|
fromYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -1796,6 +1820,7 @@ export interface components {
|
|||||||
height?: number;
|
height?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||||
};
|
};
|
||||||
CreateCommentDTO: {
|
CreateCommentDTO: {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -2747,6 +2772,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
markAllBlocksReviewed: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
reorderBlocks: {
|
reorderBlocks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user