Pulse/internal/ai/findings_lifecycle_test.go
rcourtman 731d25516e Clear stale acknowledgements on Patrol regressions
The shared findings lifecycle now clears prior acknowledgement state when a resolved finding regresses, records that prior acknowledgement in regression metadata, and documents the contract in ai-runtime.
2026-03-25 17:36:31 +00:00

177 lines
4.9 KiB
Go

package ai
import "testing"
func TestFindingsStore_AddRecordsDetectedLifecycleEvent(t *testing.T) {
store := NewFindingsStore()
f := &Finding{
ID: "lf-1",
ResourceID: "node-1",
ResourceName: "node-1",
Severity: FindingSeverityWarning,
Category: FindingCategoryPerformance,
Title: "High CPU usage trend",
Description: "CPU trend indicates sustained pressure",
}
if !store.Add(f) {
t.Fatal("expected first add to create a finding")
}
got := store.Get("lf-1")
if got == nil {
t.Fatal("expected finding to exist")
}
if got.TimesRaised != 1 {
t.Fatalf("expected timesRaised=1, got %d", got.TimesRaised)
}
if len(got.Lifecycle) == 0 {
t.Fatal("expected lifecycle events to be recorded")
}
last := got.Lifecycle[len(got.Lifecycle)-1]
if last.Type != "detected" {
t.Fatalf("expected last lifecycle event type=detected, got %q", last.Type)
}
}
func TestFindingsStore_RegressionIncrementsAndRecordsLifecycleEvent(t *testing.T) {
store := NewFindingsStore()
f := &Finding{
ID: "lf-2",
ResourceID: "vm-101",
ResourceName: "web",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
Title: "Restart loop",
Description: "Service repeatedly restarts",
}
if !store.Add(f) {
t.Fatal("expected first add to create finding")
}
if !store.Resolve("lf-2", false) {
t.Fatal("expected resolve to succeed")
}
if store.Get("lf-2").RegressionCount != 0 {
t.Fatal("expected no regressions before re-detection")
}
if store.Add(&Finding{
ID: "lf-2",
ResourceID: "vm-101",
ResourceName: "web",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
Title: "Restart loop",
Description: "Service repeatedly restarts again",
}) {
t.Fatal("expected second add to update existing finding")
}
got := store.Get("lf-2")
if got == nil {
t.Fatal("expected finding to exist")
}
if got.RegressionCount != 1 {
t.Fatalf("expected regressionCount=1, got %d", got.RegressionCount)
}
if got.LastRegressionAt == nil {
t.Fatal("expected lastRegressionAt to be set")
}
if got.AcknowledgedAt != nil {
t.Fatal("expected acknowledgement to clear when the finding regresses")
}
foundRegressed := false
for _, e := range got.Lifecycle {
if e.Type == "regressed" {
foundRegressed = true
break
}
}
if !foundRegressed {
t.Fatal("expected regressed lifecycle event")
}
}
func TestFindingsStore_RegressionClearsPriorAcknowledgement(t *testing.T) {
store := NewFindingsStore()
f := &Finding{
ID: "lf-ack-regress",
ResourceID: "vm-202",
ResourceName: "api",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
Title: "Restart loop",
Description: "Service repeatedly restarts",
}
if !store.Add(f) {
t.Fatal("expected first add to create finding")
}
if !store.Acknowledge(f.ID) {
t.Fatal("expected acknowledge to succeed")
}
if !store.Resolve(f.ID, true) {
t.Fatal("expected resolve to succeed")
}
if store.Add(&Finding{
ID: f.ID,
ResourceID: "vm-202",
ResourceName: "api",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
Title: "Restart loop",
Description: "Service repeatedly restarts again",
}) {
t.Fatal("expected second add to update existing finding")
}
got := store.Get(f.ID)
if got == nil {
t.Fatal("expected finding to exist")
}
if got.AcknowledgedAt != nil {
t.Fatal("expected acknowledgement to clear after regression")
}
foundRegressed := false
for _, e := range got.Lifecycle {
if e.Type == "regressed" {
if e.Metadata["previous_acknowledged"] != "true" {
t.Fatal("expected regressed lifecycle event to note prior acknowledgement")
}
foundRegressed = true
break
}
}
if !foundRegressed {
t.Fatal("expected regressed lifecycle event")
}
}
func TestFindingsStore_BlocksInvalidLoopStateTransition(t *testing.T) {
store := NewFindingsStore()
f := &Finding{
ID: "lf-3",
ResourceID: "ct-1",
ResourceName: "container-1",
Severity: FindingSeverityWarning,
Category: FindingCategoryPerformance,
Title: "CPU burst",
Description: "Unexpected sustained CPU burst",
LoopState: string(FindingLoopStateResolved),
InvestigationOutcome: string(InvestigationOutcomeFixExecuted), // would derive to remediating
}
// Directly call lock-only helper to validate transition guard behavior.
store.mu.Lock()
store.syncLoopStateLocked(f)
store.mu.Unlock()
if f.LoopState != string(FindingLoopStateResolved) {
t.Fatalf("expected loop state to remain resolved, got %q", f.LoopState)
}
if len(f.Lifecycle) == 0 {
t.Fatal("expected lifecycle event for blocked transition")
}
last := f.Lifecycle[len(f.Lifecycle)-1]
if last.Type != "loop_transition_violation" {
t.Fatalf("expected loop_transition_violation, got %q", last.Type)
}
}