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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user