feat(tag): add upsertBySourceRef keyed on canonical tag_path

Idempotent tag upsert for the Phase-3 importer (ADR-025). source_ref is
the stable identity (the canonical tag_path); on re-import a
human-renamed tag name is preserved while the parent link is refreshed.

Refs #669

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 10:24:30 +02:00
parent 05dd824283
commit 3501382ff5
3 changed files with 85 additions and 0 deletions

View File

@@ -22,6 +22,9 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
Optional<Tag> findByNameIgnoreCase(String name);
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
Optional<Tag> findBySourceRef(String sourceRef);
List<Tag> findByNameContainingIgnoreCase(String name);
/**

View File

@@ -55,6 +55,26 @@ public class TagService {
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
}
/**
* Idempotent upsert keyed on {@code sourceRef} (the canonical tag_path) for the
* Phase-3 importer (ADR-025). On first import the canonical name and parent are
* written; on re-import a human-renamed tag name is preserved (the source_ref is the
* stable identity, the name is a human-editable label).
*/
@Transactional
public Tag upsertBySourceRef(String sourceRef, String name, UUID parentId) {
return tagRepository.findBySourceRef(sourceRef)
.map(existing -> {
existing.setParentId(parentId);
return tagRepository.save(existing);
})
.orElseGet(() -> tagRepository.save(Tag.builder()
.sourceRef(sourceRef)
.name(name)
.parentId(parentId)
.build()));
}
@Transactional
public Tag update(UUID id, TagUpdateDTO dto) {
Tag tag = getById(id);

View File

@@ -0,0 +1,62 @@
package org.raddatz.familienarchiv.tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
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 TagImportUpsertTest {
@Mock TagRepository tagRepository;
@InjectMocks TagService tagService;
@Test
void upsertBySourceRef_insertsNewTag_whenSourceRefUnknown() {
when(tagRepository.findBySourceRef("Themen/Brautbriefe")).thenReturn(Optional.empty());
when(tagRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UUID parentId = UUID.randomUUID();
Tag result = tagService.upsertBySourceRef("Themen/Brautbriefe", "Brautbriefe", parentId);
assertThat(result.getSourceRef()).isEqualTo("Themen/Brautbriefe");
assertThat(result.getName()).isEqualTo("Brautbriefe");
assertThat(result.getParentId()).isEqualTo(parentId);
}
@Test
void upsertBySourceRef_updatesInPlace_whenSourceRefExists() {
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brautbriefe")
.sourceRef("Themen/Brautbriefe").build();
when(tagRepository.findBySourceRef("Themen/Brautbriefe")).thenReturn(Optional.of(existing));
when(tagRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
tagService.upsertBySourceRef("Themen/Brautbriefe", "Brautbriefe", null);
verify(tagRepository).save(argThat(t -> t.getId().equals(existing.getId())));
verify(tagRepository, never()).save(argThat(t -> t.getId() == null));
}
@Test
void upsertBySourceRef_preservesHumanRenamedTag_onReimport() {
Tag humanRenamed = Tag.builder().id(UUID.randomUUID()).name("Verlobungsbriefe")
.sourceRef("Themen/Brautbriefe").build();
when(tagRepository.findBySourceRef("Themen/Brautbriefe")).thenReturn(Optional.of(humanRenamed));
when(tagRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.upsertBySourceRef("Themen/Brautbriefe", "Brautbriefe", null);
assertThat(result.getName()).isEqualTo("Verlobungsbriefe");
}
}