ananke/cmd/ananke/command_additional_test.go

268 lines
9.8 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/service"
)
// rewriteConfig runs one orchestration or CLI step.
// Signature: rewriteConfig(t *testing.T, path string, transform func(string) string).
// Why: keeps command tests readable while mutating config fixtures for branch coverage.
func rewriteConfig(t *testing.T, path string, transform func(string) string) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
out := transform(string(b))
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
t.Fatalf("write config fixture: %v", err)
}
}
// TestRunStartupDryRunHitsFluxDriftGuard runs one orchestration or CLI step.
// Signature: TestRunStartupDryRunHitsFluxDriftGuard(t *testing.T).
// Why: covers startup handler path and current dry-run flux-source drift guard behavior.
func TestRunStartupDryRunHitsFluxDriftGuard(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{"--config", cfgPath})
if err == nil {
t.Fatalf("expected drift guard error in dry-run mode")
}
if !strings.Contains(err.Error(), "flux source url drift") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestRunStartupPeerRoleRequiresOverride runs one orchestration or CLI step.
// Signature: TestRunStartupPeerRoleRequiresOverride(t *testing.T).
// Why: covers peer role guard when execute mode is requested.
func TestRunStartupPeerRoleRequiresOverride(t *testing.T) {
cfgPath := writeTestConfig(t)
rewriteConfig(t, cfgPath, func(in string) string {
return in + "\ncoordination:\n role: peer\n"
})
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{"--config", cfgPath, "--execute"})
if err == nil {
t.Fatalf("expected peer-role startup block")
}
if !strings.Contains(err.Error(), "role=peer") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestRunEtcdRestoreDryRun runs one orchestration or CLI step.
// Signature: TestRunEtcdRestoreDryRun(t *testing.T).
// Why: covers etcd-restore command path in non-execute mode.
func TestRunEtcdRestoreDryRun(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
if err := runEtcdRestore(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runEtcdRestore dry-run failed: %v", err)
}
}
// TestRunDaemonRejectsMissingUPSProvider runs one orchestration or CLI step.
// Signature: TestRunDaemonRejectsMissingUPSProvider(t *testing.T).
// Why: covers daemon target-build error path.
func TestRunDaemonRejectsMissingUPSProvider(t *testing.T) {
cfgPath := writeTestConfig(t)
rewriteConfig(t, cfgPath, func(in string) string {
return strings.Replace(in, "ups:\n enabled: false\n", "ups:\n enabled: true\n", 1)
})
logger := log.New(io.Discard, "", 0)
if err := runDaemon(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected daemon config error")
}
}
// TestBuildOrchestratorFailsForMissingConfig runs one orchestration or CLI step.
// Signature: TestBuildOrchestratorFailsForMissingConfig(t *testing.T).
// Why: covers config-load failure branch in orchestrator builder.
func TestBuildOrchestratorFailsForMissingConfig(t *testing.T) {
logger := log.New(io.Discard, "", 0)
if _, _, err := buildOrchestrator(logger, filepath.Join(t.TempDir(), "missing.yaml"), true); err == nil {
t.Fatalf("expected missing-config error")
}
}
// TestEnsureStartupPowerSafeTimeoutWithNoTelemetry runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafeTimeoutWithNoTelemetry(t *testing.T).
// Why: covers timeout branch where telemetry never succeeds.
func TestEnsureStartupPowerSafeTimeoutWithNoTelemetry(t *testing.T) {
targets := []service.Target{
{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
Provider: &fakeUPSProvider{errs: []error{context.DeadlineExceeded, context.DeadlineExceeded}},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err == nil {
t.Fatalf("expected startup safety timeout")
}
}
// TestLoadStartupStatusSnapshotPrefersProgress runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotPrefersProgress(t *testing.T).
// Why: status polling should read the live startup progress file when both
// progress and terminal reports exist.
func TestLoadStartupStatusSnapshotPrefersProgress(t *testing.T) {
tmp := t.TempDir()
progress := startupStatusSnapshot{
Status: "running",
Phase: "convergence-checks",
Checks: map[string]startupCheckRecord{
"service-checklist": {Status: "running", Detail: "waiting"},
},
}
last := startupStatusSnapshot{
Status: "success",
Phase: "complete",
Checks: map[string]startupCheckRecord{},
}
writeStartupStatusFixture(t, filepath.Join(tmp, "startup-progress.json"), progress)
writeStartupStatusFixture(t, filepath.Join(tmp, "last-startup-report.json"), last)
got, source, err := loadStartupStatusSnapshot(tmp)
if err != nil {
t.Fatalf("load snapshot failed: %v", err)
}
if got == nil {
t.Fatalf("expected snapshot")
}
if !strings.HasSuffix(source, "startup-progress.json") {
t.Fatalf("expected progress source, got %s", source)
}
if got.Status != "running" || got.Phase != "convergence-checks" {
t.Fatalf("unexpected snapshot: %+v", got)
}
}
// TestLoadStartupStatusSnapshotFallsBackToLast runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotFallsBackToLast(t *testing.T).
// Why: when no live progress file exists, operators still need the latest
// terminal startup report for troubleshooting.
func TestLoadStartupStatusSnapshotFallsBackToLast(t *testing.T) {
tmp := t.TempDir()
last := startupStatusSnapshot{
Status: "failed",
Phase: "failed",
Error: "boom",
Checks: map[string]startupCheckRecord{"flux-health": {Status: "failed", Detail: "not ready"}},
}
writeStartupStatusFixture(t, filepath.Join(tmp, "last-startup-report.json"), last)
got, source, err := loadStartupStatusSnapshot(tmp)
if err != nil {
t.Fatalf("load snapshot failed: %v", err)
}
if got == nil {
t.Fatalf("expected snapshot")
}
if !strings.HasSuffix(source, "last-startup-report.json") {
t.Fatalf("expected last report source, got %s", source)
}
if got.Error != "boom" {
t.Fatalf("expected preserved error, got %+v", got)
}
}
// TestLoadStartupStatusSnapshotMissing runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotMissing(t *testing.T).
// Why: status should remain readable even before startup has written any report files.
func TestLoadStartupStatusSnapshotMissing(t *testing.T) {
got, source, err := loadStartupStatusSnapshot(t.TempDir())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil || source != "" {
t.Fatalf("expected empty result, got snapshot=%v source=%q", got, source)
}
}
// TestSummarizeChecklistCountsAndDetails runs one orchestration or CLI step.
// Signature: TestSummarizeChecklistCountsAndDetails(t *testing.T).
// Why: checklist counters power human-readable startup progress reporting, so
// counts and failed detail formatting must remain deterministic.
func TestSummarizeChecklistCountsAndDetails(t *testing.T) {
checks := map[string]startupCheckRecord{
"alpha": {Status: "passed"},
"beta": {Status: "failed", Detail: "timed out"},
"gamma": {Status: "running"},
"delta": {Status: "unknown"},
}
passed, failed, running, other, failedChecks := summarizeChecklist(checks)
if passed != 1 || failed != 1 || running != 1 || other != 1 {
t.Fatalf("unexpected counts: passed=%d failed=%d running=%d other=%d", passed, failed, running, other)
}
if len(failedChecks) != 1 || failedChecks[0] != "beta: timed out" {
t.Fatalf("unexpected failed details: %v", failedChecks)
}
}
// TestFormatMaybeTime runs one orchestration or CLI step.
// Signature: TestFormatMaybeTime(t *testing.T).
// Why: status output should keep zero values explicit without breaking timestamp formatting.
func TestFormatMaybeTime(t *testing.T) {
if got := formatMaybeTime(time.Time{}); got != "none" {
t.Fatalf("expected none for zero time, got %q", got)
}
stamp := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
if got := formatMaybeTime(stamp); got != "2026-01-02T03:04:05Z" {
t.Fatalf("unexpected formatted timestamp: %q", got)
}
}
// writeStartupStatusFixture runs one orchestration or CLI step.
// Signature: writeStartupStatusFixture(t *testing.T, path string, snapshot startupStatusSnapshot).
// Why: startup status fixture writes are shared by multiple tests and should be
// deterministic to keep failure output easy to inspect.
func writeStartupStatusFixture(t *testing.T, path string, snapshot startupStatusSnapshot) {
t.Helper()
if snapshot.Checks == nil {
snapshot.Checks = map[string]startupCheckRecord{}
}
b, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
t.Fatalf("encode fixture: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir fixture dir: %v", err)
}
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
// TestSummarizeChecklistSortsFailedChecks runs one orchestration or CLI step.
// Signature: TestSummarizeChecklistSortsFailedChecks(t *testing.T).
// Why: stable ordering keeps status output diff-friendly for operator drill notes.
func TestSummarizeChecklistSortsFailedChecks(t *testing.T) {
checks := map[string]startupCheckRecord{
"zeta": {Status: "failed", Detail: "z"},
"eta": {Status: "failed", Detail: "e"},
}
_, _, _, _, failedChecks := summarizeChecklist(checks)
expect := []string{"eta: e", "zeta: z"}
sort.Strings(failedChecks)
for i := range expect {
if failedChecks[i] != expect[i] {
t.Fatalf("unexpected failed check ordering: got=%v want=%v", failedChecks, expect)
}
}
}