package engine import ( "testing" "github.com/local/glpi-neural-brain/internal/config" "github.com/local/glpi-neural-brain/internal/model" ) func TestDetectArticleFreshnessNeedStaticTopicDoesNotForceWeb(t *testing.T) { plan := model.ArticlePlanDecision{ExpectedValue: "Least Privilege in Windows-Netzwerken konfigurieren", ArticleType: "how_to"} relation := model.RelationDecision{TopicLabel: "Least Privilege", Keywords: []string{"Windows", "Berechtigungen"}} got := detectArticleFreshnessNeed(plan, relation, []articleSource{{Node: model.Node{Label: "Least Privilege Grundlagen"}}}) if got.Required { t.Fatalf("static configuration topic must not force web research: %+v", got) } } func TestDetectArticleFreshnessNeedCurrentVersionForcesWeb(t *testing.T) { plan := model.ArticlePlanDecision{ExpectedValue: "Aktuell unterstützte Kubernetes Versionen und Supportstatus"} got := detectArticleFreshnessNeed(plan, model.RelationDecision{}, nil) if !got.Required || len(got.Queries) == 0 { t.Fatalf("freshness-sensitive topic should trigger focused web research: %+v", got) } } func TestEffectiveArticleResearchStrategyAutoFollowsProcessingMode(t *testing.T) { e := &Engine{Cfg: config.Config{ArticleResearchStrategy: "auto"}} e.runtime.ProcessingMode = "clustered" if got := e.effectiveArticleResearchStrategy(); got != "adaptive" { t.Fatalf("clustered auto strategy=%q", got) } e.runtime.ProcessingMode = "precise" if got := e.effectiveArticleResearchStrategy(); got != "always" { t.Fatalf("precise auto strategy=%q", got) } } func TestNormalizeArticleContentClearsUnusedResearchQueries(t *testing.T) { got := normalizeArticleContent(model.KnowledgeArticleContent{ResearchNeeded: false, ResearchQueries: []string{"current version"}, ResearchReason: "not needed"}) if len(got.ResearchQueries) != 0 { t.Fatalf("unused author research queries must be cleared: %#v", got.ResearchQueries) } } func TestArticleSourceFingerprintChangesWithContent(t *testing.T) { plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: "target", ArticleType: "how_to"} a := []articleSource{{Node: model.Node{ID: "A"}, Content: "old content"}} b := []articleSource{{Node: model.Node{ID: "A"}, Content: "new content"}} if articleSourceFingerprint(a, plan, nil, "test-pipeline") == articleSourceFingerprint(b, plan, nil, "test-pipeline") { t.Fatal("source fingerprint must change when source content changes") } } func TestReviewReferenceUsesExactReviewerSubset(t *testing.T) { full := []model.ResearchResult{ {Title: "weak", URL: "https://a.example/weak", Relevance: .1, SourceQualityScore: .1}, {Title: "best", URL: "https://b.example/best", Relevance: .99, SourceQualityScore: .99, Fetched: true}, {Title: "other", URL: "https://c.example/other", Relevance: .8, SourceQualityScore: .8, Fetched: true}, } subset := selectReviewEvidence(full, 2) if len(subset) != 2 || subset[0].URL != "https://b.example/best" { t.Fatalf("unexpected reviewer subset: %#v", subset) } grounded := reviewedResearchEvidence(subset, []model.ArticleClaimReview{{Claim: "x", Verdict: "supported", SourceRefs: []string{"R1"}}}) if len(grounded) != 1 || grounded[0].URL != "https://b.example/best" { t.Fatalf("R1 must resolve against the exact reviewer subset, got %#v", grounded) } } func TestClusterPendingArticleCandidatesGroupsSharedSeed(t *testing.T) { shared := model.Node{ID: "shared", Label: "Backup Repository", Categories: []string{"Backup"}} a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "a", Label: "Ransomware Schutz"}}, Relation: model.RelationDecision{TopicLabel: "Backup Hardening"}} b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "b", Label: "Immutable Backup"}}, Relation: model.RelationDecision{TopicLabel: "Backup Resilience"}} clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b}) if len(clusters) != 1 || len(clusters[0]) != 2 { t.Fatalf("shared seed should produce one article cluster: %#v", clusters) } } func TestClusterPendingArticleCandidatesDoesNotLetSharedGenericSeedBypassTopicGuard(t *testing.T) { shared := model.Node{ID: "shared", Label: "Security-Handbuch Übersicht"} a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "water", Label: "Water Leak Detection"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection"}} b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "bgp", Label: "BGP Prefix Filtering"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering"}} clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b}) if len(clusters) != 2 { t.Fatalf("shared generic seed must not bypass strict topic guard, got %#v", clusters) } } func TestArticleWorkFingerprintChangesWhenGroundedResearchChanges(t *testing.T) { sources := []articleSource{{Node: model.Node{ID: "A"}, Content: "stable internal"}} relation := model.RelationDecision{RelationType: "same_topic", TopicLabel: "Backup Hardening", Keywords: []string{"backup"}} a := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "old external evidence"}}, "test-pipeline") b := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "new external evidence"}}, "test-pipeline") if a == b { t.Fatal("new grounded research must invalidate the work fingerprint") } } func TestClusterPendingArticleCandidatesDoesNotMergeSecurityBoilerplateTopics(t *testing.T) { values := []*pendingArticleCandidate{ {Seeds: []model.Node{{ID: "water-a", Label: "Water Leak Detection – präventiv absichern"}, {ID: "water-b", Label: "Water Leak Detection – Vorfälle erkennen und untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection Sicherheitsmaßnahmen"}}, {Seeds: []model.Node{{ID: "triple-a", Label: "Triple Extortion Risiko – präventiv und resilient gestalten"}, {ID: "triple-b", Label: "Triple Extortion Risiko – incident-forensisch untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Triple Extortion Risiko Sicherheitsmaßnahmen"}}, {Seeds: []model.Node{{ID: "bgp-a", Label: "BGP Prefix Filtering – sicher gestalten und härten"}, {ID: "bgp-b", Label: "BGP Prefix Filtering – bei Sicherheitsvorfällen untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering Sicherheitsmaßnahmen"}}, } clusters := clusterPendingArticleCandidates(values) if len(clusters) != 3 { t.Fatalf("unrelated template-heavy security topics must remain separate, got %d clusters: %#v", len(clusters), clusters) } } func TestClusterPendingArticleCandidatesMergesStrongTopicOverlap(t *testing.T) { a := &pendingArticleCandidate{Seeds: []model.Node{{ID: "a1", Label: "AI Security Guardrails – härten"}}, Relation: model.RelationDecision{TopicLabel: "AI Security Guardrails"}} b := &pendingArticleCandidate{Seeds: []model.Node{{ID: "b1", Label: "AI Safety Guardrails – überwachen"}}, Relation: model.RelationDecision{TopicLabel: "AI Safety Guardrails"}} clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b}) if len(clusters) != 1 { t.Fatalf("strong topic/entity overlap should still batch, got %#v", clusters) } } func TestArticleDraftTopicConflictGroupsRejectsObservedWaterLeakMix(t *testing.T) { draft := model.KnowledgeArticleDraft{Title: "Water Leak Detection in der IT-Security"} sources := []articleSource{ {Node: model.Node{ID: "w1", Label: "Water Leak Detection – präventiv absichern"}}, {Node: model.Node{ID: "w2", Label: "Water Leak Detection – Vorfälle erkennen und untersuchen"}}, {Node: model.Node{ID: "t1", Label: "Triple Extortion Risiko – präventiv und resilient gestalten"}}, {Node: model.Node{ID: "t2", Label: "Triple Extortion Risiko – incident-forensisch untersuchen"}}, {Node: model.Node{ID: "b1", Label: "BGP Prefix Filtering – sicher entwerfen und härten"}}, {Node: model.Node{ID: "b2", Label: "BGP Prefix Filtering – bei Sicherheitsvorfällen untersuchen"}}, } conflicts := articleDraftTopicConflictGroups(draft, sources) if len(conflicts) != 2 { t.Fatalf("expected Triple Extortion and BGP conflict groups, got %#v", conflicts) } } func TestArticleDraftTopicConflictGroupsAllowsSingleSupportingOutlier(t *testing.T) { draft := model.KnowledgeArticleDraft{Title: "HAProxy Hardening und Überwachung"} sources := []articleSource{ {Node: model.Node{ID: "h1", Label: "HAProxy Hardening – präventiv absichern"}}, {Node: model.Node{ID: "h2", Label: "HAProxy Hardening – forensisch untersuchen"}}, {Node: model.Node{ID: "tls", Label: "TLS Cipher Suites Referenz"}}, } if conflicts := articleDraftTopicConflictGroups(draft, sources); len(conflicts) != 0 { t.Fatalf("one supporting outlier must not reject a coherent article: %#v", conflicts) } } func TestArticleTopicTermsIgnoreTemplatedSuffix(t *testing.T) { a := articleTopicTermsFromText("Rate Limit Testing – Ergebnisse verifizieren") b := articleTopicTermsFromText("Purple Team Lessons Learned – Ergebnisse verifizieren") if a["ergebnisse"] || a["verifizieren"] || b["ergebnisse"] || b["verifizieren"] { t.Fatalf("templated suffix leaked into topic terms: a=%v b=%v", a, b) } if boolSetIntersectionSize(a, b) != 0 { t.Fatalf("unrelated topics became related through suffix terms: a=%v b=%v", a, b) } } func TestFilterTopicCoherentArticleSourcesKeepsOnlyOneSupportingOutlier(t *testing.T) { sources := []articleSource{ {Node: model.Node{ID: "r1", Label: "Rate Limit Testing – planen"}, Score: 10}, {Node: model.Node{ID: "r2", Label: "Rate Limit Testing – verifizieren"}, Score: 9}, {Node: model.Node{ID: "w1", Label: "Web Security Testing – planen"}, Score: 8.5}, {Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned – planen"}, Score: 8}, {Node: model.Node{ID: "s1", Label: "Security Test Reporting – dokumentieren"}, Score: 7}, } kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}) if len(kept) != 3 || len(removed) != 2 { t.Fatalf("expected two topical sources plus one outlier; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed)) } if kept[2].Node.ID != "w1" || removed[0].Node.ID != "p1" || removed[1].Node.ID != "s1" { t.Fatalf("highest-scoring outlier should be retained, kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed)) } } func TestArticleSourceIDJaccardMatchesObservedOverlap(t *testing.T) { wanted := map[string]bool{"a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true, "h": true} existing := []string{"a", "b", "c", "d", "e", "f", "x", "y"} if got := articleSourceIDJaccard(wanted, existing); got != .6 { t.Fatalf("expected 0.6 source jaccard, got %.4f", got) } } func TestArticlePlanTopicTermsPreferMergeTarget(t *testing.T) { plan := model.ArticlePlanDecision{Action: "merge", TargetArticleID: "target", ExpectedValue: "Generischer Security-Artikel"} sources := []articleSource{ {Node: model.Node{ID: "target", Label: "Ransomware Containment – im Vorfall erkennen"}}, {Node: model.Node{ID: "other", Label: "Ransomware Detection – präventiv gestalten"}}, } terms := articlePlanTopicTerms(plan, sources) if !terms["ransomware"] || !terms["containment"] || terms["security"] { t.Fatalf("merge target should define the core topic, got %v", terms) } } func TestArticleDirectTopicSourceCountDoesNotCountSupportingOutlier(t *testing.T) { sources := []articleSource{ {Node: model.Node{ID: "r1", Label: "Rate Limit Testing – planen"}}, {Node: model.Node{ID: "r2", Label: "Rate Limit Testing – verifizieren"}}, {Node: model.Node{ID: "w1", Label: "Web Security Testing – planen"}}, {Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned – planen"}}, } if got := articleDirectTopicSourceCount(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 { t.Fatalf("supporting outlier must not satisfy direct topic evidence, got %d", got) } } func TestArticlePlanOperationalResearchQueryUsesTopicAndTaskType(t *testing.T) { plan := model.ArticlePlanDecision{ArticleType: "how_to", ExpectedValue: "Generischer Plan"} got := articlePlanOperationalResearchQuery(plan, model.RelationDecision{TopicLabel: "Rate Limit Testing"}) if got == "" || got[:18] != "Rate Limit Testing" { t.Fatalf("query must start from relation topic, got %q", got) } trouble := articlePlanOperationalResearchQuery(model.ArticlePlanDecision{ArticleType: "troubleshooting"}, model.RelationDecision{TopicLabel: "Kafka Netzwerkzugriff"}) if trouble == "" || trouble == got { t.Fatalf("troubleshooting query should be task-specific, got %q", trouble) } } func TestObservedRateLimitClusterIsReducedToDirectTopicEvidence(t *testing.T) { sources := []articleSource{ {Node: model.Node{ID: "rate-verify", Label: "Rate Limit Testing – Ergebnisse verifizieren"}, Score: 10}, {Node: model.Node{ID: "report", Label: "Security Test Reporting – Ergebnisse verifizieren"}, Score: 9}, {Node: model.Node{ID: "web-plan", Label: "Web Security Testing – sicher planen und durchführen"}, Score: 8}, {Node: model.Node{ID: "purple-verify", Label: "Purple Team Lessons Learned – Ergebnisse verifizieren"}, Score: 7}, {Node: model.Node{ID: "purple-plan", Label: "Purple Team Lessons Learned – sicher planen und durchführen"}, Score: 6}, {Node: model.Node{ID: "rate-plan", Label: "Rate Limit Testing – sicher planen und durchführen"}, Score: 5}, {Node: model.Node{ID: "architecture", Label: "Security Architecture Testing – sicher planen und durchführen"}, Score: 4}, {Node: model.Node{ID: "web-verify", Label: "Web Security Testing – Ergebnisse verifizieren"}, Score: 3}, } kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}) if got := articleDirectTopicSourceCount(kept, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 { t.Fatalf("observed cluster should contain exactly two direct Rate Limit sources after filtering, got %d (%v)", got, nodeIDsFromArticleSources(kept)) } if len(kept) != 3 || len(removed) != 5 { t.Fatalf("observed 8-source cluster should become 2 direct + 1 support; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed)) } } func TestFilterTopicCoherentArticleSourcesKeepsRequiredAutonomousSeeds(t *testing.T) { sources := []articleSource{ {Node: model.Node{ID: "a", Kind: "knowledge", Status: "production", Label: "OWASP SAMM Governance"}}, {Node: model.Node{ID: "b", Kind: "knowledge", Status: "production", Label: "MITRE ATT&CK Mapping"}}, {Node: model.Node{ID: "c", Kind: "knowledge", Status: "production", Label: "Unrelated Proxmox Hardening"}}, } required := map[string]bool{"a": true, "b": true} kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Frameworks & Standards / Security Framework / Standard"}, required) if len(kept) != 3 { t.Fatalf("required seeds plus at most one supporting source should remain, got kept=%#v removed=%#v", kept, removed) } ids := map[string]bool{} for _, source := range kept { ids[source.Node.ID] = true } if !ids["a"] || !ids["b"] { t.Fatalf("required autonomous seeds must survive topic filtering: %#v", ids) } } func TestMergeRequiredArticleSourceIDsPrependsOpportunitySeeds(t *testing.T) { got := mergeRequiredArticleSourceIDs([]string{"c", "a"}, []string{"a", "b", "c", "d"}, map[string]bool{"a": true, "b": true}) want := []string{"a", "b", "c"} if len(got) != len(want) { t.Fatalf("unexpected merged ids: %#v", got) } for i := range want { if got[i] != want[i] { t.Fatalf("required source ordering mismatch: got %#v want %#v", got, want) } } }