Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions internal/serve/api/feed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/RandomCodeSpace/ctm/internal/serve/events"
)

// fakeFeedSource is a minimal FeedSource backed by a static slice so we
// can exercise Feed without standing up the real hub.
type fakeFeedSource struct{ events []events.Event }

func (f fakeFeedSource) Snapshot(filter string) []events.Event {
if filter == "" {
return f.events
}
out := make([]events.Event, 0, len(f.events))
for _, ev := range f.events {
if ev.Session == filter {
out = append(out, ev)
}
}
return out
}

func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}

func TestFeed_FiltersToToolCallsOnlyAndReverses(t *testing.T) {
src := fakeFeedSource{events: []events.Event{
{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 1})},
{Type: "quota_update", Session: "", Payload: mustJSON(t, map[string]any{"n": 2})},
{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 3})},
{Type: "attention_raised", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 4})},
{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 5})},
}}
h := Feed(src, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}

var got []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d items, want 3 tool_calls only: %+v", len(got), got)
}
// Newest-first: original chronological order [1,3,5] reverses to [5,3,1].
wantNs := []float64{5, 3, 1}
for i, want := range wantNs {
if got[i]["n"] != want {
t.Errorf("item %d n = %v, want %v", i, got[i]["n"], want)
}
}
}

func TestFeed_EmptyRingReturnsEmptyArray(t *testing.T) {
h := Feed(fakeFeedSource{}, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
// Body must be a JSON array literal "[]" (not "null") so the SPA
// can distinguish empty-but-known from "fetch failed".
body := rec.Body.String()
if body != "[]\n" && body != "[]" {
t.Errorf("body = %q, want %q", body, "[]")
}
}

func TestFeed_LimitClampedToMax(t *testing.T) {
// Build 600 tool_calls; expect at most maxFeedLimit (500) returned.
in := make([]events.Event, 0, 600)
for i := 0; i < 600; i++ {
in = append(in, events.Event{
Type: "tool_call",
Session: "alpha",
Payload: mustJSON(t, map[string]any{"n": i}),
})
}
h := Feed(fakeFeedSource{events: in}, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=99999", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != maxFeedLimit {
t.Errorf("len(got) = %d, want %d (clamped)", len(got), maxFeedLimit)
}
}

func TestFeed_LimitHonouredWhenSmall(t *testing.T) {
in := make([]events.Event, 0, 10)
for i := 0; i < 10; i++ {
in = append(in, events.Event{
Type: "tool_call",
Session: "alpha",
Payload: mustJSON(t, map[string]any{"n": i}),
})
}
h := Feed(fakeFeedSource{events: in}, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=3", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 3 {
t.Errorf("len(got) = %d, want 3", len(got))
}
// Newest-first.
if got[0]["n"] != float64(9) {
t.Errorf("first item n = %v, want 9", got[0]["n"])
}
}

func TestFeed_InvalidLimitFallsBackToDefault(t *testing.T) {
// 250 tool_calls; ?limit=garbage → defaultFeedLimit (200).
in := make([]events.Event, 0, 250)
for i := 0; i < 250; i++ {
in = append(in, events.Event{
Type: "tool_call",
Session: "alpha",
Payload: mustJSON(t, map[string]any{"n": i}),
})
}
h := Feed(fakeFeedSource{events: in}, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=banana", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != defaultFeedLimit {
t.Errorf("len(got) = %d, want default %d", len(got), defaultFeedLimit)
}
}

func TestFeed_PerSessionFilterFromConstructor(t *testing.T) {
src := fakeFeedSource{events: []events.Event{
{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"who": "alpha"})},
{Type: "tool_call", Session: "beta", Payload: mustJSON(t, map[string]any{"who": "beta"})},
}}
h := Feed(src, "alpha")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/api/sessions/alpha/feed", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 1 || got[0]["who"] != "alpha" {
t.Errorf("got %+v, want only alpha", got)
}
}

func TestFeed_MethodNotAllowed(t *testing.T) {
h := Feed(fakeFeedSource{}, "")
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/api/feed", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
if got := rec.Header().Get("Allow"); got != http.MethodGet {
t.Errorf("Allow = %q, want GET", got)
}
}
137 changes: 137 additions & 0 deletions internal/serve/api/health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)

type fakeHubStats struct{ payload any }

func (f fakeHubStats) Stats() any { return f.payload }

func TestHealthz_HappyPath(t *testing.T) {
const hdr = "X-Ctm-Serve"
const ver = "0.3.7"
started := time.Now().Add(-2 * time.Second)
h := Healthz(ver, hdr, started)

rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get(hdr); got != ver {
t.Errorf("%s header = %q, want %q", hdr, got, ver)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", got)
}

var body struct {
Status string `json:"status"`
UptimeSeconds float64 `json:"uptime_seconds"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "ok" {
t.Errorf("status = %q, want ok", body.Status)
}
if body.UptimeSeconds < 1.5 {
t.Errorf("uptime = %.2fs, want at least ~2s", body.UptimeSeconds)
}
}

func TestHealthz_HEADReturnsHeadersWithoutBody(t *testing.T) {
const hdr = "X-Ctm-Serve"
h := Healthz("0.3.7", hdr, time.Now())
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodHead, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Header().Get(hdr) == "" {
t.Errorf("expected header %q to be set on HEAD", hdr)
}
}

func TestHealthz_MethodNotAllowed(t *testing.T) {
h := Healthz("0.3.7", "X-Ctm-Serve", time.Now())
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(m, "/healthz", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s status = %d, want 405", m, rec.Code)
}
}
}

func TestHealth_HappyPathWithHubStats(t *testing.T) {
const hdr = "X-Ctm-Serve"
const ver = "0.3.7"
started := time.Now().Add(-1 * time.Second)
stats := fakeHubStats{payload: map[string]any{"published": float64(42), "dropped": float64(0)}}
h := Health(ver, hdr, started, stats)

rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/health", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get(hdr); got != ver {
t.Errorf("version header = %q, want %q", got, ver)
}

var body struct {
Status string `json:"status"`
Version string `json:"version"`
UptimeSeconds float64 `json:"uptime_seconds"`
Components map[string]string `json:"components"`
Hub map[string]any `json:"hub"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Status != "ok" || body.Version != ver {
t.Errorf("status/version = (%q,%q), want (ok,%q)", body.Status, body.Version, ver)
}
if got := body.Components["http"]; got != "ok" {
t.Errorf("components[http] = %q, want ok", got)
}
if got, _ := body.Hub["published"].(float64); got != 42 {
t.Errorf("hub.published = %v, want 42", body.Hub["published"])
}
}

func TestHealth_NilHubOmitsHubField(t *testing.T) {
h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if _, present := body["hub"]; present {
t.Errorf("expected 'hub' to be omitted when nil, got %v", body["hub"])
}
}

func TestHealth_MethodNotAllowed(t *testing.T) {
h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
Loading
Loading