From 2cef170acf37316c1a2df94933d63ec7ab06e4da Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Tue, 6 Oct 2026 23:54:08 +0200 Subject: [PATCH] simplify plan view tests - keep one owner for each golden file - move full json documents into goldens - rename style role traces to human names --- internal/plan/view/helpers_test.go | 30 +++- internal/plan/view/human_chart_crd_test.go | 7 - internal/plan/view/human_norm_test.go | 24 ++- internal/plan/view/human_semantic_test.go | 61 ++------ internal/plan/view/human_style_test.go | 19 ++- internal/plan/view/human_test.go | 128 +++------------- internal/plan/view/json_test.go | 138 +++++++----------- internal/plan/view/project_internal_test.go | 62 +++++--- internal/plan/view/redact_test.go | 50 ------- internal/plan/view/schema_test.go | 20 ++- ...s.golden => human_chart_crds.roles.golden} | 0 ...reate.golden => human_create.roles.golden} | 0 ...elete.golden => human_delete.roles.golden} | 0 ...olden => human_drift_missing.roles.golden} | 0 ...lden => human_drift_modified.roles.golden} | 0 ...en => human_drift_unexpected.roles.golden} | 0 ..._tasks.golden => human_tasks.roles.golden} | 0 ...pdate.golden => human_update.roles.golden} | 0 .../view/testdata/golden/json_empty.golden | 20 +++ .../view/testdata/golden/json_tasks.golden | 52 +++++++ internal/plan/view/yaml_test.go | 2 +- 21 files changed, 257 insertions(+), 356 deletions(-) rename internal/plan/view/testdata/golden/{style_chart_crds.golden => human_chart_crds.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_create.golden => human_create.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_delete.golden => human_delete.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_drift_missing.golden => human_drift_missing.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_drift_modified.golden => human_drift_modified.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_drift_unexpected.golden => human_drift_unexpected.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_tasks.golden => human_tasks.roles.golden} (100%) rename internal/plan/view/testdata/golden/{style_update.golden => human_update.roles.golden} (100%) create mode 100644 internal/plan/view/testdata/golden/json_empty.golden create mode 100644 internal/plan/view/testdata/golden/json_tasks.golden diff --git a/internal/plan/view/helpers_test.go b/internal/plan/view/helpers_test.go index e4ae118..df95bb3 100644 --- a/internal/plan/view/helpers_test.go +++ b/internal/plan/view/helpers_test.go @@ -27,6 +27,8 @@ import ( "deployah.dev/deployah/internal/plan/semantic" "deployah.dev/deployah/internal/plan/view" + + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) var update = flag.Bool("update", false, "update golden files") @@ -367,17 +369,35 @@ func assertJSONPaths(tb testing.TB, raw []byte, wants []jsonPathWant) { } // assertGolden is the primary golden contract: got must equal -// testdata/golden/.golden in full. Contains checks in callers are -// secondary invariants only. A normal run never skips the comparison. -// -update rewrites the file from got, then the same equality still runs. +// testdata/golden/.golden in full. A mismatch stops the test. +// A normal run never skips the comparison. -update rewrites the file +// from got, then the same equality still runs. func assertGolden(t *testing.T, name, got string) { t.Helper() - assert.Equal(t, string(readGolden(t, name, got)), got) + require.Equal(t, string(readGolden(t, name, got)), got) } func assertJSONGolden(t *testing.T, name, got string) { t.Helper() - assert.JSONEq(t, string(readGolden(t, name, got)), got) + require.JSONEq(t, string(readGolden(t, name, got)), got) +} + +func compilePlanSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + compiler := jsonschema.NewCompiler() + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(view.SchemaV1())) + require.NoError(t, err) + require.NoError(t, compiler.AddResource(view.SchemaV1ID, doc)) + sch, err := compiler.Compile(view.SchemaV1ID) + require.NoError(t, err) + return sch +} + +func validatePlanSchema(t *testing.T, raw []byte) { + t.Helper() + var v any + require.NoError(t, json.Unmarshal(raw, &v)) + require.NoError(t, compilePlanSchema(t).Validate(v)) } func readGolden(t *testing.T, name, got string) []byte { diff --git a/internal/plan/view/human_chart_crd_test.go b/internal/plan/view/human_chart_crd_test.go index 9f2ec22..3541532 100644 --- a/internal/plan/view/human_chart_crd_test.go +++ b/internal/plan/view/human_chart_crd_test.go @@ -45,7 +45,6 @@ func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { action semantic.HelmAction lc semantic.ChartCRDLifecycle golden string - note string }{ { name: "process", @@ -53,7 +52,6 @@ func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { action: semantic.HelmInstall, lc: semantic.ChartCRDProcess, golden: "human_chart_crds_process", - note: "lifecycle: process (Helm install will process this chart CRD)", }, { name: "skip", @@ -61,7 +59,6 @@ func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { action: semantic.HelmInstall, lc: semantic.ChartCRDSkip, golden: "human_chart_crds_skip", - note: "lifecycle: skip (Helm install will skip this chart CRD)", }, { name: "upgrade", @@ -69,7 +66,6 @@ func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { action: semantic.HelmUpgrade, lc: semantic.ChartCRDUpgrade, golden: "human_chart_crds_upgrade", - note: "lifecycle: upgrade (Helm upgrade does not process chart CRDs)", }, } for _, tt := range tests { @@ -80,9 +76,6 @@ func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { }) text := writeHuman(t, p) assertGolden(t, tt.golden, text) - assertHumanLayout(t, text) - assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") - assert.Contains(t, text, tt.note) assertChartCRDSectionHasNoDiff(t, chartCRDSection(t, text)) }) } diff --git a/internal/plan/view/human_norm_test.go b/internal/plan/view/human_norm_test.go index e75cd4d..35dc779 100644 --- a/internal/plan/view/human_norm_test.go +++ b/internal/plan/view/human_norm_test.go @@ -28,9 +28,13 @@ import ( func TestWriteHuman_SecretDiffs(t *testing.T) { t.Parallel() addAfter := secretObj("s", "same-pass", "same-tok") - secretData(t, addAfter)["extra"] = "added-secret" + addData, ok := addAfter["data"].(map[string]any) + require.True(t, ok) + addData["extra"] = "added-secret" removeBefore := secretObj("s", "same-pass", "same-tok") - secretData(t, removeBefore)["extra"] = "removed-secret" + removeData, ok := removeBefore["data"].(map[string]any) + require.True(t, ok) + removeData["extra"] = "removed-secret" emptySecret := map[string]any{ "apiVersion": "v1", "kind": "Secret", @@ -166,10 +170,9 @@ func TestWriteHuman_OmitsBookkeepingFields(t *testing.T) { After: snap(after), }}) - var human, jsonBuf bytes.Buffer - require.NoError(t, view.WriteHuman(&human, p, view.Options{})) + text := writeHuman(t, p) + var jsonBuf bytes.Buffer require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) - text := human.String() assertNoBookkeeping(t, text) assert.Contains(t, text, "- key: v1") assert.Contains(t, text, "+ key: v2") @@ -210,9 +213,7 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, tt.changes) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertNoBookkeeping(t, text) assertKeepsUserMeta(t, text) for _, line := range tt.want { @@ -221,10 +222,3 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { }) } } - -func secretData(t *testing.T, obj map[string]any) map[string]any { - t.Helper() - m, ok := obj["data"].(map[string]any) - require.True(t, ok) - return m -} diff --git a/internal/plan/view/human_semantic_test.go b/internal/plan/view/human_semantic_test.go index 83bc4f9..98c394d 100644 --- a/internal/plan/view/human_semantic_test.go +++ b/internal/plan/view/human_semantic_test.go @@ -61,31 +61,11 @@ func TestWriteHuman_SemanticPlan(t *testing.T) { t.Parallel() p := semanticUpgradePlan(t, previousProductSpec(), currentProductSpec()) text := writeHuman(t, p) - assertGolden(t, "human_semantic_plan", text) // full-output contract; Contains below are invariants only - assertHumanLayout(t, text) - assertSemanticHeader(t, text, 2) + assertGolden(t, "human_semantic_plan", text) assert.Equal(t, "web-prod", p.Header.Release) assert.Equal(t, productNamespace, p.Header.Namespace) assert.False(t, p.Header.FreshInstall) - assert.Contains(t, text, `~ update apps/v1/Deployment "web-prod-api"`) - assert.Contains(t, text, `+ create apps/v1/Deployment "web-prod-worker"`) - assert.Contains(t, text, `- delete apps/v1/Deployment "web-prod-legacy"`) - assert.Contains(t, text, "deployah.dev/project: web") - assert.Contains(t, text, "deployah.dev/environment: prod") - assert.Contains(t, text, "deployah.dev/instance: web-prod") - assert.Contains(t, text, "app.kubernetes.io/managed-by: Helm") - assert.Contains(t, text, "helm.sh/chart:") - assert.Contains(t, text, "helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded") - assert.Contains(t, text, " preDeploy") - assert.Contains(t, text, "~ migrate changed, will run") - assert.Contains(t, text, " postDeploy") - assert.Contains(t, text, "+ smoke new, will run") - assert.Contains(t, text, "- old-check removed") - assert.Contains(t, text, " schedule") - assert.Contains(t, text, "~ cleanup changed") assert.NotContains(t, text, "~ cleanup changed, will run") - assert.Contains(t, text, `- schedule: 0 2 * * *`) - assert.Contains(t, text, `+ schedule: 0 3 * * *`) assertCronJobOnlyUnderTasks(t, text, "web-prod-cleanup") assert.NotContains(t, text, `create v1/Namespace`) assert.NotContains(t, text, "-/+") @@ -145,20 +125,6 @@ func TestWriteHuman_SemanticScheduleDelete(t *testing.T) { assert.NotContains(t, text, "-/+") } -func assertSemanticHeader(t *testing.T, text string, revision int) { - t.Helper() - block := fmt.Sprintf("Context: %s\nNamespace: %s\nRelease: web-prod\nRevision: %d\n", productClusterContext, productNamespace, revision) - assert.Contains(t, text, block) - contextIdx := strings.Index(text, "Context:") - nsIdx := strings.Index(text, "Namespace:") - relIdx := strings.Index(text, "Release:") - revIdx := strings.Index(text, "Revision:") - require.Greater(t, contextIdx, -1) - assert.Greater(t, nsIdx, contextIdx) - assert.Greater(t, relIdx, nsIdx) - assert.Greater(t, revIdx, relIdx) -} - func assertCronJobOnlyUnderTasks(t *testing.T, text, name string) { t.Helper() needle := fmt.Sprintf(`batch/v1/CronJob %q`, name) @@ -348,7 +314,18 @@ func previousReleaseFromRender(t *testing.T, result *render.RenderResult) *v1.Re if values == nil { values = map[string]any{} } - tasks, ok := nestedMap(values, "deployah", "resolved", "tasks") + cur := values + var tasks map[string]any + ok := true + for _, key := range []string{"deployah", "resolved", "tasks"} { + next, isMap := cur[key].(map[string]any) + if !isMap || next == nil { + ok = false + break + } + cur = next + tasks = next + } require.True(t, ok && len(tasks) > 0, "previous chart values must include deployah.resolved.tasks") return &v1.Release{ Name: result.ReleaseName, @@ -360,18 +337,6 @@ func previousReleaseFromRender(t *testing.T, result *render.RenderResult) *v1.Re } } -func nestedMap(root map[string]any, keys ...string) (map[string]any, bool) { - cur := root - for _, key := range keys { - next, ok := cur[key].(map[string]any) - if !ok || next == nil { - return nil, false - } - cur = next - } - return cur, true -} - func buildSemanticPlan(t *testing.T, client plan.SemanticBuildClient, resolved *spec.ResolvedSpec, live plan.LiveReader) semantic.Plan { t.Helper() p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, productMapper{}, live, plan.SemanticBuildInput{ diff --git a/internal/plan/view/human_style_test.go b/internal/plan/view/human_style_test.go index 373dd71..eff211b 100644 --- a/internal/plan/view/human_style_test.go +++ b/internal/plan/view/human_style_test.go @@ -67,8 +67,7 @@ func TestWriteHuman_StylerPreservesLayout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - plain := assertRoleTrace(t, tt.golden+".roles", tt.plan) - assertGolden(t, tt.golden, plain) + assertRoleTrace(t, tt.golden+".roles", tt.plan) }) } } @@ -155,7 +154,7 @@ func TestWriteHuman_StyleRoles(t *testing.T) { }{ { name: "create", - golden: "style_create", + golden: "human_create.roles", plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), Action: semantic.Create, @@ -164,7 +163,7 @@ func TestWriteHuman_StyleRoles(t *testing.T) { }, { name: "delete", - golden: "style_delete", + golden: "human_delete.roles", plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "old"), Action: semantic.Delete, @@ -173,7 +172,7 @@ func TestWriteHuman_StyleRoles(t *testing.T) { }, { name: "update", - golden: "style_update", + golden: "human_update.roles", plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "web"), Action: semantic.Update, @@ -181,11 +180,11 @@ func TestWriteHuman_StyleRoles(t *testing.T) { After: snap(cm("web", "v2")), }}, nil), }, - {name: "drift unexpected", golden: "style_drift_unexpected", plan: unexpected}, - {name: "drift missing", golden: "style_drift_missing", plan: missing}, - {name: "drift modified", golden: "style_drift_modified", plan: modified}, - {name: "tasks", golden: "style_tasks", plan: tasks}, - {name: "chart crds", golden: "style_chart_crds", plan: chartCRDs}, + {name: "drift unexpected", golden: "human_drift_unexpected.roles", plan: unexpected}, + {name: "drift missing", golden: "human_drift_missing.roles", plan: missing}, + {name: "drift modified", golden: "human_drift_modified.roles", plan: modified}, + {name: "tasks", golden: "human_tasks.roles", plan: tasks}, + {name: "chart crds", golden: "human_chart_crds.roles", plan: chartCRDs}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/plan/view/human_test.go b/internal/plan/view/human_test.go index f926197..ab94741 100644 --- a/internal/plan/view/human_test.go +++ b/internal/plan/view/human_test.go @@ -30,52 +30,11 @@ import ( func TestWriteHuman_CreateUpdateDelete(t *testing.T) { t.Parallel() - p := allActionsPlan(t) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() - assertGolden(t, "human_all_actions", text) // full-output contract; Contains below are invariants only - assertHumanLayout(t, text) - assertHumanHeaderMetadata(t, text, "production-eu", 12) - assert.Contains(t, text, "Resources") - assert.Contains(t, text, `+ create v1/ConfigMap "app"`) - assert.Contains(t, text, "+ example.com/keep: yes") - assert.Contains(t, text, "+ app: web") - assert.Contains(t, text, "+ key: v1") - assert.Contains(t, text, `~ update v1/ConfigMap "web"`) - assert.Contains(t, text, `- delete v1/ConfigMap "old"`) - assert.Contains(t, text, "- example.com/keep: yes") - assert.Contains(t, text, "- app: web") - assert.Contains(t, text, "- key: v1") - assert.Contains(t, text, "Tasks") - assert.Contains(t, text, " preDeploy") - assert.Contains(t, text, "~ migrate changed, will run") - assert.Contains(t, text, `~ update v1/ConfigMap "web-migrate-env"`) - assert.Contains(t, text, " seed will run") - assert.Contains(t, text, " postDeploy") - assert.Contains(t, text, "+ smoke new, will run") - assert.Contains(t, text, `+ create batch/v1/Job "web-smoke"`) - assert.Contains(t, text, "deployah.dev/task: smoke") - assert.Contains(t, text, "helm.sh/hook: post-install,post-upgrade") - assert.Contains(t, text, `helm.sh/hook-weight: "0"`) - assert.Contains(t, text, "backoffLimit: 1") - assert.Contains(t, text, "image: ghcr.io/example/web:1.2.3") - assert.Contains(t, text, "- ./smoke") - assert.Contains(t, text, "- old-check removed") - assert.Contains(t, text, `- delete batch/v1/Job "web-old-check"`) - assert.Contains(t, text, "deployah.dev/task: old-check") - assert.Contains(t, text, `helm.sh/hook-weight: "1"`) - assert.Contains(t, text, "- ./old-check") - assert.Contains(t, text, " schedule") - assert.Contains(t, text, "~ cleanup changed") + text := writeHuman(t, allActionsPlan(t)) + assertGolden(t, "human_all_actions", text) assert.NotContains(t, text, "~ cleanup changed, will run") - assert.Contains(t, text, `~ update batch/v1/CronJob "web-cleanup"`) - assert.Contains(t, text, "- schedule: 0 2 * * *") - assert.Contains(t, text, "+ schedule: 0 3 * * *") assert.NotContains(t, text, "jobTemplate:") assert.Equal(t, 1, strings.Count(text, `batch/v1/CronJob "web-cleanup"`)) - assert.Contains(t, text, " Resources: 1 create, 2 update, 1 delete") - assert.Contains(t, text, " Tasks: 3 to run, 1 schedule changed") assert.NotContains(t, text, "~ ConfigMap/prod/rs") assert.NotContains(t, text, "-/+") assert.NotContains(t, strings.ToLower(text), "recreate") @@ -86,10 +45,6 @@ func TestWriteHuman_CreateUpdateDelete(t *testing.T) { assert.NotContains(t, text, "field_manager=") assert.NotContains(t, text, "force_conflicts=") assert.NotContains(t, text, "delete=") - assert.Contains(t, text, "+ apiVersion: v1") - assert.Contains(t, text, "- apiVersion: v1") - assert.Contains(t, text, "- key: v1") - assert.Contains(t, text, "+ key: v2") assert.NotContains(t, text, "before:") assert.NotContains(t, text, "after:") assert.NotContains(t, text, "--- before") @@ -111,10 +66,7 @@ func TestWriteHuman_DeterministicMapOrder(t *testing.T) { Action: semantic.Create, After: snap(second), }}) - var b1, b2 bytes.Buffer - require.NoError(t, view.WriteHuman(&b1, p1, view.Options{})) - require.NoError(t, view.WriteHuman(&b2, p2, view.Options{})) - assert.Equal(t, b1.String(), b2.String()) + assert.Equal(t, writeHuman(t, p1), writeHuman(t, p2)) } func TestWriteHuman_DoesNotHTMLEscape(t *testing.T) { @@ -125,9 +77,7 @@ func TestWriteHuman_DoesNotHTMLEscape(t *testing.T) { Before: snap(map[string]any{"note": "plain"}), After: snap(map[string]any{"note": map[string]any{"html": "a < b & c"}}), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assert.Contains(t, text, `a < b & c`) assert.NotContains(t, text, `\u003c`) assert.NotContains(t, text, `\u0026`) @@ -172,9 +122,7 @@ func TestWriteHuman_KubernetesKeyOrder(t *testing.T) { Action: semantic.Create, After: snap(obj), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) api := strings.Index(text, "+ apiVersion: apps/v1") kind := strings.Index(text, "+ kind: Deployment") meta := strings.Index(text, "+ metadata:") @@ -217,9 +165,7 @@ func TestWriteHuman_GenerateNameBeforeNamespace(t *testing.T) { Action: semantic.Create, After: snap(obj), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) gen := strings.Index(text, "+ generateName: app-") ns := strings.Index(text, "+ namespace: prod") require.Greater(t, gen, -1) @@ -240,9 +186,7 @@ func TestWriteHuman_ArrayOrderPreserved(t *testing.T) { Action: semantic.Create, After: snap(obj), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) zeta := strings.Index(text, "zeta") alpha := strings.Index(text, "alpha") mu := strings.Index(text, "mu") @@ -288,9 +232,7 @@ func TestWriteHuman_HeaderMetadata(t *testing.T) { helmAction = semantic.HelmInstall } p := mustPlanWithHeader(t, tt.header, helmAction, nil, nil) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertHumanLayout(t, text) assertHumanHeaderMetadata(t, text, tt.context, tt.revision) assert.Equal(t, tt.context, p.Header.Context) @@ -306,9 +248,7 @@ func TestWriteHuman_HeaderMetadata(t *testing.T) { func TestWriteHuman_EmptyPlan(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmNone, nil) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assert.Contains(t, text, `Plan for project "web" on environment "prod"`) assert.NotContains(t, text, "completeness:") assert.NotContains(t, text, "Executions:") @@ -385,9 +325,7 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) for _, want := range tt.contains { assert.Contains(t, text, want) } @@ -444,9 +382,7 @@ func TestWriteHuman_GVKAndNamespaceHeadings(t *testing.T) { Action: semantic.Create, After: snap(k8sObj(tt.ref.APIVersion, tt.ref.Kind, tt.ref.Namespace, tt.ref.Name)), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assert.Contains(t, text, tt.heading) for _, omit := range tt.omits { assert.NotContains(t, text, omit) @@ -545,9 +481,7 @@ func TestWriteHuman_UpdateProjection(t *testing.T) { Before: snap(tt.before), After: snap(tt.after), }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) for _, want := range tt.contains { assert.Contains(t, text, want) } @@ -590,9 +524,7 @@ func TestWriteHuman_ScheduledTaskNotDuplicated(t *testing.T) { Action: semantic.TaskUpdate, Resources: []semantic.ResourceRef{cron}, }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assert.Contains(t, text, "Resources") assert.Contains(t, text, `+ create v1/ConfigMap "api"`) assert.Contains(t, text, "Tasks") @@ -672,9 +604,7 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() p := mustPlanWithTasks(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, []semantic.TaskPlan{tt.task}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertHumanLayout(t, text) assert.Contains(t, text, "Tasks") assert.Contains(t, text, " schedule") @@ -697,9 +627,7 @@ func TestWriteHuman_UnchangedTaskWillRun(t *testing.T) { Action: semantic.TaskUnchanged, WillRun: true, }}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assert.Contains(t, text, "seed will run") assert.NotContains(t, text, "apiVersion") assertHumanLayout(t, text) @@ -750,9 +678,7 @@ func TestWriteHuman_TaskFooter(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() p := mustPlanWithTasks(t, semantic.HelmUpgrade, nil, tt.tasks) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertHumanLayout(t, text) assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") for _, s := range tt.wantContains { @@ -857,9 +783,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, tt.plan, view.Options{})) - text := buf.String() + text := writeHuman(t, tt.plan) assertHumanLayout(t, text) for _, want := range tt.contains { assert.Contains(t, text, want) @@ -872,9 +796,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { func TestWriteHuman_SummaryOmitsTasksWhenAbsent(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertHumanLayout(t, text) assert.Contains(t, text, " Resources: 1 create, 0 update, 0 delete") assert.NotContains(t, text, " Tasks:") @@ -1000,16 +922,8 @@ func humanDriftPlan(t *testing.T) semantic.Plan { func TestWriteHuman_DriftSection(t *testing.T) { t.Parallel() p := humanDriftPlan(t) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() + text := writeHuman(t, p) assertGolden(t, "human_drift", text) - assert.Contains(t, text, "\nDrift\n") - assert.Contains(t, text, "~ modified v1/ConfigMap \"app\"") - assert.Contains(t, text, "- missing v1/ConfigMap \"other\"") - assert.Contains(t, text, "+ unexpected v1/ConfigMap \"extra\"") - assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") - assert.Contains(t, text, " Drift: 1 modified, 1 missing, 1 unexpected") assert.Less(t, strings.Index(text, "\nDrift\n"), strings.Index(text, "\nSummary\n")) assert.NotContains(t, text, "Resources\n") } @@ -1017,9 +931,7 @@ func TestWriteHuman_DriftSection(t *testing.T) { func TestWriteHuman_EmptyDriftHasNoSection(t *testing.T) { t.Parallel() p := mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - assert.NotContains(t, buf.String(), "Drift") + assert.NotContains(t, writeHuman(t, p), "Drift") } func TestWriteHuman_DriftSecretNormalizedPath(t *testing.T) { diff --git a/internal/plan/view/json_test.go b/internal/plan/view/json_test.go index 73acc2e..9b1f8b0 100644 --- a/internal/plan/view/json_test.go +++ b/internal/plan/view/json_test.go @@ -25,8 +25,6 @@ import ( "deployah.dev/deployah/internal/plan/semantic" "deployah.dev/deployah/internal/plan/view" - - jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) func TestWriteJSON_HeaderContextAndRevision(t *testing.T) { @@ -44,34 +42,13 @@ func TestWriteJSON_SchemaAndTasks(t *testing.T) { p := mustPlan(t, semantic.HelmNone, nil) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) - assert.JSONEq(t, `{ - "schema": "https://deployah.dev/schemas/plan/v1/schema.json", - "header": { - "project": "web", - "environment": "prod", - "release": "web", - "namespace": "prod" - }, - "helmAction": "none", - "changes": [], - "drift": [], - "tasks": [], - "chartCRDs": [], - "summary": { - "create": 0, - "update": 0, - "delete": 0, - "total": 0 - } - }`, buf.String()) + assertJSONGolden(t, "json_empty", buf.String()) var doc map[string]any require.NoError(t, json.Unmarshal(buf.Bytes(), &doc)) - _, hasCompleteness := doc["completeness"] - assert.False(t, hasCompleteness) + assert.NotContains(t, doc, "completeness") summary, ok := doc["summary"].(map[string]any) require.True(t, ok) - _, hasReplace := summary["replace"] - assert.False(t, hasReplace) + assert.NotContains(t, summary, "replace") assertNoJSONKeysFromBytes(t, buf.Bytes()) validatePlanSchema(t, buf.Bytes()) } @@ -109,39 +86,7 @@ func TestWriteJSON_TasksContract(t *testing.T) { }}) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) - assert.JSONEq(t, `{ - "schema": "https://deployah.dev/schemas/plan/v1/schema.json", - "header": { - "project": "web", - "environment": "prod", - "release": "web", - "namespace": "prod" - }, - "helmAction": "upgrade", - "changes": [], - "drift": [], - "tasks": [{ - "name": "migrate", - "phase": "preDeploy", - "action": "create", - "willRun": true, - "definitions": [{ - "resource": {"apiVersion": "v1", "kind": "Job", "namespace": "prod", "name": "migrate"}, - "action": "create", - "before": null, - "after": { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": "migrate", "namespace": "prod"}, - "data": {"key": "v1"} - }, - "fields": [] - }], - "resources": [] - }], - "chartCRDs": [], - "summary": {"create": 0, "update": 0, "delete": 0, "total": 0} - }`, buf.String()) + assertJSONGolden(t, "json_tasks", buf.String()) validatePlanSchema(t, buf.Bytes()) assertNoJSONKeysFromBytes(t, buf.Bytes()) } @@ -445,31 +390,6 @@ func TestWriteJSON_SnapshotMayUseProtocolKeyNames(t *testing.T) { validatePlanSchema(t, buf.Bytes()) } -func compilePlanSchema(t *testing.T) *jsonschema.Schema { - t.Helper() - compiler := jsonschema.NewCompiler() - doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(view.SchemaV1())) - require.NoError(t, err) - require.NoError(t, compiler.AddResource(view.SchemaV1ID, doc)) - sch, err := compiler.Compile(view.SchemaV1ID) - require.NoError(t, err) - return sch -} - -func validatePlanSchema(t *testing.T, raw []byte) { - t.Helper() - var v any - require.NoError(t, json.Unmarshal(raw, &v)) - require.NoError(t, compilePlanSchema(t).Validate(v)) -} - -func assertSchemaRejects(t *testing.T, raw []byte) { - t.Helper() - var v any - require.NoError(t, json.Unmarshal(raw, &v)) - require.Error(t, compilePlanSchema(t).Validate(v)) -} - func assertNoJSONKeysFromBytes(t *testing.T, raw []byte) { t.Helper() var doc map[string]any @@ -687,6 +607,56 @@ func assertNoJSONFieldKeys(t *testing.T, raw any) { } } +func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { + t.Parallel() + p := semantic.Plan{ + HelmAction: semantic.HelmUpgrade, + Header: semantic.Header{Release: "web"}, + Changes: []semantic.ResourceChange{ + { + Resource: ref("ConfigMap", "app"), + Action: semantic.Create, + After: &semantic.ResourceSnapshot{Object: map[string]any{ + "labels": map[string]string{"app": "web"}, + "args": []string{"serve"}, + "nested": []any{"x"}, + "empty": map[string]any(nil), + }}, + }, + { + Resource: ref("ConfigMap", "blank"), + Action: semantic.Create, + After: &semantic.ResourceSnapshot{}, + }, + }, + } + var human, jsonBuf bytes.Buffer + require.NoError(t, view.WriteHuman(&human, p, view.Options{})) + require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) + assert.Contains(t, human.String(), "app: web") + assert.Contains(t, human.String(), "serve") + assertJSONAt(t, jsonBuf.Bytes(), `{ + "labels": {"app": "web"}, + "args": ["serve"], + "nested": ["x"], + "empty": null + }`, "changes", 0, "after") + assertJSONAt(t, jsonBuf.Bytes(), `{}`, "changes", 1, "after") +} + +func TestWriteRenderers_NilTasks(t *testing.T) { + t.Parallel() + p := semantic.Plan{ + HelmAction: semantic.HelmNone, + Header: semantic.Header{Release: "web"}, + } + assert.Nil(t, p.Tasks) + require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) + var jsonBuf bytes.Buffer + require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) + assertJSONAt(t, jsonBuf.Bytes(), `[]`, "tasks") +} + func assertNoJSONKeys(t *testing.T, obj map[string]any) { t.Helper() require.NotNil(t, obj) diff --git a/internal/plan/view/project_internal_test.go b/internal/plan/view/project_internal_test.go index 450e377..aaa28a0 100644 --- a/internal/plan/view/project_internal_test.go +++ b/internal/plan/view/project_internal_test.go @@ -77,26 +77,48 @@ func TestProjectFields_KeepsAncestorsAndNameSibling(t *testing.T) { func TestProjectFields_AddAfterOnlyRemoveBeforeOnly(t *testing.T) { t.Parallel() - obj := map[string]any{"data": map[string]any{"keep": "yes"}} - gotBefore, gotAfter := projectFields(obj, map[string]any{ - "data": map[string]any{"keep": "yes", "extra": "x"}, - }, []semantic.FieldChange{{ - Path: "/data/extra", - Op: semantic.FieldAdd, - After: "x", - }}) - assert.Empty(t, gotBefore) - assert.Equal(t, map[string]any{"data": map[string]any{"extra": "x"}}, gotAfter) - - gotBefore, gotAfter = projectFields(map[string]any{ - "data": map[string]any{"keep": "yes", "gone": "y"}, - }, obj, []semantic.FieldChange{{ - Path: "/data/gone", - Op: semantic.FieldRemove, - Before: "y", - }}) - assert.Equal(t, map[string]any{"data": map[string]any{"gone": "y"}}, gotBefore) - assert.Empty(t, gotAfter) + base := map[string]any{"data": map[string]any{"keep": "yes"}} + tests := []struct { + name string + before map[string]any + after map[string]any + field semantic.FieldChange + wantBefore map[string]any + wantAfter map[string]any + }{ + { + name: "add is after only", + before: base, + after: map[string]any{"data": map[string]any{"keep": "yes", "extra": "x"}}, + field: semantic.FieldChange{ + Path: "/data/extra", + Op: semantic.FieldAdd, + After: "x", + }, + wantBefore: map[string]any{}, + wantAfter: map[string]any{"data": map[string]any{"extra": "x"}}, + }, + { + name: "remove is before only", + before: map[string]any{"data": map[string]any{"keep": "yes", "gone": "y"}}, + after: base, + field: semantic.FieldChange{ + Path: "/data/gone", + Op: semantic.FieldRemove, + Before: "y", + }, + wantBefore: map[string]any{"data": map[string]any{"gone": "y"}}, + wantAfter: map[string]any{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotBefore, gotAfter := projectFields(tt.before, tt.after, []semantic.FieldChange{tt.field}) + assert.Equal(t, tt.wantBefore, gotBefore) + assert.Equal(t, tt.wantAfter, gotAfter) + }) + } } func TestProjectFields_WholeNamedListElement(t *testing.T) { diff --git a/internal/plan/view/redact_test.go b/internal/plan/view/redact_test.go index f517548..d7d4d00 100644 --- a/internal/plan/view/redact_test.go +++ b/internal/plan/view/redact_test.go @@ -288,56 +288,6 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { assert.NotEqual(t, "(redacted)", p.Changes[0].Fields[0].After) } -func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { - t.Parallel() - p := semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Header: semantic.Header{Release: "web"}, - Changes: []semantic.ResourceChange{ - { - Resource: ref("ConfigMap", "app"), - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: map[string]any{ - "labels": map[string]string{"app": "web"}, - "args": []string{"serve"}, - "nested": []any{"x"}, - "empty": map[string]any(nil), - }}, - }, - { - Resource: ref("ConfigMap", "blank"), - Action: semantic.Create, - After: &semantic.ResourceSnapshot{}, - }, - }, - } - var human, jsonBuf bytes.Buffer - require.NoError(t, view.WriteHuman(&human, p, view.Options{})) - require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) - assert.Contains(t, human.String(), "app: web") - assert.Contains(t, human.String(), "serve") - assertJSONAt(t, jsonBuf.Bytes(), `{ - "labels": {"app": "web"}, - "args": ["serve"], - "nested": ["x"], - "empty": null - }`, "changes", 0, "after") - assertJSONAt(t, jsonBuf.Bytes(), `{}`, "changes", 1, "after") -} - -func TestWriteRenderers_NilTasks(t *testing.T) { - t.Parallel() - p := semantic.Plan{ - HelmAction: semantic.HelmNone, - Header: semantic.Header{Release: "web"}, - } - assert.Nil(t, p.Tasks) - require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) - var jsonBuf bytes.Buffer - require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) - assertJSONAt(t, jsonBuf.Bytes(), `[]`, "tasks") -} - func TestSecretRedaction_OmitsPlaintext(t *testing.T) { t.Parallel() redactedData := `{"token":"(redacted)"}` diff --git a/internal/plan/view/schema_test.go b/internal/plan/view/schema_test.go index b2bb995..9db8b23 100644 --- a/internal/plan/view/schema_test.go +++ b/internal/plan/view/schema_test.go @@ -36,14 +36,12 @@ func TestSchemaV1ID_MatchesEmbeddedAndRendered(t *testing.T) { assert.Equal(t, "https://json-schema.org/draft/2020-12/schema", sch["$schema"]) props, ok := sch["properties"].(map[string]any) require.True(t, ok) - _, hasChartCRDs := props["chartCRDs"] - assert.True(t, hasChartCRDs) + assert.Contains(t, props, "chartCRDs") required, ok := sch["required"].([]any) require.True(t, ok) assert.Contains(t, required, "chartCRDs") assert.NotContains(t, required, "completeness") - _, hasCompleteness := props["completeness"] - assert.False(t, hasCompleteness) + assert.NotContains(t, props, "completeness") defs, ok := sch["$defs"].(map[string]any) require.True(t, ok) @@ -51,8 +49,7 @@ func TestSchemaV1ID_MatchesEmbeddedAndRendered(t *testing.T) { require.True(t, ok) summaryProps, ok := summary["properties"].(map[string]any) require.True(t, ok) - _, hasReplace := summaryProps["replace"] - assert.False(t, hasReplace) + assert.NotContains(t, summaryProps, "replace") change, ok := defs["ResourceChange"].(map[string]any) require.True(t, ok) assert.Equal(t, []any{"resource", "action", "before", "after", "fields"}, change["required"]) @@ -190,10 +187,10 @@ func TestSchemaV1_RejectsHookUpdateWithoutFields(t *testing.T) { }})) tasks, ok := doc["tasks"].([]any) require.True(t, ok) - require.NotEmpty(t, tasks) + require.Len(t, tasks, 1) defs, ok := asObject(t, tasks[0])["definitions"].([]any) require.True(t, ok) - require.NotEmpty(t, defs) + require.Len(t, defs, 1) asObject(t, defs[0])["fields"] = []any{} raw, err := json.Marshal(doc) require.NoError(t, err) @@ -397,6 +394,13 @@ func firstChartCRD(t *testing.T, doc map[string]any) map[string]any { return asObject(t, crds[0]) } +func assertSchemaRejects(t *testing.T, raw []byte) { + t.Helper() + var v any + require.NoError(t, json.Unmarshal(raw, &v)) + require.Error(t, compilePlanSchema(t).Validate(v)) +} + func mustPlanDoc(t *testing.T, p semantic.Plan) map[string]any { t.Helper() var buf bytes.Buffer diff --git a/internal/plan/view/testdata/golden/style_chart_crds.golden b/internal/plan/view/testdata/golden/human_chart_crds.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_chart_crds.golden rename to internal/plan/view/testdata/golden/human_chart_crds.roles.golden diff --git a/internal/plan/view/testdata/golden/style_create.golden b/internal/plan/view/testdata/golden/human_create.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_create.golden rename to internal/plan/view/testdata/golden/human_create.roles.golden diff --git a/internal/plan/view/testdata/golden/style_delete.golden b/internal/plan/view/testdata/golden/human_delete.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_delete.golden rename to internal/plan/view/testdata/golden/human_delete.roles.golden diff --git a/internal/plan/view/testdata/golden/style_drift_missing.golden b/internal/plan/view/testdata/golden/human_drift_missing.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_drift_missing.golden rename to internal/plan/view/testdata/golden/human_drift_missing.roles.golden diff --git a/internal/plan/view/testdata/golden/style_drift_modified.golden b/internal/plan/view/testdata/golden/human_drift_modified.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_drift_modified.golden rename to internal/plan/view/testdata/golden/human_drift_modified.roles.golden diff --git a/internal/plan/view/testdata/golden/style_drift_unexpected.golden b/internal/plan/view/testdata/golden/human_drift_unexpected.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_drift_unexpected.golden rename to internal/plan/view/testdata/golden/human_drift_unexpected.roles.golden diff --git a/internal/plan/view/testdata/golden/style_tasks.golden b/internal/plan/view/testdata/golden/human_tasks.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_tasks.golden rename to internal/plan/view/testdata/golden/human_tasks.roles.golden diff --git a/internal/plan/view/testdata/golden/style_update.golden b/internal/plan/view/testdata/golden/human_update.roles.golden similarity index 100% rename from internal/plan/view/testdata/golden/style_update.golden rename to internal/plan/view/testdata/golden/human_update.roles.golden diff --git a/internal/plan/view/testdata/golden/json_empty.golden b/internal/plan/view/testdata/golden/json_empty.golden new file mode 100644 index 0000000..5e477d1 --- /dev/null +++ b/internal/plan/view/testdata/golden/json_empty.golden @@ -0,0 +1,20 @@ +{ + "schema": "https://deployah.dev/schemas/plan/v1/schema.json", + "header": { + "project": "web", + "environment": "prod", + "release": "web", + "namespace": "prod" + }, + "helmAction": "none", + "changes": [], + "drift": [], + "tasks": [], + "chartCRDs": [], + "summary": { + "create": 0, + "update": 0, + "delete": 0, + "total": 0 + } +} diff --git a/internal/plan/view/testdata/golden/json_tasks.golden b/internal/plan/view/testdata/golden/json_tasks.golden new file mode 100644 index 0000000..864cf55 --- /dev/null +++ b/internal/plan/view/testdata/golden/json_tasks.golden @@ -0,0 +1,52 @@ +{ + "schema": "https://deployah.dev/schemas/plan/v1/schema.json", + "header": { + "project": "web", + "environment": "prod", + "release": "web", + "namespace": "prod" + }, + "helmAction": "upgrade", + "changes": [], + "drift": [], + "tasks": [ + { + "name": "migrate", + "phase": "preDeploy", + "action": "create", + "willRun": true, + "definitions": [ + { + "resource": { + "apiVersion": "v1", + "kind": "Job", + "namespace": "prod", + "name": "migrate" + }, + "action": "create", + "before": null, + "after": { + "apiVersion": "v1", + "data": { + "key": "v1" + }, + "kind": "ConfigMap", + "metadata": { + "name": "migrate", + "namespace": "prod" + } + }, + "fields": [] + } + ], + "resources": [] + } + ], + "chartCRDs": [], + "summary": { + "create": 0, + "update": 0, + "delete": 0, + "total": 0 + } +} diff --git a/internal/plan/view/yaml_test.go b/internal/plan/view/yaml_test.go index 7f4028b..28ceb8a 100644 --- a/internal/plan/view/yaml_test.go +++ b/internal/plan/view/yaml_test.go @@ -147,7 +147,7 @@ func TestMarshalOrderedYAML_NestedMapsAndArrays(t *testing.T) { }, }) require.NoError(t, err) - assert.Regexp(t, `(?s)a: ["']?2["']?.*items:.*tags:.*z: ["']?1["']?`, got) + assertYAMLOrder(t, got, []string{"\n a:", "\n items:", "\n tags:", "\n z:"}) assert.Less(t, strings.Index(got, "zeta"), strings.Index(got, "alpha")) }