248 lines
5.9 KiB
Go
248 lines
5.9 KiB
Go
package health
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 }
|
|
|
|
func (m *mockStore) UpsertToolWindow(_ context.Context, _ *models.ToolHealthWindow) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) ListAllToolCurrent(_ context.Context) ([]models.ToolHealthWindow, error) {
|
|
return nil, 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)
|
|
}
|
|
}
|