package vip.mate.wiki.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import vip.mate.wiki.repository.WikiPageMapper; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** * E2E coverage for {@link WikiPageService#mergeDuplicateTitles} — the one-time * maintenance op that collapses pages sharing a canonical title (the duplicate * rows produced before title-based dedup existed, when one concept landed under * several LLM-minted slugs). * *

Boots the full Spring + H2 + Flyway context so MyBatis-Plus's lambda cache * and the real cascade/link machinery are exercised end to end. */ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { "spring.flyway.enabled=true", "spring.flyway.locations=classpath:db/migration/h2", "mateclaw.feature-flag.refresh-ms=999999" } ) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class WikiMergeDuplicateTitlesE2ETest { @Autowired private WikiPageService pageService; @Autowired private WikiKnowledgeBaseService kbService; @Autowired private WikiPageMapper pageMapper; @Autowired private WikiKnowledgeBaseMapper kbMapper; private Long kbId; @AfterEach void cleanup() { if (kbId != null) { pageMapper.delete(new LambdaQueryWrapper().eq(WikiPageEntity::getKbId, kbId)); kbMapper.deleteById(kbId); pageService.evictSummaryCache(kbId); kbId = null; } } private void seedKb() { WikiKnowledgeBaseEntity kb = kbService.create("merge-dup-" + System.nanoTime(), "merge test", null); kbId = kb.getId(); pageMapper.delete(new LambdaQueryWrapper().eq(WikiPageEntity::getKbId, kbId)); pageService.evictSummaryCache(kbId); } /** * Three pages share the canonical title "医宗金鉴" under different slugs (the * exact failure mode from the bug report), plus a referrer linking to a loser. */ private void seedDuplicates() { // Winner: longest content. pageService.createPage(kbId, "yizong-jinjian", "医宗金鉴", "## 医宗金鉴\n\nThis is the most complete body with the richest detail aaa bbb ccc ddd.", "complete summary", "[]"); // Losers: same canonical title, shorter content, different slugs. pageService.createPage(kbId, "yizong-jinjian-quanshu", "医宗金鉴", "Body B shorter.", "b summary", "[]"); // Trailing-space title still canonicalizes equal. pageService.createPage(kbId, "yzjj", "医宗金鉴 ", "Body C tiny.", "c summary", "[]"); // A referrer pointing at one of the losers. pageService.createPage(kbId, "ref", "Ref", "See [[yizong-jinjian-quanshu]] for the canonical text.", "referrer summary", "[]"); } @Test @DisplayName("dry run reports duplicates without mutating anything") void dryRunReportsButDoesNotMutate() { seedKb(); seedDuplicates(); Map report = pageService.mergeDuplicateTitles(kbId, true, true); assertThat(report.get("dryRun")).isEqualTo(true); assertThat(report.get("duplicateGroups")).isEqualTo(1); assertThat(report.get("pagesWouldRemove")).isEqualTo(2); assertThat(report.get("pagesRemoved")).isEqualTo(0); // Nothing deleted: all four pages still present. assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(4); assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNotNull(); assertThat(pageService.getBySlug(kbId, "yzjj")).isNotNull(); } @Test @DisplayName("apply with concatenate merges losers into winner, redirects refs, deletes losers") void applyConcatenateCollapsesGroup() { seedKb(); seedDuplicates(); Map report = pageService.mergeDuplicateTitles(kbId, false, true); assertThat(report.get("duplicateGroups")).isEqualTo(1); assertThat(report.get("pagesRemoved")).isEqualTo(2); // Only the winner + the referrer survive. assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2); assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNull(); assertThat(pageService.getBySlug(kbId, "yzjj")).isNull(); // Winner keeps its body and gains the losers' bodies (no content lost). WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian"); assertThat(winner).isNotNull(); assertThat(winner.getContent()) .contains("most complete body") .contains("Body B shorter.") .contains("Body C tiny."); assertThat(winner.getVersion()).isGreaterThan(1); // Referrer's [[loserSlug]] is redirected to the winner, not demoted. WikiPageEntity ref = pageService.getBySlug(kbId, "ref"); assertThat(ref.getContent()) .doesNotContain("[[yizong-jinjian-quanshu]]") .contains("[[yizong-jinjian]]"); } @Test @DisplayName("apply without concatenate keeps only the winner's body") void applyWithoutConcatenateDiscardsLoserBodies() { seedKb(); seedDuplicates(); pageService.mergeDuplicateTitles(kbId, false, false); assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2); WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian"); assertThat(winner).isNotNull(); assertThat(winner.getContent()) .contains("most complete body") .doesNotContain("Body B shorter.") .doesNotContain("Body C tiny."); } }