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) } } }