package main // api_stats_test.go — REST shape of /api/sessions/:id/stats and /api/stats. import ( "encoding/json" "net/http" "testing" ) func TestAPISessionStatsShape(t *testing.T) { ts, store := newTestServer(t) appendAgentEnd(t, store, "s1", 1, `{"usage":{"inputTokens":1200,"outputTokens":340,"totalCost":0.25}}`) appendAgentEnd(t, store, "s1", 2, `{}`) code, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/stats", testToken, "") if code != http.StatusOK { t.Fatalf("stats status = %d", code) } var stats map[string]any if err := json.Unmarshal([]byte(body), &stats); err != nil { t.Fatalf("decode: %v", err) } if stats["turns"].(float64) != 2 || stats["inputTokens"].(float64) != 1200 || stats["outputTokens"].(float64) != 340 || stats["totalCost"].(float64) != 0.25 { t.Fatalf("stats = %v", stats) } if _, ok := stats["sessionsCount"]; ok { t.Fatalf("per-session stats must not carry sessionsCount: %v", stats) } // unknown session: 200 with zeros (matches events endpoint behavior) code, body = apiReq(t, http.MethodGet, ts.URL+"/api/sessions/ghost/stats", testToken, "") if code != http.StatusOK { t.Fatalf("ghost status = %d", code) } var ghost map[string]any if err := json.Unmarshal([]byte(body), &ghost); err != nil { t.Fatalf("decode ghost: %v", err) } if len(ghost) != 4 || ghost["turns"].(float64) != 0 { t.Fatalf("ghost stats = %v, want four zero fields", ghost) } } func TestAPIStatsTotalsAndOnlineCount(t *testing.T) { ts, store := newTestServer(t) appendAgentEnd(t, store, "s1", 1, `{"usage":{"inputTokens":1200000,"outputTokens":340000,"totalCost":4.2}}`) appendAgentEnd(t, store, "s2", 1, `{"usage":{"inputTokens":50,"outputTokens":10,"totalCost":0}}`) get := func() map[string]any { t.Helper() code, body := apiReq(t, http.MethodGet, ts.URL+"/api/stats", testToken, "") if code != http.StatusOK { t.Fatalf("/api/stats status = %d", code) } var m map[string]any if err := json.Unmarshal([]byte(body), &m); err != nil { t.Fatalf("decode: %v", err) } return m } totals := get() if totals["turns"].(float64) != 2 || totals["inputTokens"].(float64) != 1200050 || totals["outputTokens"].(float64) != 340010 || totals["totalCost"].(float64) != 4.2 || totals["sessionsCount"].(float64) != 2 { t.Fatalf("totals = %v", totals) } if totals["onlineCount"].(float64) != 0 { t.Fatalf("onlineCount = %v, want 0 with no agents", totals["onlineCount"]) } // one registered agent connection must show up as online ws := dialAgent(t, ts) if err := ws.WriteJSON(helloFrame("s-live")); err != nil { t.Fatalf("hello: %v", err) } if _, _, err := ws.ReadMessage(); err != nil { // welcome t.Fatalf("welcome: %v", err) } online := get() if online["onlineCount"].(float64) != 1 { t.Fatalf("onlineCount = %v, want 1 after hello", online["onlineCount"]) } } func TestAPIStatsClosedStore500(t *testing.T) { ts, store := newTestServer(t) if err := store.Close(); err != nil { t.Fatalf("close store: %v", err) } if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/stats", testToken, ""); code != http.StatusInternalServerError { t.Fatal("stats with broken store must 500") } if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/stats", testToken, ""); code != http.StatusInternalServerError { t.Fatal("session stats with broken store must 500") } }