package service import ( "context" "fmt" "testing" "time" "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestPruneEmptyFolderChainsDeletesOnlyEmptyCandidateAncestors(t *testing.T) { db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{})) ctx := context.Background() repo := repository.NewWikiPageRepository(db) svc := NewWikiPageService(repo, nil, nil, nil, nil) now := time.Now() createFolder := func(id, parentID, name, path string, depth int) { require.NoError(t, repo.CreateFolder(ctx, &types.WikiFolder{ ID: id, TenantID: 1, KnowledgeBaseID: "kb-prune", ParentID: parentID, Name: name, Path: path, Depth: depth, CreatedAt: now, UpdatedAt: now, })) } createFolder("topic", "", "Topic", "Topic", 1) createFolder("empty-leaf", "topic", "Empty", "Topic/Empty", 2) createFolder("occupied-leaf", "topic", "Occupied", "Topic/Occupied", 2) createFolder("empty-chain", "", "Empty chain", "Empty chain", 1) createFolder("empty-chain-leaf", "empty-chain", "Leaf", "Empty chain/Leaf", 2) createFolder("unrelated-empty", "", "Keep me", "Keep me", 1) require.NoError(t, repo.Create(ctx, &types.WikiPage{ ID: "page-1", TenantID: 1, KnowledgeBaseID: "kb-prune", Slug: "entity/occupied", Title: "Occupied", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished, FolderID: "occupied-leaf", Version: 1, CreatedAt: now, UpdatedAt: now, })) deleted, err := svc.PruneEmptyFolderChains(ctx, "kb-prune", []string{"empty-leaf", "empty-chain-leaf"}) require.NoError(t, err) require.ElementsMatch(t, []string{"empty-leaf", "empty-chain-leaf", "empty-chain"}, deleted) _, err = repo.GetFolderByID(ctx, "kb-prune", "topic") require.NoError(t, err, "ancestor with another occupied child must remain") _, err = repo.GetFolderByID(ctx, "kb-prune", "occupied-leaf") require.NoError(t, err) _, err = repo.GetFolderByID(ctx, "kb-prune", "unrelated-empty") require.NoError(t, err, "empty folders outside the affected chains must remain") } func TestStripWikiInlineChunkCitations(t *testing.T) { input := "[**橡皮障夹**](#)**钳**\n\n夹钳是用于夹持橡皮障夹的专用器械[c003]。手柄便于操作 [c003]。多个来源[c003, c1000]。" want := "[**橡皮障夹**](#)**钳**\n\n夹钳是用于夹持橡皮障夹的专用器械。手柄便于操作。多个来源。" if got := stripWikiInlineChunkCitations(input); got != want { t.Fatalf("stripWikiInlineChunkCitations() = %q, want %q", got, want) } } func TestStripWikiInlineChunkCitationsPreservesOrdinaryMarkdown(t *testing.T) { input := "保留 [citation]、[C003]、[c12] 和 [[concept/c003|页面链接]]。" if got := stripWikiInlineChunkCitations(input); got != input { t.Fatalf("stripWikiInlineChunkCitations() changed ordinary Markdown: %q", got) } } func TestUpdateWikiPagePersistsAndClearsAliases(t *testing.T) { db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{})) ctx := context.Background() repo := repository.NewWikiPageRepository(db) svc := NewWikiPageService(repo, nil, nil, nil, nil) page, err := svc.CreatePage(ctx, &types.WikiPage{ TenantID: 1, KnowledgeBaseID: "kb-alias", Slug: "concept/alias", Title: "Alias", Summary: "summary", Content: "content", PageType: types.WikiPageTypeConcept, Aliases: types.StringArray{"old"}, }) require.NoError(t, err) page.Aliases = types.StringArray{"new", "alternate"} updated, err := svc.UpdatePage(ctx, page) require.NoError(t, err) require.Equal(t, types.StringArray{"new", "alternate"}, updated.Aliases) require.Equal(t, 2, updated.Version) updated.Aliases = types.StringArray{} cleared, err := svc.UpdatePage(ctx, updated) require.NoError(t, err) require.Empty(t, cleared.Aliases) require.Equal(t, 3, cleared.Version) stored, err := svc.GetPageBySlug(ctx, "kb-alias", "concept/alias") require.NoError(t, err) require.Empty(t, stored.Aliases) } func TestParseOutLinks(t *testing.T) { svc := &wikiPageService{} tests := []struct { name string content string want []string }{ { name: "single link", content: "See [[entity/acme-corp]] for details.", want: []string{"entity/acme-corp"}, }, { name: "multiple links", content: "See [[entity/acme-corp]] and [[concept/rag]] for details.", want: []string{"entity/acme-corp", "concept/rag"}, }, { name: "duplicate links deduplicated", content: "See [[entity/acme-corp]] and also [[entity/acme-corp]] again.", want: []string{"entity/acme-corp"}, }, { name: "pipe syntax: slug|display name", content: "See [[entity/acme-corp|Acme Corp]] for details.", want: []string{"entity/acme-corp"}, }, { name: "mixed: pipe and bare links", content: "See [[entity/acme-corp|Acme Corp]] and [[concept/rag]] here.", want: []string{"entity/acme-corp", "concept/rag"}, }, { name: "no links", content: "Just plain text without any links.", want: nil, }, { name: "empty content", content: "", want: nil, }, { name: "link with spaces normalized", content: "See [[Entity/Acme Corp]] for details.", want: []string{"entity/acme-corp"}, }, { name: "nested brackets ignored", content: "Not a link: [not [a] link]", want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := svc.parseOutLinks(tt.content) if len(got) != len(tt.want) { t.Errorf("parseOutLinks() = %v (len %d), want %v (len %d)", got, len(got), tt.want, len(tt.want)) return } for i := range got { if got[i] != tt.want[i] { t.Errorf("parseOutLinks()[%d] = %q, want %q", i, got[i], tt.want[i]) } } }) } } func TestRepairContentLinks(t *testing.T) { db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{})) ctx := context.Background() repo := repository.NewWikiPageRepository(db) svc := NewWikiPageService(repo, nil, nil, nil, nil) const kbID = "kb-repair" now := time.Now() seed := func(slug, title, pageType string) { require.NoError(t, repo.Create(ctx, &types.WikiPage{ ID: uuid.New().String(), TenantID: 1, KnowledgeBaseID: kbID, Slug: slug, Title: title, PageType: pageType, Status: types.WikiPageStatusPublished, Version: 1, CreatedAt: now, UpdatedAt: now, })) } // Real summary page (clean UUID slug) + a distractor summary + an entity. realSummary := "summary/07a20bb1-a662-47cf-9929-06fb5d5b5b5e" seed(realSummary, "Weknora 试错记录.md - Summary", types.WikiPageTypeSummary) seed("summary/fcadcaab-e094-4037-b71c-9edfdcdba058", "Another Doc - Summary", types.WikiPageTypeSummary) seed("entity/mongodb", "MongoDB", types.WikiPageTypeEntity) t.Run("mangled uuid summary link repaired via bigram", func(t *testing.T) { // One hex digit inserted into the last group — 404s on exact lookup. content := "See [[summary/07a20bb1-a662-47cf-9929-06fb14d5b14b14e|Weknora 试错记录.md - Summary]] for context." got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content) require.NoError(t, err) require.True(t, changed, "expected the mangled summary link to be rewritten") require.Contains(t, got, "[["+realSummary+"|Weknora 试错记录.md - Summary]]") require.NotContains(t, got, "06fb14d5b14b14e") }) t.Run("live links untouched", func(t *testing.T) { content := "Fine: [[entity/mongodb]] and [[" + realSummary + "]]." got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content) require.NoError(t, err) require.False(t, changed) require.Equal(t, content, got) }) t.Run("unresolvable dead link left as-is, never stripped", func(t *testing.T) { // A future entity page that doesn't exist and isn't similar to anything. content := "Pending: [[entity/some-brand-new-topic|New Topic]]." got, changed, err := svc.RepairContentLinks(ctx, kbID, "synthesis/notes", content) require.NoError(t, err) require.False(t, changed) require.Equal(t, content, got, "unresolvable links must be preserved verbatim, not stripped") }) } func TestNormalizeSlug(t *testing.T) { tests := []struct { input string want string }{ {"Entity/Acme Corp", "entity/acme-corp"}, {" hello ", "hello"}, {"UPPER-CASE", "upper-case"}, {"already-ok", "already-ok"}, {"", ""}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := normalizeSlug(tt.input) if got != tt.want { t.Errorf("normalizeSlug(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestNormalizeWikiHierarchyCleansModelCategoryNoise(t *testing.T) { page := &types.WikiPage{ Slug: "concept/ai-knowledge", Title: "AI时代的知识困境", PageType: types.WikiPageTypeConcept, CategoryPath: types.StringArray{"概念/爱护花草", " 实体/标牌 ", "摘要", "生态/平台", "多余层级"}, } normalizeWikiHierarchy(page) want := types.StringArray{"爱护花草", "标牌", "生态"} if len(page.CategoryPath) != len(want) { t.Fatalf("CategoryPath = %v, want %v", page.CategoryPath, want) } for i := range want { if page.CategoryPath[i] != want[i] { t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, page.CategoryPath[i], want[i], page.CategoryPath) } } if page.Depth != 3 { t.Fatalf("Depth = %d, want 3", page.Depth) } if page.WikiPath != "concept/爱护花草/标牌/生态/AI时代的知识困境" { t.Fatalf("WikiPath = %q", page.WikiPath) } } func TestNormalizeWikiIndexEntryHierarchyCleansModelCategoryNoise(t *testing.T) { entry := &types.WikiIndexEntry{ Slug: "entity/sign", Title: "爱护花草标牌", CategoryPath: types.StringArray{"实体/标牌", "概念", "生态/行为倡导"}, } normalizeWikiIndexEntryHierarchy(entry, types.WikiPageTypeEntity) want := types.StringArray{"标牌", "生态", "行为倡导"} if len(entry.CategoryPath) != len(want) { t.Fatalf("CategoryPath = %v, want %v", entry.CategoryPath, want) } for i := range want { if entry.CategoryPath[i] != want[i] { t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, entry.CategoryPath[i], want[i], entry.CategoryPath) } } if entry.WikiPath != "entity/标牌/生态/行为倡导/爱护花草标牌" { t.Fatalf("WikiPath = %q", entry.WikiPath) } } func TestContainsString(t *testing.T) { slice := []string{"a", "b", "c"} if !containsString(slice, "b") { t.Error("should contain 'b'") } if containsString(slice, "d") { t.Error("should not contain 'd'") } if containsString(nil, "a") { t.Error("nil slice should not contain anything") } } func TestRemoveString(t *testing.T) { slice := types.StringArray{"a", "b", "c", "b"} result := removeString(slice, "b") if len(result) != 2 { t.Errorf("Expected 2 items after removing 'b', got %d: %v", len(result), result) } if result[0] != "a" || result[1] != "c" { t.Errorf("Unexpected result: %v", result) } // Remove non-existing result2 := removeString(slice, "z") if len(result2) != 4 { t.Errorf("Expected 4 items (nothing removed), got %d", len(result2)) } } // makeGraphFixture builds a small synthetic wiki for GetGraph tests. // // Edges (directed): // // hub -> a, hub -> b, hub -> c, hub -> d // a -> hub // b -> hub // c -> d // x -> y (isolated 2-node cluster, disconnected from hub) // // Page types are chosen so type-filter tests can exclude specific nodes. func makeGraphFixture() []*types.WikiPage { return []*types.WikiPage{ { Slug: "hub", Title: "Hub", PageType: types.WikiPageTypeSummary, OutLinks: types.StringArray{"a", "b", "c", "d"}, InLinks: types.StringArray{"a", "b"}, }, { Slug: "a", Title: "A", PageType: types.WikiPageTypeEntity, OutLinks: types.StringArray{"hub"}, InLinks: types.StringArray{"hub"}, }, { Slug: "b", Title: "B", PageType: types.WikiPageTypeEntity, OutLinks: types.StringArray{"hub"}, InLinks: types.StringArray{"hub"}, }, { Slug: "c", Title: "C", PageType: types.WikiPageTypeConcept, OutLinks: types.StringArray{"d"}, InLinks: types.StringArray{"hub"}, }, { Slug: "d", Title: "D", PageType: types.WikiPageTypeConcept, OutLinks: types.StringArray{}, InLinks: types.StringArray{"hub", "c"}, }, { Slug: "x", Title: "X", PageType: types.WikiPageTypeEntity, OutLinks: types.StringArray{"y"}, InLinks: types.StringArray{}, }, { Slug: "y", Title: "Y", PageType: types.WikiPageTypeEntity, OutLinks: types.StringArray{}, InLinks: types.StringArray{"x"}, }, } } func nodeSlugs(data *types.WikiGraphData) map[string]bool { out := make(map[string]bool, len(data.Nodes)) for _, n := range data.Nodes { out[n.Slug] = true } return out } // TestComputeGraphSubset_OverviewTruncatesByLinkCount verifies that overview // mode returns the most-connected nodes first and reports truncation // honestly in Meta. At 4万 pages this is the path that must NOT return the // full graph — the cap is what keeps the response size and frontend // rendering tractable. func TestComputeGraphSubset_OverviewTruncatesByLinkCount(t *testing.T) { pages := makeGraphFixture() got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeOverview, Limit: 3, }) if err != nil { t.Fatalf("computeGraphSubset: %v", err) } if len(got.Nodes) != 3 { t.Errorf("want 3 nodes, got %d (%v)", len(got.Nodes), nodeSlugs(got)) } slugs := nodeSlugs(got) // hub has link_count 6 (4 out + 2 in), must survive the cap. if !slugs["hub"] { t.Errorf("expected hub to survive the top-3 cap, got %v", slugs) } if !got.Meta.Truncated { t.Errorf("expected Meta.Truncated=true when returned < total") } if got.Meta.Total != len(pages) { t.Errorf("Meta.Total = %d, want %d", got.Meta.Total, len(pages)) } if got.Meta.Returned != len(got.Nodes) { t.Errorf("Meta.Returned mismatch: %d vs %d", got.Meta.Returned, len(got.Nodes)) } // Every returned edge must connect two surviving nodes. for _, e := range got.Edges { if !slugs[e.Source] || !slugs[e.Target] { t.Errorf("edge %s->%s references a non-returned node", e.Source, e.Target) } } } func TestComputeGraphSubset_MarksFamiliarSourcePages(t *testing.T) { pages := makeGraphFixture() pages[0].SourceRefs = types.StringArray{"doc-1|排班手册"} pages[1].SourceRefs = types.StringArray{"doc-2"} got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeOverview, Limit: 0, FamiliarKnowledgeIDs: []string{"doc-1"}, }) if err != nil { t.Fatalf("computeGraphSubset: %v", err) } familiar := map[string]bool{} for _, n := range got.Nodes { if n.Familiar { familiar[n.Slug] = true } } if !familiar["hub"] { t.Errorf("hub sources doc-1, want familiar, got %v", familiar) } if familiar["a"] { t.Errorf("a sources a different document, must not light up") } if got.Meta.FamiliarCount != 1 { t.Errorf("FamiliarCount = %d, want 1", got.Meta.FamiliarCount) } } // TestComputeGraphSubset_OverviewUncapped ensures the Limit<=0 escape hatch // still works for internal callers (wiki lint) that need every page. func TestComputeGraphSubset_OverviewUncapped(t *testing.T) { pages := makeGraphFixture() got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeOverview, Limit: 0, }) if err != nil { t.Fatalf("computeGraphSubset: %v", err) } if len(got.Nodes) != len(pages) { t.Errorf("want %d nodes (uncapped), got %d", len(pages), len(got.Nodes)) } if got.Meta.Truncated { t.Errorf("expected Meta.Truncated=false when nothing was dropped") } } // TestComputeGraphSubset_OverviewTypeFilter ensures the type filter applies // to the candidate set (not just post-hoc), so "total" reflects the // filter-aware denominator the frontend shows to the user. func TestComputeGraphSubset_OverviewTypeFilter(t *testing.T) { pages := makeGraphFixture() got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeOverview, Types: []string{types.WikiPageTypeEntity}, Limit: 100, }) if err != nil { t.Fatalf("computeGraphSubset: %v", err) } slugs := nodeSlugs(got) // Only entity-typed pages: a, b, x, y. for _, expected := range []string{"a", "b", "x", "y"} { if !slugs[expected] { t.Errorf("expected entity %q to be returned, got %v", expected, slugs) } } for _, forbidden := range []string{"hub", "c", "d"} { if slugs[forbidden] { t.Errorf("%q should have been filtered out by type, got %v", forbidden, slugs) } } if got.Meta.Total != 4 { t.Errorf("Meta.Total should equal entity-typed page count (4), got %d", got.Meta.Total) } } // TestComputeGraphSubset_EgoDepth1 checks that depth=1 returns center + // immediate neighbors only, with no transitive hops. func TestComputeGraphSubset_EgoDepth1(t *testing.T) { pages := makeGraphFixture() got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeEgo, Center: "hub", Depth: 1, Limit: 100, }) if err != nil { t.Fatalf("computeGraphSubset: %v", err) } slugs := nodeSlugs(got) // hub's direct neighbors: a, b, c, d (out) ∪ a, b (in) = {a, b, c, d} + hub. want := []string{"hub", "a", "b", "c", "d"} for _, s := range want { if !slugs[s] { t.Errorf("expected %q in ego depth=1 of hub, got %v", s, slugs) } } // x and y are in a disconnected cluster and must not leak in. if slugs["x"] || slugs["y"] { t.Errorf("disconnected nodes leaked into ego graph: %v", slugs) } if got.Meta.Mode != types.WikiGraphModeEgo || got.Meta.Center != "hub" || got.Meta.Depth != 1 { t.Errorf("Meta not populated correctly for ego: %+v", got.Meta) } } // TestComputeGraphSubset_EgoDepth2 checks that an extra hop reaches pages // only accessible through a one-hop neighbor (c -> d becomes reachable from // "a" at depth 2 because a -> hub -> c -> d ... wait, that's 3 hops. Use // a clearer case: from "a", depth=2 should reach hub's neighbors.) func TestComputeGraphSubset_EgoDepth2ExpandsFrontier(t *testing.T) { pages := makeGraphFixture() depth1, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeEgo, Center: "a", Depth: 1, Limit: 100, }) if err != nil { t.Fatalf("depth=1: %v", err) } depth2, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeEgo, Center: "a", Depth: 2, Limit: 100, }) if err != nil { t.Fatalf("depth=2: %v", err) } if len(depth2.Nodes) <= len(depth1.Nodes) { t.Errorf("depth=2 should expand the frontier beyond depth=1 (%d <= %d)", len(depth2.Nodes), len(depth1.Nodes)) } // At depth 2 from "a": a -> hub -> {b, c, d}. So b, c, d must appear. slugs := nodeSlugs(depth2) for _, s := range []string{"a", "hub", "b", "c", "d"} { if !slugs[s] { t.Errorf("expected %q at depth=2 from a, got %v", s, slugs) } } } // TestComputeGraphSubset_EgoRejectsMissingCenter ensures we fail fast // rather than returning an empty graph when the caller points at a // non-existent slug — an empty result here would look identical to "your // wiki has no links to that page" and hide the real bug. func TestComputeGraphSubset_EgoRejectsMissingCenter(t *testing.T) { pages := makeGraphFixture() _, err := computeGraphSubset(pages, &types.WikiGraphRequest{ Mode: types.WikiGraphModeEgo, Center: "does-not-exist", Depth: 1, Limit: 100, }) if err == nil { t.Fatalf("expected error for missing center slug") } } func TestFindPagesByNormalizedTitleMatchesWhitespace(t *testing.T) { db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&types.WikiFolder{}, &types.WikiPage{}, &types.WikiPageRevision{})) ctx := context.Background() repo := repository.NewWikiPageRepository(db) svc := NewWikiPageService(repo, nil, nil, nil, nil) now := time.Now() require.NoError(t, repo.Create(ctx, &types.WikiPage{ ID: "page-kong", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "entity/confucius", Title: "孔 子", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished, Version: 1, CreatedAt: now, UpdatedAt: now, })) require.NoError(t, repo.Create(ctx, &types.WikiPage{ ID: "page-fable", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "concept/yuyan", Title: "《寓言》", PageType: types.WikiPageTypeConcept, Status: types.WikiPageStatusPublished, Version: 1, CreatedAt: now, UpdatedAt: now, })) pages, err := svc.FindPagesByNormalizedTitle(ctx, "kb-id", types.WikiPageTypeEntity, "孔子") require.NoError(t, err) require.Len(t, pages, 1) require.Equal(t, "entity/confucius", pages[0].Slug) none, err := svc.FindPagesByNormalizedTitle(ctx, "kb-id", types.WikiPageTypeConcept, "寓言") require.NoError(t, err) require.Empty(t, none, "punctuation-significant titles must stay distinct") require.NoError(t, repo.Create(ctx, &types.WikiPage{ ID: "page-mencius", TenantID: 1, KnowledgeBaseID: "kb-id", Slug: "entity/mencius", Title: "孟子", PageType: types.WikiPageTypeEntity, Status: types.WikiPageStatusPublished, Version: 1, CreatedAt: now, UpdatedAt: now, })) batched, err := svc.FindPagesByNormalizedTitles(ctx, "kb-id", types.WikiPageTypeEntity, []string{"孔子", "孟子", "孔子"}) require.NoError(t, err) require.Len(t, batched, 2) slugs := []string{batched[0].Slug, batched[1].Slug} require.ElementsMatch(t, []string{"entity/confucius", "entity/mencius"}, slugs) }