package health import ( "context" "sync" "testing" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) // ── Mock Store ────────────────────────────── type mockStore struct { mu sync.Mutex windows map[string]*models.ProviderHealthWindow // keyed by provider_config_id } func newMockStore() *mockStore { return &mockStore{windows: make(map[string]*models.ProviderHealthWindow)} } func (m *mockStore) UpsertWindow(_ context.Context, w *models.ProviderHealthWindow) error { m.mu.Lock() defer m.mu.Unlock() existing, ok := m.windows[w.ProviderConfigID] if ok { existing.RequestCount += w.RequestCount existing.ErrorCount += w.ErrorCount existing.TimeoutCount += w.TimeoutCount existing.TotalLatencyMs += w.TotalLatencyMs if w.MaxLatencyMs > existing.MaxLatencyMs { existing.MaxLatencyMs = w.MaxLatencyMs } if w.LastError != nil { existing.LastError = w.LastError existing.LastErrorAt = w.LastErrorAt } } else { cp := *w m.windows[w.ProviderConfigID] = &cp } return nil } func (m *mockStore) GetCurrentWindow(_ context.Context, id string) (*models.ProviderHealthWindow, error) { m.mu.Lock() defer m.mu.Unlock() w, ok := m.windows[id] if !ok { return nil, nil } cp := *w return &cp, nil } func (m *mockStore) ListWindows(_ context.Context, _ string, _ int) ([]models.ProviderHealthWindow, error) { return nil, nil } func (m *mockStore) ListAllCurrent(_ context.Context) ([]models.ProviderHealthWindow, error) { m.mu.Lock() defer m.mu.Unlock() var result []models.ProviderHealthWindow for _, w := range m.windows { result = append(result, *w) } return result, nil } func (m *mockStore) Prune(_ context.Context, _ time.Time) (int64, error) { return 0, nil } // ── Tests ─────────────────────────────────── func TestRecordSuccess(t *testing.T) { ms := newMockStore() a := &Accumulator{ buckets: make(map[string]*bucket), store: ms, stopCh: make(chan struct{}), } a.RecordSuccess("prov-1", 100) a.RecordSuccess("prov-1", 200) a.RecordSuccess("prov-2", 50) a.flush() w1, _ := ms.GetCurrentWindow(context.Background(), "prov-1") if w1 == nil { t.Fatal("expected prov-1 window") } if w1.RequestCount != 2 { t.Fatalf("expected 2 requests, got %d", w1.RequestCount) } if w1.ErrorCount != 0 { t.Fatalf("expected 0 errors, got %d", w1.ErrorCount) } if w1.TotalLatencyMs != 300 { t.Fatalf("expected 300ms total latency, got %d", w1.TotalLatencyMs) } if w1.MaxLatencyMs != 200 { t.Fatalf("expected 200ms max latency, got %d", w1.MaxLatencyMs) } w2, _ := ms.GetCurrentWindow(context.Background(), "prov-2") if w2 == nil { t.Fatal("expected prov-2 window") } if w2.RequestCount != 1 { t.Fatalf("expected 1 request, got %d", w2.RequestCount) } } func TestRecordError(t *testing.T) { ms := newMockStore() a := &Accumulator{ buckets: make(map[string]*bucket), store: ms, stopCh: make(chan struct{}), } a.RecordSuccess("prov-1", 100) a.RecordError("prov-1", 500, "connection refused") a.RecordSuccess("prov-1", 150) a.flush() w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") if w.RequestCount != 3 { t.Fatalf("expected 3 requests, got %d", w.RequestCount) } if w.ErrorCount != 1 { t.Fatalf("expected 1 error, got %d", w.ErrorCount) } if w.LastError == nil || *w.LastError != "connection refused" { t.Fatalf("expected last_error='connection refused', got %v", w.LastError) } } func TestRecordTimeout(t *testing.T) { ms := newMockStore() a := &Accumulator{ buckets: make(map[string]*bucket), store: ms, stopCh: make(chan struct{}), } a.RecordTimeout("prov-1", 30000, "context deadline exceeded") a.flush() w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") if w.RequestCount != 1 { t.Fatalf("expected 1 request, got %d", w.RequestCount) } if w.ErrorCount != 1 { t.Fatalf("expected 1 error, got %d", w.ErrorCount) } if w.TimeoutCount != 1 { t.Fatalf("expected 1 timeout, got %d", w.TimeoutCount) } } func TestDeriveStatus(t *testing.T) { tests := []struct { rate float64 count int expected models.ProviderStatus }{ {0, 0, models.StatusUnknown}, {0, 100, models.StatusHealthy}, {0.03, 100, models.StatusHealthy}, {0.05, 100, models.StatusDegraded}, {0.15, 100, models.StatusDegraded}, {0.25, 100, models.StatusDown}, {0.50, 100, models.StatusDown}, {1.0, 1, models.StatusDown}, } for _, tt := range tests { got := DeriveStatus(tt.rate, tt.count) if got != tt.expected { t.Errorf("DeriveStatus(%v, %d) = %s, want %s", tt.rate, tt.count, got, tt.expected) } } } func TestFlushClearsBuckets(t *testing.T) { ms := newMockStore() a := &Accumulator{ buckets: make(map[string]*bucket), store: ms, stopCh: make(chan struct{}), } a.RecordSuccess("prov-1", 100) a.flush() // After flush, buckets should be empty a.mu.Lock() count := len(a.buckets) a.mu.Unlock() if count != 0 { t.Fatalf("expected 0 buckets after flush, got %d", count) } } func TestConcurrentRecording(t *testing.T) { ms := newMockStore() a := &Accumulator{ buckets: make(map[string]*bucket), store: ms, stopCh: make(chan struct{}), } var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() a.RecordSuccess("prov-1", 50) }() } wg.Wait() a.flush() w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") if w.RequestCount != 100 { t.Fatalf("expected 100 concurrent requests, got %d", w.RequestCount) } } func TestHourFloor(t *testing.T) { ts := time.Date(2026, 3, 1, 14, 37, 42, 0, time.UTC) floor := hourFloor(ts) expected := time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC) if !floor.Equal(expected) { t.Fatalf("expected %v, got %v", expected, floor) } }