package legacygraph import ( "context" "encoding/json" "fmt" "os" "slices" "strings" "testing" "time" "github.com/Tencent/WeKnora/internal/types" "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestMissingGraphScope(t *testing.T) { r := NewRepository(nil, nil) if _, err := r.Browse(context.Background(), "kb", "", 0, 0); err == nil { t.Fatal("missing tenant accepted") } ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10059)) if _, err := r.SearchNode(ctx, types.NameSpace{}, []string{"term"}); err == nil { t.Fatal("missing KB accepted") } } func TestBoundedTerms(t *testing.T) { terms := boundedTerms([]string{"", " ", "a", "a", strings.Repeat("中", 240), "b", "c", "d", "e", "f", "g", "h"}) if len(terms) != 8 || terms[0] != "a" || len([]rune(terms[1])) != 200 { t.Fatalf("unbounded terms: %#v", terms) } } func TestSearchNodeKeepsGenericSourcesIsolated(t *testing.T) { tx, r, ctx := nativeWriteFixture(t) if err := tx.Exec(`INSERT INTO chunks VALUES('sibling',7,'kb','doc','Evidence about trust and learning.',NULL,true)`).Error; err != nil { t.Fatal(err) } ns := types.NameSpace{KnowledgeBase: "kb", Knowledge: "doc"} for chunk, target := range map[string]string{"chunk": "First target", "sibling": "Second target"} { graph := &types.GraphData{ Node: []*types.GraphNode{ {Name: "本品", Chunks: []string{chunk}}, {Name: target, Chunks: []string{chunk}}, }, Relation: []*types.GraphRelation{{Node1: "本品", Node2: target, Type: "关联"}}, } if err := r.AddGraph(ctx, ns, []*types.GraphData{graph}); err != nil { t.Fatal(err) } } for chunk, target := range map[string]string{"chunk": "First target", "sibling": "Second target"} { graph, err := r.SearchNode(ctx, ns, []string{target}) if err != nil || len(graph.Node) != 2 || len(graph.Relation) != 1 { t.Fatalf("unexpected single-product result: %#v %v", graph, err) } for _, node := range graph.Node { if len(node.Chunks) != 1 || node.Chunks[0] != chunk { t.Fatalf("same-name neighbor expanded another product: %#v", node) } } if edge := graph.Relation[0]; edge.Node1 != "本品" || edge.Node2 != target { t.Fatalf("unexpected relation projection: %#v", edge) } } graph, err := r.SearchNode(ctx, ns, []string{"本品"}) if err != nil || len(graph.Node) != 4 || len(graph.Relation) != 2 { t.Fatalf("same-name nodes were collapsed: %#v %v", graph, err) } sources := map[string]bool{} identities := map[string]bool{} for _, node := range graph.Node { if node.Name != "本品" { continue } if len(node.Chunks) != 1 || len(node.Attributes) != 2 { t.Fatalf("generic source identity was lost: %#v", node) } sources[node.Chunks[0]] = true identities[node.Attributes[1]] = true } if len(sources) != 2 || !sources["chunk"] || !sources["sibling"] || len(identities) != 2 { t.Fatalf("generic products share retrieval identity: sources=%v identities=%v", sources, identities) } } func assertReaderTrustAttributes(t *testing.T, r *Repository, ctx context.Context, want []string) { t.Helper() ns := types.NameSpace{KnowledgeBase: "kb", Knowledge: "doc"} id, _ := nativeID("doc", "entity", "trust") wantType := "concept" if len(want) > 0 { wantType = want[0] } wantGraph := append([]string{wantType}, want...) if len(want) > 0 { wantGraph = wantGraph[1:] } wantGraph = append(wantGraph, fmt.Sprintf("legacy_entity:%d", id)) for _, term := range []string{"Trust", "Learning"} { graph, err := r.SearchNode(ctx, ns, []string{term}) if err != nil { t.Fatal(err) } var found *types.GraphNode for _, node := range graph.Node { if node.Name == "Trust" { found = node } } if found == nil || !slices.Equal(found.Attributes, wantGraph) { t.Fatalf("%s returned unsupported native attributes: got=%#v want=%v", term, found, wantGraph) } } for _, entityID := range []int64{0, id} { page, err := r.Browse(ctx, "kb", "", 0, entityID) if err != nil { t.Fatal(err) } found := false for _, node := range page.Nodes { if node.ID != id { continue } found = true var payload struct { Attributes []string `json:"attributes"` Type string `json:"entity_type"` } if err := json.Unmarshal(node.Payload, &payload); err != nil { t.Fatal(err) } if !slices.Equal(payload.Attributes, want) || payload.Type != wantType { t.Fatalf("browse returned unsupported native attributes: %#v; want %v / %s", payload, want, wantType) } } if !found { t.Fatal("browse lost the entity's surviving bare reference") } } } func TestNativeReadersRevalidateSharedAttributeSources(t *testing.T) { for name, invalidate := range map[string]string{ "changed source": `UPDATE chunks SET content=content || ' changed' WHERE id='chunk' AND tenant_id=7`, "disabled source": `UPDATE chunks SET is_enabled=false WHERE id='chunk' AND tenant_id=7`, "deleted source": `UPDATE chunks SET deleted_at=now() WHERE id='chunk' AND tenant_id=7`, "moved source document": `UPDATE chunks SET knowledge_id='doc2' WHERE id='chunk' AND tenant_id=7`, "wrong evidence hash": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{source_content_hash}',to_jsonb(repeat('a',64))) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong row hash": `UPDATE native_graph_records SET source_snapshot_sha256=repeat('a',64) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "inactive evidence": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{status}','"inactive"'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong origin": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{origin}','"foreign"'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong tenant": `UPDATE native_graph_records SET tenant_id=8 WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong knowledge base": `UPDATE native_graph_records SET knowledge_base_id='other-kb' WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong document": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{native_knowledge_id}','"doc2"'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "wrong mapping document": `UPDATE native_graph_sources SET native_knowledge_id='doc2' WHERE source_chunk_id='native:chunk'`, "wrong mapping hash": `UPDATE native_graph_sources SET source_content_hash=repeat('a',64) WHERE source_chunk_id='native:chunk'`, "inactive mapping": `UPDATE native_graph_sources SET mapping_state='stale' WHERE source_chunk_id='native:chunk'`, "missing old snapshot": `UPDATE native_graph_records SET payload=payload-'entity_attributes' WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk'`, "null snapshot": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{entity_attributes}','null'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk' AND payload->>'subject_type'='entity'`, "null attribute": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{entity_attributes}','[null]'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:chunk' AND payload->>'subject_type'='entity'`, "conflicting snapshot": `UPDATE native_graph_records SET payload=jsonb_set(payload,'{entity_attributes}','["contradiction"]'::jsonb) WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:sibling' AND payload->>'subject_type'='entity'`, } { t.Run(name, func(t *testing.T) { tx, r, ctx := nativeWriteFixture(t) if err := tx.Exec(`INSERT INTO chunks VALUES('sibling',7,'kb','doc','Another source about trust and learning.',NULL,true)`).Error; err != nil { t.Fatal(err) } owner, bare := testGraph("chunk"), testGraph("sibling") owner.Node[0].Attributes = []string{"restriction", "Cannot be taken orally"} bare.Node[0].Attributes = nil ns := types.NameSpace{KnowledgeBase: "kb", Knowledge: "doc"} if err := r.AddGraph(ctx, ns, []*types.GraphData{owner, bare}); err != nil { t.Fatal(err) } assertReaderTrustAttributes(t, r, ctx, owner.Node[0].Attributes) if err := tx.Exec(invalidate).Error; err != nil { t.Fatal(err) } before := nativeGraphWriteState(t, tx) assertReaderTrustAttributes(t, r, ctx, nil) if after := nativeGraphWriteState(t, tx); after != before { t.Fatal("reader projection changed stored graph records or mappings") } }) } } func TestNativeReadersKeepVerifiedAttributesBeyondChunkCap(t *testing.T) { tx, r, ctx := nativeWriteFixture(t) if err := tx.Exec(`INSERT INTO chunks SELECT id,7,'kb','doc','Another source about trust and learning.',NULL,true FROM unnest(ARRAY['aa','ab','ac','ad']) id`).Error; err != nil { t.Fatal(err) } ns := types.NameSpace{KnowledgeBase: "kb", Knowledge: "doc"} owner := testGraph("chunk") owner.Node[0].Attributes = []string{"restriction", "Cannot be taken orally"} graphs := []*types.GraphData{owner} for _, id := range []string{"aa", "ab", "ac", "ad"} { bare := testGraph(id) bare.Node[0].Attributes = nil graphs = append(graphs, bare) } if err := r.AddGraph(ctx, ns, graphs); err != nil { t.Fatal(err) } // An old bare source cannot erase attributes proved by an explicit source. if err := tx.Exec(`UPDATE native_graph_records SET payload=payload-'entity_attributes' WHERE source_table='knowledgegraphevidence' AND payload->>'source_chunk_id'='native:aa'`).Error; err != nil { t.Fatal(err) } assertReaderTrustAttributes(t, r, ctx, owner.Node[0].Attributes) graph, err := r.SearchNode(ctx, ns, []string{"Trust"}) if err != nil { t.Fatal(err) } for _, node := range graph.Node { if len(node.Chunks) != 4 || (node.Name == "Trust" && !slices.Contains(node.Chunks, "chunk")) { t.Fatalf("attribute owner was omitted from the bounded references: %#v", node) } } if err := tx.Exec(`UPDATE chunks SET is_enabled=false WHERE id='chunk' AND tenant_id=7`).Error; err != nil { t.Fatal(err) } assertReaderTrustAttributes(t, r, ctx, nil) } func TestNativeReadersRespectCopiedSourceMappings(t *testing.T) { tx, r, ctx := nativeWriteFixture(t) graph := testGraph("chunk") graph.Node[0].Attributes = []string{"restriction", "Cannot be taken orally"} if err := r.AddGraph(ctx, types.NameSpace{KnowledgeBase: "kb", Knowledge: "doc"}, []*types.GraphData{graph}); err != nil { t.Fatal(err) } for _, sql := range []string{ `INSERT INTO knowledges VALUES('copy-doc',7,'other-kb','copy.md',NULL)`, `INSERT INTO chunks VALUES('copy-chunk',7,'other-kb','copy-doc','Evidence about trust and learning.',NULL,true)`, `INSERT INTO native_graph_records SELECT tenant_id,'other-kb',source_table,legacy_id,graph_version_id,payload,source_snapshot_sha256,imported_at FROM native_graph_records WHERE tenant_id=7 AND knowledge_base_id='kb'`, `INSERT INTO native_graph_sources SELECT tenant_id,'other-kb',source_chunk_id,source_file,source_content_hash,excerpt,'copy-doc','copy-chunk',native_content_sha256,mapping_state FROM native_graph_sources WHERE tenant_id=7 AND knowledge_base_id='kb'`, `UPDATE chunks SET is_enabled=false WHERE id='chunk' AND tenant_id=7`, } { if err := tx.Exec(sql).Error; err != nil { t.Fatal(err) } } ns := types.NameSpace{KnowledgeBase: "other-kb", Knowledge: "copy-doc"} id, _ := nativeID("doc", "entity", "trust") found, err := r.SearchNode(ctx, ns, []string{"Trust"}) if err != nil || len(found.Node) != 2 { t.Fatalf("copied native graph was not retrieved: %#v %v", found, err) } for _, node := range found.Node { if !slices.Equal(node.Chunks, []string{"copy-chunk"}) { t.Fatalf("copied graph retained an original chunk reference: %#v", node) } if node.Name == "Trust" && !slices.Equal(node.Attributes, []string{"restriction", "Cannot be taken orally", fmt.Sprintf("legacy_entity:%d", id)}) { t.Fatalf("copied source snapshot lost its attributes: %#v", node) } } wrong, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: "other-kb", Knowledge: "doc"}, []string{"Trust"}) if err != nil || len(wrong.Node) != 0 { t.Fatalf("copied graph leaked through the original document scope: %#v %v", wrong, err) } page, err := r.Browse(ctx, "other-kb", "", 0, id) if err != nil || len(page.Nodes) != 1 { t.Fatalf("copied entity was not browsable: %#v %v", page, err) } var payload struct { Attributes []string `json:"attributes"` } if err := json.Unmarshal(page.Nodes[0].Payload, &payload); err != nil || !slices.Equal(payload.Attributes, graph.Node[0].Attributes) { t.Fatalf("copied browse attributes were not revalidated: %#v %v", payload, err) } } // Fixture mutations are confined to a rolled-back transaction. func TestAcceptancePreservedGraph(t *testing.T) { dsn := os.Getenv("GRAPH_TEST_DSN") if dsn == "" { t.Skip("GRAPH_TEST_DSN is required for acceptance integration") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { t.Fatal(err) } tx := db.Begin() defer tx.Rollback() if tx.Error != nil { t.Fatal(tx.Error) } ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10059)) r := NewRepository(tx, nil) var fixture struct { KB string Entity int64 Name string Chunk string Knowledge string } err = tx.Raw(`SELECT r.knowledge_base_id AS kb,r.legacy_id AS entity,r.payload->>'canonical_name' AS name,s.native_chunk_id AS chunk,s.native_knowledge_id AS knowledge FROM native_graph_records r JOIN native_graph_records e ON e.tenant_id=r.tenant_id AND e.knowledge_base_id=r.knowledge_base_id AND e.source_table='knowledgegraphevidence' AND e.payload->>'subject_type'='entity' AND e.payload->>'subject_id'=r.legacy_id::text JOIN native_graph_sources s ON s.tenant_id=e.tenant_id AND s.knowledge_base_id=e.knowledge_base_id AND s.source_chunk_id=e.payload->>'source_chunk_id' WHERE r.tenant_id=10059 AND r.source_table='knowledgeentity' AND r.knowledge_base_id='16c3e59c-4bb6-41cf-ae0a-6d0941b3d032' AND s.native_chunk_id IS NOT NULL ORDER BY r.legacy_id LIMIT 1`).Scan(&fixture).Error if err != nil || fixture.Chunk == "" { t.Fatalf("missing mapped fixture: %v", err) } overview, err := r.Browse(ctx, fixture.KB, "", 0, fixture.Entity) if err != nil { t.Fatal(err) } var preservedEntities, preservedMappings int64 if err := tx.Raw(`SELECT count(*) FROM native_graph_records WHERE tenant_id=10059 AND knowledge_base_id=? AND source_table='knowledgeentity' AND payload->>'origin' IS DISTINCT FROM 'native_extraction_v1'`, fixture.KB).Scan(&preservedEntities).Error; err != nil { t.Fatal(err) } if err := tx.Raw(`SELECT count(*) FROM native_graph_sources WHERE tenant_id=10059 AND knowledge_base_id=? AND mapping_state<>'native_extraction' AND native_chunk_id IS NOT NULL`, fixture.KB).Scan(&preservedMappings).Error; err != nil { t.Fatal(err) } if len(overview.Nodes) != 1 || len(overview.Evidence) == 0 || preservedEntities != 2 || preservedMappings != 3 || overview.Counts["knowledgeentity"] < preservedEntities || overview.MappedSources < preservedMappings { t.Fatalf("preserved graph changed: entities=%d mappings=%d counts=%v", preservedEntities, preservedMappings, overview.Counts) } found := false for _, e := range overview.Evidence { if e.NativeChunkID == fixture.Chunk { found = true } } if !found { t.Fatal("mapped evidence was not returned") } graph, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: fixture.KB, Knowledge: fixture.Knowledge}, []string{fixture.Name}) if err != nil { t.Fatal(err) } if len(graph.Node) == 0 { t.Fatal("native graph retrieval produced no nodes") } for _, n := range graph.Node { for _, c := range n.Chunks { if strings.HasPrefix(c, "chunk-") { t.Fatal("legacy chunk ID leaked into native retrieval") } } } other := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(999999999)) denied, err := r.Browse(other, fixture.KB, "", 0, fixture.Entity) if err != nil || len(denied.Nodes) != 0 || len(denied.Evidence) != 0 { t.Fatal("cross-tenant graph leak", err) } wrongFile, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: fixture.KB, Knowledge: "other-file"}, []string{fixture.Name}) if err != nil || len(wrongFile.Node) != 0 { t.Fatal("file scope was ignored", err) } for _, change := range []struct { name, sql, id string }{ {"edited chunk", "UPDATE chunks SET content=content || ' stale-check' WHERE id=? AND tenant_id=10059", fixture.Chunk}, {"disabled chunk", "UPDATE chunks SET is_enabled=false WHERE id=? AND tenant_id=10059", fixture.Chunk}, {"deleted chunk", "UPDATE chunks SET deleted_at=now() WHERE id=? AND tenant_id=10059", fixture.Chunk}, {"deleted document", "UPDATE knowledges SET deleted_at=now() WHERE id=? AND tenant_id=10059", fixture.Knowledge}, {"deleted knowledge base", "UPDATE knowledge_bases SET deleted_at=now() WHERE id=? AND tenant_id=10059", fixture.KB}, } { t.Run(change.name, func(t *testing.T) { if err := tx.SavePoint("resource_check").Error; err != nil { t.Fatal(err) } defer tx.RollbackTo("resource_check") if err := tx.Exec(change.sql, change.id).Error; err != nil { t.Fatal(err) } stale, err := r.Browse(ctx, fixture.KB, "", 0, fixture.Entity) if err != nil { t.Fatal(err) } for _, e := range stale.Evidence { if e.NativeChunkID == fixture.Chunk { t.Fatal("unavailable chunk remained linked") } } staleGraph, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: fixture.KB}, []string{fixture.Name}) if err != nil { t.Fatal(err) } for _, n := range staleGraph.Node { for _, c := range n.Chunks { if c == fixture.Chunk { t.Fatal("unavailable chunk leaked into retrieval") } } } }) } } func TestAcceptanceLargestGraph(t *testing.T) { dsn := os.Getenv("GRAPH_TEST_DSN") if dsn == "" { t.Skip("GRAPH_TEST_DSN is required for acceptance integration") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { t.Fatal(err) } ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(10059)) kb := "bb86b44a-576b-4bf8-a6bf-8f1baca3a845" r := NewRepository(db, nil) started := time.Now() overview, err := r.Browse(ctx, kb, "", 0, 0) if err != nil { t.Fatal(err) } var preserved struct{ Sources, Mapped int64 } if err := db.Raw(`SELECT count(*) AS sources,count(native_chunk_id) AS mapped FROM native_graph_sources WHERE tenant_id=10059 AND knowledge_base_id=? AND mapping_state<>'native_extraction'`, kb).Scan(&preserved).Error; err != nil { t.Fatal(err) } if len(overview.Nodes) != 50 || preserved.Sources != 66204 || preserved.Mapped != 7000 || overview.Sources < preserved.Sources || overview.MappedSources < preserved.Mapped { t.Fatalf("unexpected largest graph overview: preserved=%+v sources=%d mapped=%d", preserved, overview.Sources, overview.MappedSources) } t.Logf("largest KB overview: %s", time.Since(started)) started = time.Now() graph, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: kb}, []string{"销售", "客户", "信任", "心法"}) if err != nil { t.Fatal(err) } if len(graph.Node) == 0 || len(graph.Node) > 32 || len(graph.Relation) > 32 { t.Fatalf("invalid bounded retrieval: %d nodes, %d relations", len(graph.Node), len(graph.Relation)) } for _, node := range graph.Node { if len(node.Chunks) == 0 || len(node.Chunks) > 4 { t.Fatalf("invalid chunk cap: %#v", node) } } t.Logf("largest KB graph retrieval: %s; %d nodes, %d relations", time.Since(started), len(graph.Node), len(graph.Relation)) var relationFixture struct { KB, From, To, Relation string } err = db.Raw(`SELECT r.knowledge_base_id AS kb,a.payload->>'canonical_name' AS "from",b.payload->>'canonical_name' AS "to",r.payload->>'relation_type' AS relation FROM native_graph_records r JOIN native_graph_records a ON a.tenant_id=r.tenant_id AND a.knowledge_base_id=r.knowledge_base_id AND a.source_table='knowledgeentity' AND a.legacy_id=(r.payload->>'from_entity_id')::bigint JOIN native_graph_records b ON b.tenant_id=r.tenant_id AND b.knowledge_base_id=r.knowledge_base_id AND b.source_table='knowledgeentity' AND b.legacy_id=(r.payload->>'to_entity_id')::bigint WHERE r.tenant_id=10059 AND r.source_table='knowledgerelation' AND r.legacy_id=1`).Scan(&relationFixture).Error if err != nil || relationFixture.KB == "" { t.Fatalf("missing relation fixture: %v", err) } expanded, err := r.SearchNode(ctx, types.NameSpace{KnowledgeBase: relationFixture.KB}, []string{relationFixture.From}) if err != nil { t.Fatal(err) } found := false for _, relation := range expanded.Relation { if relation.Node1 == relationFixture.From && relation.Node2 == relationFixture.To && relation.Type == relationFixture.Relation { found = true } } if !found { t.Fatal("verified relation neighbor was not expanded") } }