From a58378e8f0c184384bb12d3403414c5603da2c1c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 10:53:04 +0200 Subject: [PATCH] test(tag): pin case-colliding tag resolution on real Postgres (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocked TagServiceTest can't prove the two things that actually broke: that findAllByNameIgnoreCase folds umlauts the way Postgres LOWER() does, and that saving a document tagged with a case-colliding tag no longer throws NonUniqueResultException. Testcontainers postgres:16-alpine: - updateDocument on a doc tagged with the child "weihnachten" succeeds and keeps exactly the child tag (not the parent). - findOrCreate("GLÜCKWÜNSCHE") resolves the Glückwünsche/glückwünsche umlaut pair deterministically (lowest id) without throwing — the regression catcher a plain-ASCII pair would miss. - bulk-edit funnels through resolveTags → findOrCreate, guarding a future refactor that bypasses it. Co-Authored-By: Claude Opus 4.8 --- .../TagCaseCollisionIntegrationTest.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/TagCaseCollisionIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/TagCaseCollisionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/TagCaseCollisionIntegrationTest.java new file mode 100644 index 00000000..994d4062 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/TagCaseCollisionIntegrationTest.java @@ -0,0 +1,121 @@ +package org.raddatz.familienarchiv.document; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagRepository; +import org.raddatz.familienarchiv.tag.TagService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * #730 — tag-name resolution against a real Postgres. A mocked repo can't prove the two things that + * actually break: that {@code findAllByNameIgnoreCase} folds case the way Postgres {@code LOWER()} + * does (critical for umlauts like {@code ü}), and that saving a document tagged with a case-colliding + * tag no longer throws {@code NonUniqueResultException}. H2 folds case differently, so this pins the + * behaviour on {@code postgres:16-alpine}. The four-branch resolution logic itself is covered faster + * by the mocked {@code TagServiceTest}. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class TagCaseCollisionIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired DocumentService documentService; + @Autowired DocumentRepository documentRepository; + @Autowired TagRepository tagRepository; + @Autowired TagService tagService; + + private Tag persistTag(String name, String sourceRef, UUID parentId) { + return tagRepository.save(Tag.builder().name(name).sourceRef(sourceRef).parentId(parentId).build()); + } + + private Document persistDocTaggedWith(Tag tag) { + return documentRepository.save(Document.builder() + .originalFilename("C-7301") + .title("Weihnachtsbrief") + .documentDate(LocalDate.of(1928, 1, 1)) + .metaDatePrecision(DatePrecision.YEAR) + .status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tag))) + .build()); + } + + @Test + void updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChild() throws Exception { + Tag parent = persistTag("Weihnachten", "Weihnachten", null); + Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId()); + Document doc = persistDocTaggedWith(child); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Weihnachtsbrief"); + dto.setDocumentDate(LocalDate.of(1930, 1, 1)); // change the date — the field that 500'd on staging + dto.setMetaDatePrecision(DatePrecision.YEAR); + dto.setTags("weihnachten"); // the edit form round-trips the stored child name + + assertThatCode(() -> documentService.updateDocument(doc.getId(), dto, null, null)) + .doesNotThrowAnyException(); + + Set tags = documentRepository.findById(doc.getId()).orElseThrow().getTags(); + assertThat(tags).hasSize(1); + assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); // child kept, not the parent + } + + @Test + void findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow() { + // The regression catcher: a plain-ASCII pair would stay green even if Postgres folded ü wrongly. + Tag parent = persistTag("Glückwünsche", "Glückwünsche", null); + Tag child = persistTag("glückwünsche", "Glückwünsche/glückwünsche", parent.getId()); + + // Proof that real Postgres LOWER() folds the umlaut so both rows match case-insensitively. + assertThat(tagRepository.findAllByNameIgnoreCase("glückwünsche")).hasSize(2); + + // No exact-case "GLÜCKWÜNSCHE" row exists → resolution falls through to the case-insensitive + // branch with two candidates and must pick the lowest id deterministically, never throwing. + UUID expected = List.of(parent, child).stream().min(Comparator.comparing(Tag::getId)).orElseThrow().getId(); + Tag first = tagService.findOrCreate("GLÜCKWÜNSCHE"); + Tag second = tagService.findOrCreate("GLÜCKWÜNSCHE"); + + assertThat(first.getId()).isEqualTo(expected); + assertThat(second.getId()).isEqualTo(first.getId()); + } + + @Test + void bulkEdit_resolvesCaseCollidingTagThroughFindOrCreate_withoutThrow() { + // Bulk-edit shares resolveTags → findOrCreate; this guards a future refactor that bypasses it. + Tag parent = persistTag("Weihnachten", "Weihnachten", null); + Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId()); + Document doc = documentRepository.save(Document.builder() + .originalFilename("C-7302") + .title("Brief") + .status(DocumentStatus.UPLOADED) + .build()); + + DocumentBulkEditDTO dto = new DocumentBulkEditDTO(); + dto.setTagNames(List.of("weihnachten")); + + assertThatCode(() -> documentService.applyBulkEditToDocument(doc.getId(), dto, null)) + .doesNotThrowAnyException(); + + Set tags = documentRepository.findById(doc.getId()).orElseThrow().getTags(); + assertThat(tags).hasSize(1); + assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); + } +}