Pulse/internal/ai/patrol_history_persistence_test.go
rcourtman fa1b74792e docs: add comprehensive deep-dive documentation for AI subsystems
Adds detailed architecture documentation for Pulse Patrol and Pulse Assistant. Updates AI.md and PULSE_PRO.md. Also includes additional tests.
2026-02-02 10:29:07 +00:00

520 lines
14 KiB
Go

package ai
import (
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// mockPatrolHistoryPersistence implements PatrolHistoryPersistence for testing
type mockPatrolHistoryPersistence struct {
mu sync.Mutex
runs []PatrolRunRecord
saveErr error
loadErr error
saveCalls atomic.Int32
loadCalls atomic.Int32
}
type errorPatrolHistoryPersistence struct {
err error
}
func (e *errorPatrolHistoryPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
return e.err
}
func (e *errorPatrolHistoryPersistence) LoadPatrolRunHistory() ([]PatrolRunRecord, error) {
return nil, nil
}
func (m *mockPatrolHistoryPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
m.saveCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.saveErr != nil {
return m.saveErr
}
m.runs = runs
return nil
}
func (m *mockPatrolHistoryPersistence) LoadPatrolRunHistory() ([]PatrolRunRecord, error) {
m.loadCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.loadErr != nil {
return nil, m.loadErr
}
return m.runs, nil
}
func TestNewPatrolRunHistoryStore(t *testing.T) {
// Test with positive maxRuns
store := NewPatrolRunHistoryStore(50)
if store == nil {
t.Fatal("Expected non-nil store")
}
if store.maxRuns != 50 {
t.Errorf("Expected maxRuns=50, got %d", store.maxRuns)
}
// Test with zero maxRuns (should use default)
storeDefault := NewPatrolRunHistoryStore(0)
if storeDefault.maxRuns != MaxPatrolRunHistory {
t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeDefault.maxRuns)
}
// Test with negative maxRuns (should use default)
storeNegative := NewPatrolRunHistoryStore(-10)
if storeNegative.maxRuns != MaxPatrolRunHistory {
t.Errorf("Expected maxRuns=%d (default), got %d", MaxPatrolRunHistory, storeNegative.maxRuns)
}
}
func TestPatrolRunHistoryStore_Add(t *testing.T) {
store := NewPatrolRunHistoryStore(3)
run1 := PatrolRunRecord{ID: "run-1", StartedAt: time.Now()}
run2 := PatrolRunRecord{ID: "run-2", StartedAt: time.Now()}
run3 := PatrolRunRecord{ID: "run-3", StartedAt: time.Now()}
run4 := PatrolRunRecord{ID: "run-4", StartedAt: time.Now()}
store.Add(run1)
if store.Count() != 1 {
t.Errorf("Expected count=1, got %d", store.Count())
}
store.Add(run2)
store.Add(run3)
if store.Count() != 3 {
t.Errorf("Expected count=3, got %d", store.Count())
}
// Adding 4th run should trim to maxRuns
store.Add(run4)
if store.Count() != 3 {
t.Errorf("Expected count=3 (trimmed), got %d", store.Count())
}
// Newest should be first
runs := store.GetAll()
if runs[0].ID != "run-4" {
t.Errorf("Expected newest run first, got %s", runs[0].ID)
}
}
func TestPatrolRunHistoryStore_GetAll(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
// Empty store
runs := store.GetAll()
if len(runs) != 0 {
t.Errorf("Expected empty slice, got %d runs", len(runs))
}
// Add runs
store.Add(PatrolRunRecord{ID: "run-1"})
store.Add(PatrolRunRecord{ID: "run-2"})
runs = store.GetAll()
if len(runs) != 2 {
t.Errorf("Expected 2 runs, got %d", len(runs))
}
// Verify it returns a copy (modification shouldn't affect store)
runs[0].ID = "modified"
storedRuns := store.GetAll()
if storedRuns[0].ID == "modified" {
t.Error("GetAll should return a copy, not the original slice")
}
}
func TestPatrolRunHistoryStore_GetRecent(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.Add(PatrolRunRecord{ID: "run-1"})
store.Add(PatrolRunRecord{ID: "run-2"})
store.Add(PatrolRunRecord{ID: "run-3"})
// Get 2 recent
runs := store.GetRecent(2)
if len(runs) != 2 {
t.Errorf("Expected 2 runs, got %d", len(runs))
}
if runs[0].ID != "run-3" {
t.Errorf("Expected run-3 first, got %s", runs[0].ID)
}
// Get more than available
runsAll := store.GetRecent(10)
if len(runsAll) != 3 {
t.Errorf("Expected 3 runs (all available), got %d", len(runsAll))
}
// Get 0 or negative
runsZero := store.GetRecent(0)
if len(runsZero) != 3 {
t.Errorf("Expected 3 runs for n=0, got %d", len(runsZero))
}
runsNeg := store.GetRecent(-5)
if len(runsNeg) != 3 {
t.Errorf("Expected 3 runs for n=-5, got %d", len(runsNeg))
}
}
func TestPatrolRunHistoryStore_Count(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
if store.Count() != 0 {
t.Errorf("Expected count=0, got %d", store.Count())
}
store.Add(PatrolRunRecord{ID: "run-1"})
if store.Count() != 1 {
t.Errorf("Expected count=1, got %d", store.Count())
}
store.Add(PatrolRunRecord{ID: "run-2"})
store.Add(PatrolRunRecord{ID: "run-3"})
if store.Count() != 3 {
t.Errorf("Expected count=3, got %d", store.Count())
}
}
func TestPatrolRunHistoryStore_SetPersistence(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
// Create mock with existing runs
mockPersistence := &mockPatrolHistoryPersistence{
runs: []PatrolRunRecord{
{ID: "persisted-1"},
{ID: "persisted-2"},
},
}
err := store.SetPersistence(mockPersistence)
if err != nil {
t.Fatalf("SetPersistence failed: %v", err)
}
// Should have loaded runs
if store.Count() != 2 {
t.Errorf("Expected 2 runs loaded, got %d", store.Count())
}
runs := store.GetAll()
if runs[0].ID != "persisted-1" {
t.Errorf("Expected persisted-1, got %s", runs[0].ID)
}
}
func TestPatrolRunHistoryStore_PersistenceStatusAndErrors(t *testing.T) {
store := NewPatrolRunHistoryStore(5)
store.saveDebounce = 0
saveErr := errors.New("save failed")
errPersistence := &errorPatrolHistoryPersistence{err: saveErr}
if err := store.SetPersistence(errPersistence); err != nil {
t.Fatalf("SetPersistence failed: %v", err)
}
errCh := make(chan error, 1)
store.SetOnSaveError(func(err error) {
errCh <- err
})
store.Add(PatrolRunRecord{ID: "run-1"})
select {
case err := <-errCh:
if err == nil || err.Error() != "save failed" {
t.Fatalf("unexpected error callback: %v", err)
}
case <-time.After(1 * time.Second):
t.Fatal("timed out waiting for save error callback")
}
lastErr, lastSaveTime, hasPersistence := store.GetPersistenceStatus()
if lastErr == nil {
t.Fatal("expected last save error to be recorded")
}
if !lastSaveTime.IsZero() {
t.Fatalf("expected last save time to be zero on failure, got %v", lastSaveTime)
}
if !hasPersistence {
t.Fatal("expected persistence to be configured")
}
}
func TestConvertPatrolToolCalls(t *testing.T) {
aiCalls := []ToolCallRecord{
{
ID: "call-1",
ToolName: "pulse_query",
Input: `{"action":"get"}`,
Output: `{"status":"ok"}`,
Success: true,
StartTime: 123,
EndTime: 456,
Duration: 333,
},
}
cfgCalls := convertAIToolCallsToConfig(aiCalls)
if len(cfgCalls) != 1 {
t.Fatalf("expected 1 config tool call, got %d", len(cfgCalls))
}
if cfgCalls[0].ToolName != "pulse_query" || cfgCalls[0].Duration != 333 {
t.Fatalf("unexpected config tool call: %+v", cfgCalls[0])
}
roundTrip := convertConfigToolCallsToAI(cfgCalls)
if len(roundTrip) != 1 {
t.Fatalf("expected 1 AI tool call, got %d", len(roundTrip))
}
if roundTrip[0].ID != "call-1" || roundTrip[0].Output != `{"status":"ok"}` {
t.Fatalf("unexpected AI tool call: %+v", roundTrip[0])
}
}
func TestPatrolRunHistoryStore_SetPersistence_Nil(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
// Setting nil persistence should not error
err := store.SetPersistence(nil)
if err != nil {
t.Fatalf("SetPersistence(nil) should not error: %v", err)
}
}
func TestPatrolRunHistoryStore_SetPersistence_TrimToMax(t *testing.T) {
store := NewPatrolRunHistoryStore(2) // Only allow 2 runs
// Create mock with more runs than maxRuns
mockPersistence := &mockPatrolHistoryPersistence{
runs: []PatrolRunRecord{
{ID: "run-1"},
{ID: "run-2"},
{ID: "run-3"},
{ID: "run-4"},
},
}
err := store.SetPersistence(mockPersistence)
if err != nil {
t.Fatalf("SetPersistence failed: %v", err)
}
// Should have trimmed to max
if store.Count() != 2 {
t.Errorf("Expected 2 runs (trimmed), got %d", store.Count())
}
}
func TestPatrolRunHistoryStore_SetPersistence_LoadError(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
mockPersistence := &mockPatrolHistoryPersistence{
loadErr: errors.New("load failed"),
}
err := store.SetPersistence(mockPersistence)
if err == nil {
t.Fatal("expected error from SetPersistence when load fails")
}
}
func TestPatrolRunHistoryStore_FlushPersistence(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
mockPersistence := &mockPatrolHistoryPersistence{}
store.SetPersistence(mockPersistence)
store.Add(PatrolRunRecord{ID: "run-1"})
store.Add(PatrolRunRecord{ID: "run-2"})
err := store.FlushPersistence()
if err != nil {
t.Fatalf("FlushPersistence failed: %v", err)
}
// Should have saved
if len(mockPersistence.runs) != 2 {
t.Errorf("Expected 2 runs saved, got %d", len(mockPersistence.runs))
}
}
func TestPatrolRunHistoryStore_FlushPersistence_NoPersistence(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.Add(PatrolRunRecord{ID: "run-1"})
// Should not error when no persistence is set
err := store.FlushPersistence()
if err != nil {
t.Fatalf("FlushPersistence without persistence should not error: %v", err)
}
}
func TestPatrolRunHistoryStore_ScheduleSave(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.saveDebounce = 50 * time.Millisecond // Short debounce for testing
mockPersistence := &mockPatrolHistoryPersistence{}
store.SetPersistence(mockPersistence)
mockPersistence.saveCalls.Store(0) // Reset after SetPersistence load
// Add a run (triggers scheduleSave)
store.Add(PatrolRunRecord{ID: "run-1"})
// Wait for debounce
time.Sleep(100 * time.Millisecond)
if mockPersistence.saveCalls.Load() < 1 {
t.Errorf("Expected at least 1 save call after debounce, got %d", mockPersistence.saveCalls.Load())
}
}
func TestPatrolRunHistoryStore_ScheduleSave_StopsExistingTimer(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.saveDebounce = 50 * time.Millisecond
mockPersistence := &mockPatrolHistoryPersistence{}
store.SetPersistence(mockPersistence)
mockPersistence.saveCalls.Store(0)
store.Add(PatrolRunRecord{ID: "run-1"})
store.Add(PatrolRunRecord{ID: "run-2"})
time.Sleep(120 * time.Millisecond)
if mockPersistence.saveCalls.Load() != 1 {
t.Errorf("expected 1 save call after reschedule, got %d", mockPersistence.saveCalls.Load())
}
}
func TestPatrolRunHistoryStore_ScheduleSave_Cancelled(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.saveDebounce = 50 * time.Millisecond
mockPersistence := &mockPatrolHistoryPersistence{}
store.SetPersistence(mockPersistence)
mockPersistence.saveCalls.Store(0)
store.Add(PatrolRunRecord{ID: "run-1"})
store.mu.Lock()
store.savePending = false
store.mu.Unlock()
time.Sleep(120 * time.Millisecond)
if mockPersistence.saveCalls.Load() != 0 {
t.Errorf("expected no save calls after cancellation, got %d", mockPersistence.saveCalls.Load())
}
}
func TestPatrolRunHistoryStore_ScheduleSave_Error(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
store.saveDebounce = 50 * time.Millisecond
mockPersistence := &mockPatrolHistoryPersistence{saveErr: errors.New("save failed")}
store.SetPersistence(mockPersistence)
mockPersistence.saveCalls.Store(0)
store.Add(PatrolRunRecord{ID: "run-1"})
time.Sleep(120 * time.Millisecond)
if mockPersistence.saveCalls.Load() < 1 {
t.Errorf("expected save to be attempted, got %d calls", mockPersistence.saveCalls.Load())
}
}
func TestPatrolRunHistoryStore_ScheduleSave_NoPersistence(t *testing.T) {
store := NewPatrolRunHistoryStore(10)
// Add without persistence - should not panic
store.Add(PatrolRunRecord{ID: "run-1"})
// Give time for any potential async operation
time.Sleep(10 * time.Millisecond)
// No error or panic is success
}
func TestPatrolHistoryPersistenceAdapter_SaveAndLoad(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewPatrolHistoryPersistenceAdapter(persistence)
if adapter == nil {
t.Fatal("expected adapter to be created")
}
runs := []PatrolRunRecord{
{
ID: "run-1",
StartedAt: time.Now().Add(-2 * time.Minute),
CompletedAt: time.Now().Add(-1 * time.Minute),
Duration: time.Minute,
Type: "manual",
ResourcesChecked: 10,
NodesChecked: 2,
GuestsChecked: 5,
DockerChecked: 1,
StorageChecked: 1,
HostsChecked: 0,
PBSChecked: 0,
NewFindings: 1,
ExistingFindings: 2,
ResolvedFindings: 1,
AutoFixCount: 0,
FindingsSummary: "summary",
FindingIDs: []string{"f1", "f2"},
ErrorCount: 0,
Status: "ok",
AIAnalysis: "analysis",
InputTokens: 100,
OutputTokens: 200,
},
}
if err := adapter.SavePatrolRunHistory(runs); err != nil {
t.Fatalf("save failed: %v", err)
}
loaded, err := adapter.LoadPatrolRunHistory()
if err != nil {
t.Fatalf("load failed: %v", err)
}
if len(loaded) != 1 || loaded[0].ID != "run-1" {
t.Fatalf("unexpected loaded runs: %+v", loaded)
}
if loaded[0].Duration != runs[0].Duration {
t.Fatalf("expected duration %v, got %v", runs[0].Duration, loaded[0].Duration)
}
}
func TestPatrolHistoryPersistenceAdapter_LoadError(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
adapter := NewPatrolHistoryPersistenceAdapter(persistence)
badPath := filepath.Join(tmp, "ai_patrol_runs.json")
if err := os.Mkdir(badPath, 0700); err != nil {
t.Fatalf("failed to create directory at %s: %v", badPath, err)
}
if _, err := adapter.LoadPatrolRunHistory(); err == nil {
t.Fatal("expected error when patrol runs path is a directory")
}
}