Pulse/internal/ai/patterns/detector_coverage_test.go
rcourtman 053a40d7df fix: Docker container update detection showing false positives
Fixed an issue where all Docker containers were showing 'click to update'
even when they were up to date. The root cause was comparing the wrong
digest types:

- Previously: Compared ImageID (local config hash) vs registry manifest digest
- Now: Uses RepoDigests from image inspect, which is the actual manifest
  digest that Docker received from the registry when pulling the image

For multi-arch images, the registry returns a manifest list digest, while
Docker stores the platform-specific image config digest locally. These
will never match, causing false positives for all multi-arch images.

Changes:
- Added ImageInspectWithRaw to dockerClient interface
- Added getImageRepoDigest method to extract RepoDigest from image
- Added matchesImageReference helper for Docker Hub naming conventions
- Added tests for matchesImageReference

Fixes #955
2025-12-29 13:49:04 +00:00

386 lines
9.5 KiB
Go

package patterns
import (
"encoding/json"
"math"
"os"
"path/filepath"
"testing"
"time"
)
func TestNewDetector_LoadFromDiskSuccess(t *testing.T) {
tmpDir := t.TempDir()
now := time.Now()
data := struct {
Events []HistoricalEvent `json:"events"`
Patterns map[string]*Pattern `json:"patterns"`
}{
Events: []HistoricalEvent{
{
ID: "event-1",
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now,
},
},
Patterns: map[string]*Pattern{
"vm-1:high_cpu": {
ResourceID: "vm-1",
EventType: EventHighCPU,
Occurrences: 3,
LastOccurrence: now,
NextPredicted: now.Add(24 * time.Hour),
Confidence: 0.5,
},
},
}
blob, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal data: %v", err)
}
path := filepath.Join(tmpDir, "ai_patterns.json")
if err := os.WriteFile(path, blob, 0600); err != nil {
t.Fatalf("write data: %v", err)
}
d := NewDetector(DetectorConfig{DataDir: tmpDir})
if len(d.events) == 0 {
t.Fatal("expected events to be loaded")
}
if len(d.patterns) == 0 {
t.Fatal("expected patterns to be loaded")
}
}
func TestNewDetector_LoadFromDiskError(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "ai_patterns.json")
if err := os.WriteFile(path, []byte("{bad"), 0600); err != nil {
t.Fatalf("write invalid json: %v", err)
}
d := NewDetector(DetectorConfig{DataDir: tmpDir})
if len(d.events) != 0 {
t.Error("expected no events on load error")
}
}
func TestRecordEvent_SaveError(t *testing.T) {
tmpFile, err := os.CreateTemp("", "patterns-dir")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
path := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
t.Fatalf("close temp file: %v", err)
}
defer os.Remove(path)
d := NewDetector(DetectorConfig{DataDir: path, MinOccurrences: 1})
d.RecordEvent(HistoricalEvent{
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: time.Now(),
})
time.Sleep(20 * time.Millisecond)
}
func TestGetPredictions_FiltersAndSorts(t *testing.T) {
d := NewDetector(DetectorConfig{MinOccurrences: 2, PredictionLimit: 48 * time.Hour})
now := time.Now()
d.patterns["nil"] = nil
d.patterns["low-confidence"] = &Pattern{
ResourceID: "vm-1",
EventType: EventHighCPU,
Occurrences: 2,
Confidence: 0.2,
NextPredicted: now.Add(2 * time.Hour),
}
d.patterns["low-occurrences"] = &Pattern{
ResourceID: "vm-2",
EventType: EventHighMemory,
Occurrences: 1,
Confidence: 0.9,
NextPredicted: now.Add(2 * time.Hour),
}
d.patterns["past"] = &Pattern{
ResourceID: "vm-3",
EventType: EventDiskFull,
Occurrences: 2,
Confidence: 0.9,
NextPredicted: now.Add(-2 * time.Hour),
}
d.patterns["future"] = &Pattern{
ResourceID: "vm-4",
EventType: EventOOM,
Occurrences: 2,
Confidence: 0.9,
NextPredicted: now.Add(72 * time.Hour),
}
d.patterns["soon"] = &Pattern{
ResourceID: "vm-5",
EventType: EventRestart,
Occurrences: 2,
Confidence: 0.9,
NextPredicted: now.Add(4 * time.Hour),
}
d.patterns["later"] = &Pattern{
ResourceID: "vm-6",
EventType: EventUnresponsive,
Occurrences: 2,
Confidence: 0.9,
NextPredicted: now.Add(12 * time.Hour),
}
predictions := d.GetPredictions()
if len(predictions) != 2 {
t.Fatalf("expected 2 predictions, got %d", len(predictions))
}
if predictions[0].DaysUntil > predictions[1].DaysUntil {
t.Fatalf("expected predictions sorted by days until")
}
}
func TestGetPatterns_SkipsNil(t *testing.T) {
d := NewDetector(DefaultConfig())
d.patterns["nil"] = nil
d.patterns["ok"] = &Pattern{ResourceID: "vm-1", EventType: EventHighCPU}
result := d.GetPatterns()
if len(result) != 1 {
t.Fatalf("expected 1 pattern, got %d", len(result))
}
if result["ok"] == nil {
t.Fatal("expected non-nil pattern")
}
}
func TestComputePattern_AverageDuration(t *testing.T) {
d := NewDetector(DetectorConfig{MinOccurrences: 2, PatternWindow: 24 * time.Hour})
now := time.Now()
d.events = []HistoricalEvent{
{
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now.Add(-4 * time.Hour),
Duration: 10 * time.Minute,
},
{
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now.Add(-2 * time.Hour),
Duration: 20 * time.Minute,
},
{
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now,
},
}
pattern := d.computePattern("vm-1", EventHighCPU)
if pattern == nil {
t.Fatal("expected pattern")
}
if pattern.AverageDuration == 0 {
t.Fatal("expected average duration to be set")
}
}
func TestSaveToDisk_Errors(t *testing.T) {
tmpDir := t.TempDir()
d := NewDetector(DetectorConfig{DataDir: tmpDir})
d.patterns["bad"] = &Pattern{Confidence: math.NaN()}
if err := d.saveToDisk(); err == nil {
t.Fatal("expected json marshal error")
}
tmpFile, err := os.CreateTemp("", "patterns-dir")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
path := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
t.Fatalf("close temp file: %v", err)
}
defer os.Remove(path)
d = NewDetector(DetectorConfig{DataDir: path})
if err := d.saveToDisk(); err == nil {
t.Fatal("expected write error")
}
tmpDir = t.TempDir()
path = filepath.Join(tmpDir, "ai_patterns.json")
if err := os.Mkdir(path, 0700); err != nil {
t.Fatalf("create dir: %v", err)
}
d = NewDetector(DetectorConfig{DataDir: tmpDir})
if err := d.saveToDisk(); err == nil {
t.Fatal("expected rename error")
}
}
func TestLoadFromDisk_ErrorsAndPrune(t *testing.T) {
d := &Detector{}
if err := d.loadFromDisk(); err != nil {
t.Fatalf("expected empty dataDir to return nil, got %v", err)
}
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "ai_patterns.json")
blob := make([]byte, (10<<20)+1)
if err := os.WriteFile(path, blob, 0600); err != nil {
t.Fatalf("write large file: %v", err)
}
d = &Detector{dataDir: tmpDir}
if err := d.loadFromDisk(); err == nil {
t.Fatal("expected size error")
}
tmpDir = t.TempDir()
path = filepath.Join(tmpDir, "ai_patterns.json")
if err := os.Mkdir(path, 0700); err != nil {
t.Fatalf("create dir: %v", err)
}
d = &Detector{dataDir: tmpDir}
if err := d.loadFromDisk(); err == nil {
t.Fatal("expected read error")
}
tmpDir = t.TempDir()
path = filepath.Join(tmpDir, "ai_patterns.json")
if err := os.WriteFile(path, []byte("{bad"), 0600); err != nil {
t.Fatalf("write invalid json: %v", err)
}
d = &Detector{dataDir: tmpDir}
if err := d.loadFromDisk(); err == nil {
t.Fatal("expected json error")
}
tmpDir = t.TempDir()
now := time.Now()
data := struct {
Events []HistoricalEvent `json:"events"`
Patterns map[string]*Pattern `json:"patterns"`
}{
Events: []HistoricalEvent{
{
ID: "old",
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now.Add(-2 * time.Hour),
},
{
ID: "new",
ResourceID: "vm-1",
EventType: EventHighCPU,
Timestamp: now,
},
},
Patterns: map[string]*Pattern{
"old": {
ResourceID: "vm-1",
EventType: EventHighCPU,
Occurrences: 1,
LastOccurrence: now.Add(-2 * time.Hour),
Confidence: 0.5,
},
"nil": nil,
"new": {
ResourceID: "vm-1",
EventType: EventHighCPU,
Occurrences: 3,
LastOccurrence: now,
Confidence: 0.5,
},
},
}
blob, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal data: %v", err)
}
path = filepath.Join(tmpDir, "ai_patterns.json")
if err := os.WriteFile(path, blob, 0600); err != nil {
t.Fatalf("write data: %v", err)
}
d = &Detector{
dataDir: tmpDir,
maxEvents: 10,
minOccurrences: 2,
patternWindow: time.Hour,
}
if err := d.loadFromDisk(); err != nil {
t.Fatalf("loadFromDisk failed: %v", err)
}
if _, ok := d.patterns["old"]; ok {
t.Fatal("expected old pattern to be pruned")
}
if _, ok := d.patterns["nil"]; ok {
t.Fatal("expected nil pattern to be pruned")
}
if _, ok := d.patterns["new"]; !ok {
t.Fatal("expected new pattern to remain")
}
if len(d.events) != 1 {
t.Fatalf("expected trimmed events, got %d", len(d.events))
}
}
func TestFormatForContext_Limits(t *testing.T) {
d := NewDetector(DetectorConfig{MinOccurrences: 1, PredictionLimit: 365 * 24 * time.Hour})
now := time.Now()
for i := 0; i < 50; i++ {
key := "res-" + intToStr(i)
d.patterns[key] = &Pattern{
ResourceID: key,
EventType: EventHighCPU,
Occurrences: 3,
Confidence: 0.9,
LastOccurrence: now.Add(-24 * time.Hour),
NextPredicted: now.Add(2 * time.Hour),
}
}
result := d.FormatForContext("")
if result == "" {
t.Fatal("expected non-empty context")
}
if !contains(result, "... and more") {
t.Fatalf("expected truncation marker, got %q", result)
}
}
func TestFormatPatternBasis_EventNames(t *testing.T) {
now := time.Now()
tests := []struct {
eventType EventType
expected string
overdue bool
}{
{EventDiskFull, "disk space critical", true},
{EventUnresponsive, "unresponsive periods", false},
{EventBackupFailed, "backup failures", false},
}
for _, tt := range tests {
next := now.Add(2 * time.Hour)
if tt.overdue {
next = now.Add(-2 * time.Hour)
}
result := formatPatternBasis(&Pattern{
EventType: tt.eventType,
AverageInterval: 24 * time.Hour,
LastOccurrence: now.Add(-48 * time.Hour),
NextPredicted: next,
})
if !contains(result, tt.expected) {
t.Fatalf("expected %q in result, got %q", tt.expected, result)
}
if tt.overdue && !contains(result, "overdue") {
t.Fatalf("expected overdue wording, got %q", result)
}
}
}