test: migrate execx and metrics into testing module

This commit is contained in:
Brad Stein 2026-04-10 17:04:23 -03:00
parent 2e247b6782
commit 632a1e2824
12 changed files with 314 additions and 283 deletions

View File

@ -1,53 +0,0 @@
package execx
import (
"context"
"strings"
"testing"
)
// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step.
// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T).
// Why: covers error branch where command fails without producing output.
func TestRunnerRunFailureWithoutOutput(t *testing.T) {
r := &Runner{}
out, err := r.Run(context.Background(), "sh", "-c", "exit 3")
if err == nil {
t.Fatalf("expected failure")
}
if out != "" {
t.Fatalf("expected empty output, got %q", out)
}
}
// TestRunnerLogfNoLogger runs one orchestration or CLI step.
// Signature: TestRunnerLogfNoLogger(t *testing.T).
// Why: covers no-op logging path.
func TestRunnerLogfNoLogger(t *testing.T) {
r := &Runner{}
r.logf("hello %s", "world")
}
// TestRunnerCommandMissing runs one orchestration or CLI step.
// Signature: TestRunnerCommandMissing(t *testing.T).
// Why: covers false branch of command existence checks.
func TestRunnerCommandMissing(t *testing.T) {
r := &Runner{}
if r.CommandExists("definitely-not-a-real-command-ananke") {
t.Fatalf("expected missing command to be false")
}
}
// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step.
// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T).
// Why: covers kubeconfig environment injection branch in command runner.
func TestRunnerInjectsKubeconfigEnv(t *testing.T) {
r := &Runner{Kubeconfig: "/tmp/test-kubeconfig"}
out, err := r.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"")
if err != nil {
t.Fatalf("runner command failed: %v", err)
}
if strings.TrimSpace(out) != "/tmp/test-kubeconfig" {
t.Fatalf("expected kubeconfig env to propagate, got %q", out)
}
}

View File

@ -1,68 +0,0 @@
package execx
import (
"bytes"
"context"
"log"
"strings"
"testing"
)
// TestRunnerDryRun runs one orchestration or CLI step.
// Signature: TestRunnerDryRun(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunnerDryRun(t *testing.T) {
var buf bytes.Buffer
r := &Runner{
DryRun: true,
Logger: log.New(&buf, "", 0),
}
out, err := r.Run(context.Background(), "echo", "hello")
if err != nil {
t.Fatalf("dry-run should not fail: %v", err)
}
if out != "" {
t.Fatalf("expected empty dry-run output, got %q", out)
}
if !strings.Contains(buf.String(), "DRY-RUN: echo hello") {
t.Fatalf("expected dry-run log entry, got %q", buf.String())
}
}
// TestRunnerRunSuccess runs one orchestration or CLI step.
// Signature: TestRunnerRunSuccess(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunnerRunSuccess(t *testing.T) {
r := &Runner{}
out, err := r.Run(context.Background(), "sh", "-c", "printf ok")
if err != nil {
t.Fatalf("expected command success: %v", err)
}
if out != "ok" {
t.Fatalf("expected output ok, got %q", out)
}
}
// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step.
// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunnerRunFailureIncludesOutput(t *testing.T) {
r := &Runner{}
out, err := r.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1")
if err == nil {
t.Fatalf("expected command failure")
}
if strings.TrimSpace(out) != "boom" {
t.Fatalf("expected stderr to be preserved, got %q", out)
}
}
// TestRunnerCommandExists runs one orchestration or CLI step.
// Signature: TestRunnerCommandExists(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunnerCommandExists(t *testing.T) {
r := &Runner{}
if !r.CommandExists("sh") {
t.Fatalf("expected shell command to exist")
}
}

View File

@ -0,0 +1,9 @@
package execx
// TestHookLogf runs one orchestration or CLI step.
// Signature: TestHookLogf(r *Runner, format string, args ...any).
// Why: top-level testing module still needs to hit the nil-logger branch
// directly while runner tests move out of the root module.
func TestHookLogf(r *Runner, format string, args ...any) {
r.logf(format, args...)
}

View File

@ -1,105 +0,0 @@
package metrics
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// TestExporterHealthzAndEscaping runs one orchestration or CLI step.
// Signature: TestExporterHealthzAndEscaping(t *testing.T).
// Why: covers health endpoint and label escaping branches in metrics renderer.
func TestExporterHealthzAndEscaping(t *testing.T) {
e := New()
e.UpdateSample(Sample{
Name: `Sta"tera`,
Target: `statera\host`,
Status: `O"B`,
LastError: "x",
})
h := e.Handler("/custom")
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
healthRR := httptest.NewRecorder()
h.ServeHTTP(healthRR, healthReq)
if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" {
t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String())
}
metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil)
metricsRR := httptest.NewRecorder()
h.ServeHTTP(metricsRR, metricsReq)
body := metricsRR.Body.String()
if !strings.Contains(body, `source="Sta\\\"tera"`) {
t.Fatalf("expected escaped source label, got:\n%s", body)
}
if !strings.Contains(body, `target="statera\\\\host"`) {
t.Fatalf("expected escaped target label, got:\n%s", body)
}
if !strings.Contains(body, "ananke_ups_error") {
t.Fatalf("expected error metric line in output")
}
}
// TestBoolNumAndSafeHelpers runs one orchestration or CLI step.
// Signature: TestBoolNumAndSafeHelpers(t *testing.T).
// Why: directly covers remaining helper branches.
func TestBoolNumAndSafeHelpers(t *testing.T) {
if boolNum(true) != 1 || boolNum(false) != 0 {
t.Fatalf("unexpected boolNum values")
}
if got := safe(`a"b\c`); got != `a\"b\\c` {
t.Fatalf("unexpected escaped string: %q", got)
}
}
// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step.
// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T).
// Why: verifies quality-gate metrics are surfaced on /metrics for Grafana suite
// pass-rate tracking.
func TestExporterAppendsQualityGateMetrics(t *testing.T) {
tmp := t.TempDir()
metricsPath := filepath.Join(tmp, "quality-gate.prom")
content := strings.Join([]string{
`# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`,
`# TYPE ananke_quality_gate_runs_total counter`,
`ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`,
`ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`,
"",
}, "\n")
if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil {
t.Fatalf("write quality metrics file: %v", err)
}
t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath)
e := New()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
e.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) {
t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body)
}
}
// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step.
// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T).
// Why: ensures Grafana panels see zero-value quality-gate series instead of
// "no data" before host-side quality-gate files exist.
func TestExporterDefaultsQualityGateMetrics(t *testing.T) {
t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom"))
e := New()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
e.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) {
t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body)
}
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) {
t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body)
}
}

View File

@ -1,53 +0,0 @@
package metrics
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestExporterEmitsCoreMetrics runs one orchestration or CLI step.
// Signature: TestExporterEmitsCoreMetrics(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestExporterEmitsCoreMetrics(t *testing.T) {
e := New()
e.UpdateBudget(321)
e.UpdateSample(Sample{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
OnBattery: true,
LowBattery: false,
RuntimeSecond: 412,
BatteryCharge: 81.5,
LoadPercent: 27.4,
PowerNominalW: 510,
ThresholdSec: 354,
Trigger: true,
BreachCount: 2,
Status: "OB",
UpdatedAt: time.Unix(1710000000, 0).UTC(),
})
e.MarkShutdown("ups-threshold")
req := httptest.NewRequest("GET", "/metrics", nil)
rr := httptest.NewRecorder()
e.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
mustContain := []string{
"ananke_shutdown_budget_seconds 321",
"ananke_shutdown_triggers_total 1",
"ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
}
for _, m := range mustContain {
if !strings.Contains(body, m) {
t.Fatalf("missing metric fragment %q in output:\n%s", m, body)
}
}
}

View File

@ -0,0 +1,17 @@
package metrics
// TestHookBoolNum runs one orchestration or CLI step.
// Signature: TestHookBoolNum(v bool) int.
// Why: top-level testing module coverage still needs explicit access to the
// boolean-to-metric helper without keeping package-local root tests around.
func TestHookBoolNum(v bool) int {
return boolNum(v)
}
// TestHookSafeLabelValue runs one orchestration or CLI step.
// Signature: TestHookSafeLabelValue(in string) string.
// Why: top-level testing module coverage still needs to verify label escaping
// behavior directly while split-module migration removes in-tree tests.
func TestHookSafeLabelValue(in string) string {
return safe(in)
}

View File

@ -117,6 +117,8 @@ func TestPerFileCoverageReport(t *testing.T) {
t, t,
filepath.Join(root, "testing"), filepath.Join(root, "testing"),
testingCover, testingCover,
"./execx",
"./metrics",
"./hygiene", "./hygiene",
"./orchestrator", "./orchestrator",
"./state", "./state",

View File

@ -0,0 +1,66 @@
package execxquality
import (
"context"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/execx"
)
// TestRunnerRunSuccess runs one orchestration or CLI step.
// Signature: TestRunnerRunSuccess(t *testing.T).
// Why: keeps command execution success behavior stable after moving runner
// checks into the top-level testing module.
func TestRunnerRunSuccess(t *testing.T) {
runner := &execx.Runner{}
out, err := runner.Run(context.Background(), "sh", "-c", "printf ok")
if err != nil {
t.Fatalf("expected command success: %v", err)
}
if out != "ok" {
t.Fatalf("expected output ok, got %q", out)
}
}
// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step.
// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T).
// Why: preserves failure-output behavior checks after split-module migration.
func TestRunnerRunFailureIncludesOutput(t *testing.T) {
runner := &execx.Runner{}
out, err := runner.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1")
if err == nil {
t.Fatalf("expected command failure")
}
if strings.TrimSpace(out) != "boom" {
t.Fatalf("expected stderr to be preserved, got %q", out)
}
}
// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step.
// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T).
// Why: keeps the empty-output failure branch covered after root-module tests are removed.
func TestRunnerRunFailureWithoutOutput(t *testing.T) {
runner := &execx.Runner{}
out, err := runner.Run(context.Background(), "sh", "-c", "exit 3")
if err == nil {
t.Fatalf("expected failure")
}
if out != "" {
t.Fatalf("expected empty output, got %q", out)
}
}
// TestRunnerCommandDiscovery runs one orchestration or CLI step.
// Signature: TestRunnerCommandDiscovery(t *testing.T).
// Why: preserves both positive and negative command existence checks from the
// top-level testing module.
func TestRunnerCommandDiscovery(t *testing.T) {
runner := &execx.Runner{}
if !runner.CommandExists("sh") {
t.Fatalf("expected shell command to exist")
}
if runner.CommandExists("definitely-not-a-real-command-ananke") {
t.Fatalf("expected missing command to be false")
}
}

View File

@ -0,0 +1,54 @@
package execxquality
import (
"bytes"
"context"
"log"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/execx"
)
// TestRunnerDryRun runs one orchestration or CLI step.
// Signature: TestRunnerDryRun(t *testing.T).
// Why: keeps dry-run logging behavior explicit after moving runner tests into
// the top-level testing module.
func TestRunnerDryRun(t *testing.T) {
var buf bytes.Buffer
runner := &execx.Runner{
DryRun: true,
Logger: log.New(&buf, "", 0),
}
out, err := runner.Run(context.Background(), "echo", "hello")
if err != nil {
t.Fatalf("dry-run should not fail: %v", err)
}
if out != "" {
t.Fatalf("expected empty dry-run output, got %q", out)
}
if !strings.Contains(buf.String(), "DRY-RUN: echo hello") {
t.Fatalf("expected dry-run log entry, got %q", buf.String())
}
}
// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step.
// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T).
// Why: keeps kubeconfig environment propagation covered from the split testing module.
func TestRunnerInjectsKubeconfigEnv(t *testing.T) {
runner := &execx.Runner{Kubeconfig: "/tmp/test-kubeconfig"}
out, err := runner.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"")
if err != nil {
t.Fatalf("runner command failed: %v", err)
}
if strings.TrimSpace(out) != "/tmp/test-kubeconfig" {
t.Fatalf("expected kubeconfig env to propagate, got %q", out)
}
}
// TestRunnerNilLoggerHook runs one orchestration or CLI step.
// Signature: TestRunnerNilLoggerHook(t *testing.T).
// Why: keeps the nil-logger no-op branch covered without reintroducing same-package tests.
func TestRunnerNilLoggerHook(t *testing.T) {
execx.TestHookLogf(&execx.Runner{}, "hello %s", "world")
}

View File

@ -19,10 +19,6 @@ internal/cluster/orchestrator_vault_test.go
internal/config/config_test.go internal/config/config_test.go
internal/config/load_additional_test.go internal/config/load_additional_test.go
internal/config/validate_matrix_test.go internal/config/validate_matrix_test.go
internal/execx/runner_additional_test.go
internal/execx/runner_test.go
internal/metrics/exporter_additional_test.go
internal/metrics/exporter_test.go
internal/service/daemon_additional_test.go internal/service/daemon_additional_test.go
internal/service/daemon_coverage_closeout_test.go internal/service/daemon_coverage_closeout_test.go
internal/service/daemon_quality_branches_test.go internal/service/daemon_quality_branches_test.go

View File

@ -0,0 +1,106 @@
package metricsquality
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/metrics"
)
// TestExporterEmitsCoreMetrics runs one orchestration or CLI step.
// Signature: TestExporterEmitsCoreMetrics(t *testing.T).
// Why: keeps exporter metric output stable after moving checks into the
// top-level testing module.
func TestExporterEmitsCoreMetrics(t *testing.T) {
exporter := metrics.New()
exporter.UpdateBudget(321)
exporter.UpdateSample(metrics.Sample{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
OnBattery: true,
LowBattery: false,
RuntimeSecond: 412,
BatteryCharge: 81.5,
LoadPercent: 27.4,
PowerNominalW: 510,
ThresholdSec: 354,
Trigger: true,
BreachCount: 2,
Status: "OB",
UpdatedAt: time.Unix(1710000000, 0).UTC(),
})
exporter.MarkShutdown("ups-threshold")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
mustContain := []string{
"ananke_shutdown_budget_seconds 321",
"ananke_shutdown_triggers_total 1",
"ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
"ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
}
for _, fragment := range mustContain {
if !strings.Contains(body, fragment) {
t.Fatalf("missing metric fragment %q in output:\n%s", fragment, body)
}
}
}
// TestExporterHealthzAndEscaping runs one orchestration or CLI step.
// Signature: TestExporterHealthzAndEscaping(t *testing.T).
// Why: covers exporter health routing and label escaping from the top-level
// testing module so HTTP behavior stays explicit after migration.
func TestExporterHealthzAndEscaping(t *testing.T) {
exporter := metrics.New()
exporter.UpdateSample(metrics.Sample{
Name: `Sta"tera`,
Target: `statera\host`,
Status: `O"B`,
LastError: "x",
})
handler := exporter.Handler("/custom")
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
healthRR := httptest.NewRecorder()
handler.ServeHTTP(healthRR, healthReq)
if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" {
t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String())
}
metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil)
metricsRR := httptest.NewRecorder()
handler.ServeHTTP(metricsRR, metricsReq)
body := metricsRR.Body.String()
if !strings.Contains(body, `source="Sta\\\"tera"`) {
t.Fatalf("expected escaped source label, got:\n%s", body)
}
if !strings.Contains(body, `target="statera\\\\host"`) {
t.Fatalf("expected escaped target label, got:\n%s", body)
}
if !strings.Contains(body, "ananke_ups_error") {
t.Fatalf("expected error metric line in output")
}
}
// TestExporterHelperContracts runs one orchestration or CLI step.
// Signature: TestExporterHelperContracts(t *testing.T).
// Why: preserves direct helper coverage for metric formatting utilities after
// removing same-package root tests.
func TestExporterHelperContracts(t *testing.T) {
if metrics.TestHookBoolNum(true) != 1 || metrics.TestHookBoolNum(false) != 0 {
t.Fatalf("unexpected boolNum values")
}
if got := metrics.TestHookSafeLabelValue(`a"b\c`); got != `a\"b\\c` {
t.Fatalf("unexpected escaped string: %q", got)
}
}

View File

@ -0,0 +1,60 @@
package metricsquality
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/metrics"
)
// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step.
// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T).
// Why: verifies exporter output still includes host quality-gate metrics after
// the split-module migration.
func TestExporterAppendsQualityGateMetrics(t *testing.T) {
tmp := t.TempDir()
metricsPath := filepath.Join(tmp, "quality-gate.prom")
content := strings.Join([]string{
`# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`,
`# TYPE ananke_quality_gate_runs_total counter`,
`ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`,
`ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`,
"",
}, "\n")
if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil {
t.Fatalf("write quality metrics file: %v", err)
}
t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath)
exporter := metrics.New()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) {
t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body)
}
}
// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step.
// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T).
// Why: keeps zero-value quality-gate series visible before the host writes its
// first metrics file.
func TestExporterDefaultsQualityGateMetrics(t *testing.T) {
t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom"))
exporter := metrics.New()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) {
t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body)
}
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) {
t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body)
}
}