package api import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) func TestHandleGetEscalationFunnel_AggregatesWithinDefaultWindow(t *testing.T) { srv, store := testServer(t) tk := createTaskWithState(t, store, "funnel-task", task.StateCompleted) now := time.Now().UTC() mustCreateAPIExecution(t, store, "f1", tk.ID, "local", 0, 0, now.Add(-1*time.Hour)) mustCreateAPIExecution(t, store, "f2", tk.ID, "local", 0, 0, now.Add(-2*time.Hour)) mustCreateAPIExecution(t, store, "f3", tk.ID, "anthropic", 1, 0.10, now.Add(-3*time.Hour)) // Outside the default 24h window. mustCreateAPIExecution(t, store, "f4", tk.ID, "local", 0, 0, now.Add(-48*time.Hour)) req := httptest.NewRequest("GET", "/api/escalation-funnel", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String()) } var buckets []storage.EscalationBucket if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil { t.Fatalf("unmarshal: %v", err) } if len(buckets) != 2 { t.Fatalf("expected 2 buckets (rung 0/local, rung 1/anthropic), got %d: %+v", len(buckets), buckets) } for _, b := range buckets { if b.Rung == 0 && b.Agent == "local" && b.Count != 2 { t.Errorf("rung0/local count = %d, want 2", b.Count) } if b.Rung == 1 && b.Agent == "anthropic" && (b.Count != 1 || b.CostUSD != 0.10) { t.Errorf("rung1/anthropic = %+v, want count 1 cost 0.10", b) } } } func TestHandleGetEscalationFunnel_WindowParam_Narrows(t *testing.T) { srv, store := testServer(t) tk := createTaskWithState(t, store, "funnel-window-task", task.StateCompleted) now := time.Now().UTC() mustCreateAPIExecution(t, store, "fw1", tk.ID, "local", 0, 0, now.Add(-10*time.Hour)) req := httptest.NewRequest("GET", "/api/escalation-funnel?window=5h", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) var buckets []storage.EscalationBucket if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil { t.Fatalf("unmarshal: %v", err) } if len(buckets) != 0 { t.Errorf("expected no buckets within a 5h window, got %+v", buckets) } } func TestHandleGetSpendTimeseries_ReturnsPerProviderPoints(t *testing.T) { srv, store := testServer(t) tk := createTaskWithState(t, store, "spend-task", task.StateCompleted) now := time.Now().UTC() mustCreateAPIExecution(t, store, "s1", tk.ID, "local", 0, 0.01, now.Add(-1*time.Hour)) mustCreateAPIExecution(t, store, "s2", tk.ID, "anthropic", 0, 0.20, now.Add(-1*time.Hour)) req := httptest.NewRequest("GET", "/api/spend-timeseries", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String()) } var points []storage.SpendPoint if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil { t.Fatalf("unmarshal: %v", err) } agents := map[string]bool{} for _, p := range points { agents[p.Agent] = true } if !agents["local"] || !agents["anthropic"] { t.Errorf("expected points for both local and anthropic, got %+v", points) } } func TestHandleGetSpendTimeseries_EmptyDB_ReturnsEmptyArray(t *testing.T) { srv, _ := testServer(t) req := httptest.NewRequest("GET", "/api/spend-timeseries", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: want 200, got %d", w.Code) } var points []storage.SpendPoint if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil { t.Fatalf("unmarshal: %v", err) } if len(points) != 0 { t.Errorf("expected empty slice, got %+v", points) } } // mustCreateAPIExecution mirrors createExecution (executions_test.go) but // also sets Agent/EscalationRung, which the funnel/spend-timeseries // endpoints aggregate on. func mustCreateAPIExecution(t *testing.T, store *storage.DB, id, taskID, agent string, rung int, cost float64, start time.Time) { t.Helper() exec := &storage.Execution{ ID: id, TaskID: taskID, StartTime: start, EndTime: start.Add(time.Minute), Status: "COMPLETED", CostUSD: cost, Agent: agent, EscalationRung: rung, } if err := store.CreateExecution(exec); err != nil { t.Fatalf("createExecution(%s): %v", id, err) } }