package api import ( "encoding/json" "net/http" "reflect" "sync/atomic" "testing" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/system/metrics" ) // TestSplitMetrics covers the query-list parser. Blank entries have to be // dropped rather than passed through, because an empty metric ID reaches the // collector as an unknown metric. func TestSplitMetrics(t *testing.T) { cases := []struct { name string raw string want []string }{ {"empty", "", nil}, {"single", "cpu", []string{"cpu"}}, {"several with spaces", " cpu , memory ,disk ", []string{"cpu", "memory", "disk"}}, {"blank entries dropped", "cpu,,memory,", []string{"cpu", "memory"}}, {"only separators", ",,,", []string{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := splitMetrics(tc.raw) if !reflect.DeepEqual(got, tc.want) { t.Errorf("splitMetrics(%q) = %#v, want %#v", tc.raw, got, tc.want) } }) } } // TestHandleSystemMetrics_StampsExecutionIdentity asserts the snapshot is // labelled as this executor before it leaves the handler. The dashboard merges // host and executor snapshots into one list keyed by ID, so an unstamped // snapshot would collide with the host's. func TestHandleSystemMetrics_StampsExecutionIdentity(t *testing.T) { srv := newTestServer(t) rec := serverGet(t, srv, "/api/v1/system/metrics") if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { ID string `json:"id"` Label string `json:"label"` Kind string `json:"kind"` Metrics []map[string]any `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } if snapshot.ID != "agentctl" { t.Errorf("id = %q, want %q", snapshot.ID, "agentctl") } if snapshot.Label != "Execution" { t.Errorf("label = %q, want %q", snapshot.Label, "Execution") } if snapshot.Kind != "execution" { t.Errorf("kind = %q, want %q", snapshot.Kind, "execution") } if len(snapshot.Metrics) != len(metrics.DefaultSettings().Metrics) { t.Errorf("sampled %d metrics, want the %d defaults", len(snapshot.Metrics), len(metrics.DefaultSettings().Metrics)) } } // TestHandleSystemMetrics_HonoursRequestedMetrics pins the selection: asking // for one metric must sample exactly that one, not the default set. func TestHandleSystemMetrics_HonoursRequestedMetrics(t *testing.T) { srv := newTestServer(t) wanted := metrics.DefaultSettings().Metrics[0] rec := serverGet(t, srv, "/api/v1/system/metrics?metrics="+wanted) if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []struct { ID string `json:"id"` } `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } if len(snapshot.Metrics) != 1 || snapshot.Metrics[0].ID != wanted { t.Errorf("metrics = %+v, want exactly [%s]", snapshot.Metrics, wanted) } } // TestHandleSystemMetrics_DiagnosticMetricsAbsentByDefault guards the // user-visible-surface boundary: the agentctl diagnostic metric IDs // must never appear unless a caller names them explicitly, because they are // deliberately excluded from metrics.isKnownMetric and must never leak into // what looks like the persisted/broadcast metric set. func TestHandleSystemMetrics_DiagnosticMetricsAbsentByDefault(t *testing.T) { srv := newTestServer(t) rec := serverGet(t, srv, "/api/v1/system/metrics") if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []struct { ID string `json:"id"` } `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } diagnostic := map[string]bool{ metrics.MetricAgentctlGoroutines: true, metrics.MetricAgentctlGitPollMillis: true, metrics.MetricAgentctlMonitorMillis: true, metrics.MetricAgentctlCreateReadyMs: true, } for _, sample := range snapshot.Metrics { if diagnostic[sample.ID] { t.Errorf("default snapshot unexpectedly includes diagnostic metric %q", sample.ID) } } } // TestHandleSystemMetrics_DiagnosticMetricsServedWhenRequested asserts the // diagnostic metrics are served, with the expected shape, only when // explicitly named. newTestServer's config never goes through // instance.Manager.CreateInstance and its process.Manager is never started, // so agentctl_create_ready_ms and agentctl_git_poll_ms are exercised on their // "not recorded yet" branches; agentctl_goroutines is always available. func TestHandleSystemMetrics_DiagnosticMetricsServedWhenRequested(t *testing.T) { srv := newTestServer(t) rec := serverGet(t, srv, "/api/v1/system/metrics?metrics="+ metrics.MetricAgentctlGoroutines+","+ metrics.MetricAgentctlGitPollMillis+","+ metrics.MetricAgentctlMonitorMillis+","+ metrics.MetricAgentctlCreateReadyMs) if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []metrics.MetricSample `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } // Assert on the list itself, not a map keyed by ID: a collector that // doesn't recognize a diagnostic ID emits its own "unknown metric" sample // alongside the handler's correct one for the same ID, and collapsing // into a map (last-write-wins) hides that duplication instead of // catching it. wantIDs := []string{ metrics.MetricAgentctlGoroutines, metrics.MetricAgentctlGitPollMillis, metrics.MetricAgentctlMonitorMillis, metrics.MetricAgentctlCreateReadyMs, } if len(snapshot.Metrics) != len(wantIDs) { t.Fatalf("got %d metrics %+v, want exactly %d (one per requested ID)", len(snapshot.Metrics), snapshot.Metrics, len(wantIDs)) } counts := make(map[string]int, len(snapshot.Metrics)) for _, sample := range snapshot.Metrics { counts[sample.ID]++ } for _, id := range wantIDs { if counts[id] != 1 { t.Fatalf("id %q appears %d times in %+v, want exactly 1", id, counts[id], snapshot.Metrics) } } byID := make(map[string]metrics.MetricSample, len(snapshot.Metrics)) for _, sample := range snapshot.Metrics { byID[sample.ID] = sample } goroutines := byID[metrics.MetricAgentctlGoroutines] if !goroutines.Available || goroutines.Value == nil || *goroutines.Value <= 0 { t.Errorf("goroutines sample = %+v, want available with a positive value", goroutines) } gitPoll := byID[metrics.MetricAgentctlGitPollMillis] if gitPoll.Available || gitPoll.Error == "" { t.Errorf("git poll sample = %+v, want unavailable with an error (no tracker started)", gitPoll) } monitorPoll := byID[metrics.MetricAgentctlMonitorMillis] if monitorPoll.Available || monitorPoll.Error == "" { t.Errorf("monitor poll sample = %+v, want unavailable with an error (no tracker started)", monitorPoll) } createReady := byID[metrics.MetricAgentctlCreateReadyMs] if createReady.Available || createReady.Error == "" { t.Errorf("create-ready sample = %+v, want unavailable with an error (nil CreateReadyMillis)", createReady) } } func TestHandleSystemMetrics_CreateReadyIsAvailableAfterInstanceStartup(t *testing.T) { log := newTestLogger() readyMillis := &atomic.Int64{} readyMillis.Store(42) cfg := &config.InstanceConfig{ Port: 0, WorkDir: t.TempDir(), CreateReadyMillis: readyMillis, } server := NewServer(cfg, process.NewManager(cfg, log), nil, nil, log) rec := serverGet(t, server, "/api/v1/system/metrics?metrics="+metrics.MetricAgentctlCreateReadyMs) if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []metrics.MetricSample `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } if len(snapshot.Metrics) != 1 { t.Fatalf("metrics = %+v, want one sample", snapshot.Metrics) } sample := snapshot.Metrics[0] if sample.ID != metrics.MetricAgentctlCreateReadyMs || !sample.Available || sample.Value == nil || *sample.Value != 42 { t.Fatalf("create-ready sample = %+v, want available value 42", sample) } } // TestHandleSystemMetrics_MixedMetricsPreserveRequestOrder pins the // interleaving behaviour when a request mixes a standard (collector-served) // metric ID with a diagnostic (handler-served) one: the response must come // back in the same order they were requested in, not with every diagnostic // sample trailing every collector sample regardless of query order. func TestHandleSystemMetrics_MixedMetricsPreserveRequestOrder(t *testing.T) { srv := newTestServer(t) rec := serverGet(t, srv, "/api/v1/system/metrics?metrics="+ metrics.MetricAgentctlGoroutines+","+ metrics.MetricCPUPercent+","+ metrics.MetricAgentctlGitPollMillis) if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []metrics.MetricSample `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } wantOrder := []string{ metrics.MetricAgentctlGoroutines, metrics.MetricCPUPercent, metrics.MetricAgentctlGitPollMillis, } if len(snapshot.Metrics) != len(wantOrder) { t.Fatalf("got %d metrics %+v, want exactly %d (one per requested ID)", len(snapshot.Metrics), snapshot.Metrics, len(wantOrder)) } for i, id := range wantOrder { if snapshot.Metrics[i].ID != id { t.Errorf("metrics[%d].ID = %q, want %q (request order not preserved): got order %+v", i, snapshot.Metrics[i].ID, id, snapshot.Metrics) } } } // TestHandleSystemMetrics_GenuinelyUnknownMetricStillReportsUnknown pins the // collector's fallback behaviour for an ID that is neither a known backend // metric nor an agentctl diagnostic one: it must still come back as exactly // one "unknown metric" sample, not zero (collectorMetricIDs must not filter // out anything besides the diagnostic IDs) and not two (the diagnostic // path must not also emit something for it). func TestHandleSystemMetrics_GenuinelyUnknownMetricStillReportsUnknown(t *testing.T) { srv := newTestServer(t) rec := serverGet(t, srv, "/api/v1/system/metrics?metrics=nonsense") if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } var snapshot struct { Metrics []metrics.MetricSample `json:"metrics"` } if err := json.Unmarshal(rec.Body.Bytes(), &snapshot); err != nil { t.Fatalf("decode snapshot %q: %v", rec.Body.String(), err) } if len(snapshot.Metrics) != 1 { t.Fatalf("got %d metrics %+v, want exactly 1", len(snapshot.Metrics), snapshot.Metrics) } got := snapshot.Metrics[0] if got.ID != "nonsense" || got.Available || got.Error != "unknown metric" { t.Errorf("sample = %+v, want {ID: nonsense, Available: false, Error: unknown metric}", got) } }