fix(tag): resolve case-colliding tag names without throwing (#730)

findOrCreate used tagRepository.findByNameIgnoreCase, which returns
Optional<Tag> and threw NonUniqueResultException whenever two tags
collided case-insensitively (a canonical parent and its same-named
lowercase child). Every document carrying such a tag became un-editable:
any save re-resolves the whole tag set by name and blew up with a 500.

Replace the throwing lookup with exact-case-first resolution: findByName
(exact) → findAllByNameIgnoreCase (lowest-id, deterministic, never
throws) → create. Delete findByNameIgnoreCase so the throwing call can't
be reintroduced. Case collisions are valid tree nodes — no migration, no
unique(lower(name)) constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 10:49:02 +02:00
parent d1ed9c022f
commit d000170f52
3 changed files with 70 additions and 15 deletions

View File

@@ -53,20 +53,54 @@ class TagServiceTest {
// ─── findOrCreate ─────────────────────────────────────────────────────────
@Test
void findOrCreate_returnsExisting_whenNameFound() {
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
void findOrCreate_exactCaseWins_overCaseInsensitiveSibling() {
// "Geburt" (parent) and "geburt" (child) both exist; the edit round-trip replays the stored
// name "geburt", which must bind to the exact-case row, not the parent.
Tag exact = Tag.builder().id(UUID.randomUUID()).name("geburt").build();
when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exact));
Tag result = tagService.findOrCreate("Familie");
Tag result = tagService.findOrCreate("geburt");
assertThat(result).isEqualTo(existing);
assertThat(result).isEqualTo(exact);
verify(tagRepository, never()).save(any());
}
@Test
void findOrCreate_createsNew_whenNameNotFound() {
void findOrCreate_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
// Stored name is "Weihnachten"; a save replays "weihnachten" (no exact-case row) → bind to the
// single case-insensitive match rather than creating a duplicate.
Tag stored = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").build();
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(stored));
Tag result = tagService.findOrCreate("weihnachten");
assertThat(result).isEqualTo(stored);
verify(tagRepository, never()).save(any());
}
@Test
void findOrCreate_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
// Two rows collide case-insensitively and neither equals the query exactly. Resolution must be
// deterministic (lowest id) and never throw — proven by calling twice and getting the same id.
Tag lowerId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).name("Reisepläne").build();
Tag higherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).name("reisepläne").build();
when(tagRepository.findByName("REISEPLÄNE")).thenReturn(Optional.empty());
when(tagRepository.findAllByNameIgnoreCase("REISEPLÄNE")).thenReturn(List.of(higherId, lowerId));
Tag first = tagService.findOrCreate("REISEPLÄNE");
Tag second = tagService.findOrCreate("REISEPLÄNE");
assertThat(first.getId()).isEqualTo(lowerId.getId());
assertThat(second.getId()).isEqualTo(first.getId());
verify(tagRepository, never()).save(any());
}
@Test
void findOrCreate_createsOrphanTag_whenNameAbsent() {
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
when(tagRepository.findByName("Krieg")).thenReturn(Optional.empty());
when(tagRepository.findAllByNameIgnoreCase("Krieg")).thenReturn(List.of());
when(tagRepository.save(any())).thenReturn(saved);
Tag result = tagService.findOrCreate("Krieg");
@@ -76,13 +110,15 @@ class TagServiceTest {
}
@Test
void findOrCreate_trimsWhitespaceBeforeLookup() {
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
void findOrCreate_trimsWhitespace_thenLandsOnCaseInsensitiveChild() {
Tag child = Tag.builder().id(UUID.randomUUID()).name("weihnachten").build();
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(child));
tagService.findOrCreate(" Urlaub ");
Tag result = tagService.findOrCreate(" weihnachten ");
verify(tagRepository).findByNameIgnoreCase("Urlaub");
assertThat(result).isEqualTo(child);
verify(tagRepository).findByName("weihnachten");
}
// ─── update ───────────────────────────────────────────────────────────────