From 08a7f8920c873057cab1e3caf57ff12734343920 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 12:15:15 +0200 Subject: [PATCH] test(tag): validate subtree rollup CTE against real Postgres (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover AC#1-4 (leaf=direct, distinct overlap counted once, full descendant depth), REQ-THEMEN-05 (empty subtree absent), REQ-THEMEN-06 (cycle terminates via the 50-level guard) and AC#7 (rollup equals distinct documents found by the real tag-search expansion — count↔destination parity). Testcontainers postgres:16-alpine since the recursive CTE + COUNT(DISTINCT) needs real PG. Co-Authored-By: Claude Opus 4.8 --- .../TagRollupRepositoryIntegrationTest.java | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/tag/TagRollupRepositoryIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagRollupRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagRollupRepositoryIntegrationTest.java new file mode 100644 index 00000000..5fd150aa --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagRollupRepositoryIntegrationTest.java @@ -0,0 +1,179 @@ +package org.raddatz.familienarchiv.tag; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentSpecifications; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Real-Postgres validation of the subtree document-count rollup ({@link TagRepository + * #findSubtreeDocumentCountsPerTag}). The recursive CTE + COUNT(DISTINCT) cannot be exercised on + * H2, so these run against {@code postgres:16-alpine} via Testcontainers. Covers issue #698 + * AC#1–#4, #6 (REQ-THEMEN-06 cycle guard) and #7 (count↔destination parity). + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TagRollupRepositoryIntegrationTest { + + @Autowired private TagRepository tagRepository; + @Autowired private DocumentRepository documentRepository; + @Autowired private EntityManager entityManager; + + // ─── helpers ────────────────────────────────────────────────────────────── + + private Tag tag(String name, UUID parentId) { + return tagRepository.save(Tag.builder().name(name).parentId(parentId).build()); + } + + private Document docWithTags(String title, Tag... tags) { + return documentRepository.save(Document.builder() + .title(title) + .originalFilename(title + ".pdf") + .status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tags))) + .build()); + } + + private Map rollup() { + entityManager.flush(); + entityManager.clear(); + return tagRepository.findSubtreeDocumentCountsPerTag().stream() + .collect(Collectors.toMap(TagRepository.TagCount::getTagId, TagRepository.TagCount::getCount)); + } + + // ─── AC#4 — rollup of a leaf equals its direct count ──────────────────────── + + @Test + void leafTag_subtreeCount_equalsItsDirectCount() { + Tag leaf = tag("Tagebuch", null); + docWithTags("a", leaf); + docWithTags("b", leaf); + docWithTags("c", leaf); + + assertThat(rollup().get(leaf.getId())).isEqualTo(3L); + } + + // ─── AC#1 + AC#2 — parent rolls up children, distinct (shared doc counted once) ── + + @Test + void parentTag_rollsUpChildDocuments_countingSharedDocumentOnce() { + Tag reisen = tag("Reisen", null); + Tag italien = tag("Italien", reisen.getId()); + + Document shared = docWithTags("shared", reisen, italien); // tagged with both + docWithTags("reisenOnly", reisen); + docWithTags("it1", italien); + docWithTags("it2", italien); + docWithTags("it3", italien); + docWithTags("it4", italien); + + Map rollup = rollup(); + + // Reisen direct {shared, reisenOnly} = 2; Italien {shared, it1..it4} = 5; union distinct = 6 + assertThat(rollup.get(reisen.getId())).isEqualTo(6L); + assertThat(rollup.get(italien.getId())).isEqualTo(5L); + assertThat(shared.getId()).isNotNull(); + } + + // ─── AC#3 — full descendant depth (grandchildren included) ────────────────── + + @Test + void rollup_includesGrandchildDocuments_atFullDepth() { + Tag reisen = tag("Reisen", null); + Tag italien = tag("Italien", reisen.getId()); + Tag rom = tag("Rom", italien.getId()); + + docWithTags("r1", reisen); + docWithTags("i1", italien); + docWithTags("rom1", rom); + docWithTags("rom2", rom); + docWithTags("rom3", rom); + + Map rollup = rollup(); + + assertThat(rollup.get(reisen.getId())).isEqualTo(5L); // 1 + 1 + 3, all distinct + assertThat(rollup.get(italien.getId())).isEqualTo(4L); // 1 + 3 + assertThat(rollup.get(rom.getId())).isEqualTo(3L); + } + + // ─── REQ-THEMEN-05 — a tag whose whole subtree is empty is absent (→ 0) ───── + + @Test + void tagWithEmptySubtree_isAbsentFromRollup() { + Tag empty = tag("Leer", null); + Tag emptyChild = tag("LeerKind", empty.getId()); + + Map rollup = rollup(); + + assertThat(rollup).doesNotContainKey(empty.getId()); + assertThat(rollup).doesNotContainKey(emptyChild.getId()); + } + + // ─── REQ-THEMEN-06 — a hierarchy cycle terminates safely via the depth guard ── + + @Test + void rollup_terminatesSafely_whenHierarchyContainsCycle() { + Tag a = tag("CycleA", null); + Tag b = tag("CycleB", a.getId()); + // Close the loop: A.parent = B (DB only forbids parent_id == id, so a 2-node cycle is insertable) + a.setParentId(b.getId()); + tagRepository.save(a); + + docWithTags("ca", a); + docWithTags("cb", b); + + assertThatCode(this::rollup).doesNotThrowAnyException(); // depth guard prevents a runaway recursion + Map rollup = rollup(); + + // COUNT(DISTINCT document_id) dedupes documents reached via repeated cycle paths + assertThat(rollup.get(a.getId())).isEqualTo(2L); + assertThat(rollup.get(b.getId())).isEqualTo(2L); + } + + // ─── AC#7 — count↔destination parity with the real search expansion ───────── + + @Test + void subtreeCount_equalsDistinctDocumentsFoundByTagSearch_parity() { + // Uniquely-named root so name-based search expansion lines up with the per-id rollup. + Tag root = tag("ZzzParitaetReise", null); + Tag child = tag("ZzzParitaetItalien", root.getId()); + Tag grandchild = tag("ZzzParitaetRom", child.getId()); + + docWithTags("p_shared", root, child); // overlap inside the subtree + docWithTags("p_root", root); + docWithTags("p_child", child); + docWithTags("p_gc1", grandchild); + docWithTags("p_gc2", grandchild); + + entityManager.flush(); + entityManager.clear(); + + long rollupCount = rollup().get(root.getId()); + + List searchExpansionIds = tagRepository.findDescendantIdsByName("ZzzParitaetReise"); + var spec = DocumentSpecifications.hasTags(List.of(new HashSet<>(searchExpansionIds)), true); + long distinctSearchResults = documentRepository.findAll(spec).stream() + .map(Document::getId).distinct().count(); + + assertThat(rollupCount).isEqualTo(distinctSearchResults); + } +}