Pulse/internal/ai/cost_persistence_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

126 lines
3.3 KiB
Go

package ai
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestNewCostPersistenceAdapter(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewCostPersistenceAdapter(persistence)
if adapter == nil {
t.Fatal("expected non-nil adapter")
}
if adapter.config != persistence {
t.Fatal("expected config to match persistence")
}
}
func TestCostPersistenceAdapter_SaveAndLoad(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewCostPersistenceAdapter(persistence)
events := []cost.UsageEvent{
{
Timestamp: time.Now(),
Provider: "openai",
RequestModel: "gpt-4",
ResponseModel: "gpt-4",
UseCase: "patrol",
InputTokens: 100,
OutputTokens: 50,
TargetType: "vm",
TargetID: "node1-100",
FindingID: "finding-123",
},
{
Timestamp: time.Now().Add(-time.Hour),
Provider: "anthropic",
RequestModel: "claude-3-sonnet",
ResponseModel: "claude-3-sonnet",
UseCase: "chat",
InputTokens: 200,
OutputTokens: 100,
TargetType: "container",
TargetID: "node1-101",
FindingID: "",
},
}
// Save events
err := adapter.SaveUsageHistory(events)
if err != nil {
t.Fatalf("failed to save usage history: %v", err)
}
// Load events back
loaded, err := adapter.LoadUsageHistory()
if err != nil {
t.Fatalf("failed to load usage history: %v", err)
}
if len(loaded) != len(events) {
t.Fatalf("expected %d events, got %d", len(events), len(loaded))
}
// Verify first event
if loaded[0].Provider != events[0].Provider {
t.Errorf("expected provider %q, got %q", events[0].Provider, loaded[0].Provider)
}
if loaded[0].InputTokens != events[0].InputTokens {
t.Errorf("expected input tokens %d, got %d", events[0].InputTokens, loaded[0].InputTokens)
}
if loaded[0].UseCase != events[0].UseCase {
t.Errorf("expected use case %q, got %q", events[0].UseCase, loaded[0].UseCase)
}
}
func TestCostPersistenceAdapter_LoadEmpty(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewCostPersistenceAdapter(persistence)
// Load from empty persistence should return empty slice, not error
loaded, err := adapter.LoadUsageHistory()
if err != nil {
t.Fatalf("expected no error for empty load, got: %v", err)
}
if len(loaded) != 0 {
t.Fatalf("expected empty slice, got %d events", len(loaded))
}
}
func TestCostPersistenceAdapter_SaveEmpty(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewCostPersistenceAdapter(persistence)
// Save empty slice should work
err := adapter.SaveUsageHistory([]cost.UsageEvent{})
if err != nil {
t.Fatalf("failed to save empty usage history: %v", err)
}
}
func TestCostPersistenceAdapter_LoadError(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewCostPersistenceAdapter(persistence)
badPath := filepath.Join(tmp, "ai_usage_history.json")
if err := os.Mkdir(badPath, 0700); err != nil {
t.Fatalf("failed to create directory at %s: %v", badPath, err)
}
if _, err := adapter.LoadUsageHistory(); err == nil {
t.Fatal("expected error when usage history path is a directory")
}
}