package servicequality import ( "context" "errors" "io" "log" "net/http" "net/http/httptest" "strings" "testing" "time" "scm.bstein.dev/bstein/ananke/internal/config" "scm.bstein.dev/bstein/ananke/internal/metrics" "scm.bstein.dev/bstein/ananke/internal/service" ) // TestParseGitRepositories runs one orchestration or CLI step. // Signature: TestParseGitRepositories(t *testing.T). // Why: branch, revision, and Ready status are the labels the Grafana GitOps // panels rely on for the current Flux source. func TestParseGitRepositories(t *testing.T) { raw := []byte(`{ "items": [{ "metadata": {"name": "flux-system", "namespace": "flux-system"}, "spec": {"url": "ssh://git@example/repo.git", "ref": {"branch": "main"}}, "status": { "artifact": {"revision": "main@sha1:abc123"}, "conditions": [{"type": "Ready", "status": "True", "reason": "Succeeded"}] } }] }`) got, err := service.TestHookParseGitRepositories(raw) if err != nil { t.Fatalf("parseGitRepositories failed: %v", err) } if len(got) != 1 || got[0].Branch != "main" || got[0].Revision != "main@sha1:abc123" || !got[0].Ready { t.Fatalf("unexpected GitRepository parse result: %+v", got) } } // TestParseKustomizations runs one orchestration or CLI step. // Signature: TestParseKustomizations(t *testing.T). // Why: Kustomization source and suspend labels power the detailed GitOps table. func TestParseKustomizations(t *testing.T) { raw := []byte(`{ "items": [{ "metadata": {"name": "monitoring", "namespace": "flux-system"}, "spec": { "path": "./services/monitoring", "sourceRef": {"name": "flux-system"}, "suspend": true }, "status": { "lastAppliedRevision": "main@sha1:def456", "conditions": [{"type": "Ready", "status": "False", "reason": "DependencyNotReady"}] } }] }`) got, err := service.TestHookParseKustomizations(raw) if err != nil { t.Fatalf("parseKustomizations failed: %v", err) } if len(got) != 1 || got[0].SourceNamespace != "flux-system" || got[0].Ready || !got[0].Suspended { t.Fatalf("unexpected Kustomization parse result: %+v", got) } } // TestParseHelmReleases runs one orchestration or CLI step. // Signature: TestParseHelmReleases(t *testing.T). // Why: HelmRelease chart fields have separate status fallbacks from plain Flux // Kustomizations and need their own contract coverage. func TestParseHelmReleases(t *testing.T) { raw := []byte(`{ "items": [{ "metadata": {"name": "grafana", "namespace": "monitoring"}, "spec": { "chart": {"spec": {"chart": "grafana", "version": "~8.5.0"}} }, "status": { "lastAttemptedRevision": "8.5.8", "history": [{"chartName": "grafana", "chartVersion": "8.5.8", "appVersion": "11.4.0", "digest": "sha256:abc"}], "conditions": [{"type": "Ready", "status": "True", "reason": "InstallSucceeded"}] } }] }`) got, err := service.TestHookParseHelmReleases(raw) if err != nil { t.Fatalf("parseHelmReleases failed: %v", err) } if len(got) != 1 || got[0].Chart != "grafana" || got[0].Version != "8.5.8" || got[0].Revision != "sha256:abc" || !got[0].Ready { t.Fatalf("unexpected HelmRelease parse result: %+v", got) } } // TestCollectGitOpsSnapshotPartialFailure runs one orchestration or CLI step. // Signature: TestCollectGitOpsSnapshotPartialFailure(t *testing.T). // Why: a recovering cluster may serve some Flux resource families before // others, and Ananke should publish a partial snapshot instead of failing closed. func TestCollectGitOpsSnapshotPartialFailure(t *testing.T) { runner := func(_ context.Context, _ string, args ...string) ([]byte, error) { joined := strings.Join(args, " ") switch { case strings.Contains(joined, "gitrepositories"): return []byte(`{"items":[]}`), nil case strings.Contains(joined, "kustomizations"): return nil, errors.New("api unavailable") case strings.Contains(joined, "helmreleases"): return []byte(`{"items":[]}`), nil default: return nil, errors.New("unexpected command") } } snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{Kubeconfig: "/tmp/kubeconfig"}, runner) if snapshot.ScrapeSuccess { t.Fatalf("expected partial failure") } if snapshot.Errors["kustomization"] == "" { t.Fatalf("expected kustomization error, got %+v", snapshot.Errors) } if summary := service.TestHookGitOpsErrorSummary(snapshot.Errors); !strings.Contains(summary, "kustomization=api unavailable") { t.Fatalf("unexpected error summary: %s", summary) } } // TestCollectGitOpsSnapshotParseFailures runs one orchestration or CLI step. // Signature: TestCollectGitOpsSnapshotParseFailures(t *testing.T). // Why: malformed API payloads should be surfaced per resource family so the // exporter can distinguish data errors from transport failures. func TestCollectGitOpsSnapshotParseFailures(t *testing.T) { runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) { return []byte(`not-json`), nil } snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner) if snapshot.ScrapeSuccess { t.Fatalf("expected parse failures") } for _, key := range []string{"gitrepository", "kustomization", "helmrelease"} { if snapshot.Errors[key] == "" { t.Fatalf("expected %s parse error, got %+v", key, snapshot.Errors) } } } // TestCollectGitOpsSnapshotSuccess runs one orchestration or CLI step. // Signature: TestCollectGitOpsSnapshotSuccess(t *testing.T). // Why: successful collection should preserve non-branch Git refs as the source // label rather than hiding them as unknown. func TestCollectGitOpsSnapshotSuccess(t *testing.T) { runner := func(_ context.Context, _ string, args ...string) ([]byte, error) { joined := strings.Join(args, " ") switch { case strings.Contains(joined, "gitrepositories"): return []byte(`{"items":[{"metadata":{"name":"flux-system","namespace":"flux-system"},"spec":{"ref":{"tag":"v1.0.0"}}}]}`), nil case strings.Contains(joined, "kustomizations"): return []byte(`{"items":[]}`), nil case strings.Contains(joined, "helmreleases"): return []byte(`{"items":[]}`), nil default: return nil, errors.New("unexpected command") } } snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner) if !snapshot.ScrapeSuccess || len(snapshot.FluxSources) != 1 || snapshot.FluxSources[0].Branch != "v1.0.0" { t.Fatalf("unexpected snapshot: %+v", snapshot) } } // TestMaybeStartGitOpsSnapshot runs one orchestration or CLI step. // Signature: TestMaybeStartGitOpsSnapshot(t *testing.T). // Why: Ananke must collect GitOps status asynchronously so a slow Kubernetes API // cannot delay UPS polling. func TestMaybeStartGitOpsSnapshot(t *testing.T) { runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) { return []byte(`{"items":[]}`), nil } exporter := metrics.New() done := make(chan struct{}, 1) lastRun := time.Time{} cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 1}} withRunner := func() bool { return service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner).ScrapeSuccess } if !withRunner() { t.Fatalf("expected injected runner sanity check to pass") } if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, exporter, log.New(io.Discard, "", 0), &lastRun, false, done, runner); !running { t.Fatalf("expected scrape to start") } select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("timed out waiting for scrape") } req := httptest.NewRequest(http.MethodGet, "/metrics", nil) rr := httptest.NewRecorder() exporter.Handler("/metrics").ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "ananke_gitops_scrape_success") { t.Fatalf("expected GitOps scrape metric, got:\n%s", rr.Body.String()) } if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, exporter, log.New(io.Discard, "", 0), &lastRun, true, done); !running { t.Fatalf("running scrape should stay marked running") } cfg.GitOps.Enabled = false if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, exporter, nil, &lastRun, false, done); running { t.Fatalf("disabled GitOps scrape should not start") } } // TestRunGitOpsKubectlOutput runs one orchestration or CLI step. // Signature: TestRunGitOpsKubectlOutput(t *testing.T). // Why: command diagnostics should preserve stderr for operator-visible errors. func TestRunGitOpsKubectlOutput(t *testing.T) { out, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "printf ok") if err != nil || string(out) != "ok" { t.Fatalf("unexpected command success result: out=%q err=%v", out, err) } if _, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "echo bad >&2; exit 7"); err == nil || !strings.Contains(err.Error(), "bad") { t.Fatalf("expected stderr to be preserved in error, got %v", err) } if _, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "exit 8"); err == nil { t.Fatalf("expected empty-stderr command failure") } } // TestGitOpsParseErrors runs one orchestration or CLI step. // Signature: TestGitOpsParseErrors(t *testing.T). // Why: invalid JSON should return parse errors for every Flux resource parser. func TestGitOpsParseErrors(t *testing.T) { if _, err := service.TestHookParseGitRepositories([]byte(`not-json`)); err == nil { t.Fatalf("expected GitRepository parse error") } if _, err := service.TestHookParseKustomizations([]byte(`not-json`)); err == nil { t.Fatalf("expected Kustomization parse error") } if _, err := service.TestHookParseHelmReleases([]byte(`not-json`)); err == nil { t.Fatalf("expected HelmRelease parse error") } } // TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter runs one // orchestration or CLI step. // Signature: TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter(t *testing.T). // Why: disabled or already-fresh scrapes should be cheap no-ops in the daemon // loop. func TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter(t *testing.T) { cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 60}} done := make(chan struct{}, 1) lastRun := time.Now() if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, nil, nil, &lastRun, false, done); running { t.Fatalf("nil exporter should not start scrape") } if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done); running { t.Fatalf("fresh sample should not start another scrape") } runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) { return []byte(`{"items":[]}`), nil } if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, true, done, runner); !running { t.Fatalf("already-running scrape should remain marked running") } cfg.GitOps.Enabled = false lastRun = time.Time{} if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done, runner); running { t.Fatalf("disabled scrape should not start through runner hook") } } // TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger runs one // orchestration or CLI step. // Signature: TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger(t *testing.T). // Why: scrape failure handling should not depend on a logger being present. func TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger(t *testing.T) { done := make(chan struct{}, 1) lastRun := time.Time{} cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 1}} runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) { return nil, errors.New("api down") } if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done, runner); !running { t.Fatalf("expected scrape to start") } select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("timed out waiting for failed scrape") } } // TestGitOpsHelpers runs one orchestration or CLI step. // Signature: TestGitOpsHelpers(t *testing.T). // Why: tiny label helpers carry dashboard readability contracts and are worth // locking down. func TestGitOpsHelpers(t *testing.T) { if got := service.TestHookGitOpsDefaultString(" ", "fallback"); got != "fallback" { t.Fatalf("unexpected defaultString fallback: %q", got) } if got := service.TestHookGitOpsFirstNonEmpty(" ", "second"); got != "second" { t.Fatalf("unexpected firstNonEmpty result: %q", got) } if got := service.TestHookGitOpsFirstNonEmpty(" ", ""); got != "" { t.Fatalf("unexpected empty firstNonEmpty result: %q", got) } if got := service.TestHookGitOpsErrorSummary(nil); got != "none" { t.Fatalf("unexpected empty summary: %q", got) } ready, reason := service.TestHookFluxReadyMissing() if ready || reason != "ReadyMissing" { t.Fatalf("unexpected empty Ready condition parse: ready=%t reason=%s", ready, reason) } }