test(tag): validate subtree rollup CTE against real Postgres (#698)

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 12:15:15 +02:00
committed by marcel
parent 138bf446e4
commit 2f1538754e

View File

@@ -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<UUID, Long> 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<UUID, Long> 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<UUID, Long> 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<UUID, Long> 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<UUID, Long> 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<UUID> 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);
}
}