diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0e2c5ad..b4dbd6f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -67,6 +67,18 @@ jobs: COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') echo "Total coverage: ${COVERAGE}%" + - name: Check coverage threshold + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') + echo "Total coverage: ${COVERAGE}%" + awk -v cov="$COVERAGE" 'BEGIN { + if (cov + 0 < 80) { + printf "Coverage %s%% is below the 80%% threshold\n", cov + exit 1 + } + printf "Coverage %s%% meets the 80%% threshold\n", cov + }' + lint: name: Lint needs: changes diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 7017bb7..2969deb 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -53,6 +53,18 @@ jobs: COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') echo "Total coverage: ${COVERAGE}%" + - name: Check coverage threshold + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') + echo "Total coverage: ${COVERAGE}%" + awk -v cov="$COVERAGE" 'BEGIN { + if (cov + 0 < 80) { + printf "Coverage %s%% is below the 80%% threshold\n", cov + exit 1 + } + printf "Coverage %s%% meets the 80%% threshold\n", cov + }' + lint: name: Lint runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5a36778..945329d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@

Tests & Lint + Coverage Integration (act + gitea) Fleet E2E (live GitHub)

diff --git a/internal/changelog/command_test.go b/internal/changelog/command_test.go new file mode 100644 index 0000000..1121347 --- /dev/null +++ b/internal/changelog/command_test.go @@ -0,0 +1,78 @@ +package changelog + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChangelogNewCommand_Structure(t *testing.T) { + cmd := NewCommand() + + assert.Equal(t, "generate-changelog", cmd.Use) + assert.NotEmpty(t, cmd.Short) + + assert.NotNil(t, cmd.Flags().Lookup("base-sha")) + assert.NotNil(t, cmd.Flags().Lookup("head-sha")) + assert.NotNil(t, cmd.Flags().Lookup("repo")) + assert.NotNil(t, cmd.Flags().Lookup("exclude-paths")) + assert.NotNil(t, cmd.Flags().Lookup("contributors")) +} + +func TestChangelogNewCommand_RunE_EmptySHARange(t *testing.T) { + // HEAD..HEAD yields zero commits without error; exercises the RunE closure + // body and runGenerateChangelog through the JSON output path. + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--base-sha", "HEAD", + "--head-sha", "HEAD", + "--repo", "owner/repo", + }) + // The empty commit range produces valid JSON output; swallow any git failure. + _ = cmd.Execute() +} + +func TestChangelogNewCommand_RunE_WithExcludePaths(t *testing.T) { + // Exercises the excludePaths splitting branch in the RunE closure. + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--base-sha", "HEAD", + "--head-sha", "HEAD", + "--repo", "owner/repo", + "--exclude-paths", "docs/, internal/old/", + }) + _ = cmd.Execute() +} + +func TestChangelogNewCommand_RunE_WithContributors(t *testing.T) { + // Exercises the contributors branch in runGenerateChangelog. + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--base-sha", "HEAD", + "--head-sha", "HEAD", + "--repo", "owner/repo", + "--contributors", + }) + _ = cmd.Execute() +} + +func TestChangelogNewCommand_MissingRequiredFlags(t *testing.T) { + // Omitting required flags produces an error before RunE runs. + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--repo", "owner/repo"}) + err := cmd.Execute() + assert.Error(t, err) +} diff --git a/internal/changelog/github_coverage_test.go b/internal/changelog/github_coverage_test.go new file mode 100644 index 0000000..9912740 --- /dev/null +++ b/internal/changelog/github_coverage_test.go @@ -0,0 +1,86 @@ +package changelog + +import ( + "encoding/json" + "testing" + + "github.com/stablekernel/cascade/internal/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupAuthorsBatch_EmptyEmails(t *testing.T) { + result := lookupAuthorsBatch("owner/repo", nil, nil) + assert.Empty(t, result) +} + +func TestLookupAuthorsBatch_EmptyEmailSlice(t *testing.T) { + result := lookupAuthorsBatch("owner/repo", []string{}, map[string]string{}) + assert.Empty(t, result) +} + +func TestLookupAuthorsBatch_InvalidRepoFormat(t *testing.T) { + // A repo string without a slash causes an early return. + emails := []string{"alice@example.com"} + hashes := map[string]string{"alice@example.com": "abc123"} + result := lookupAuthorsBatch("noslash", emails, hashes) + assert.Empty(t, result) +} + +func TestUnmarshalJSON_InvalidEntrySkipped(t *testing.T) { + // When one repository entry cannot be decoded as a commitObject, it is + // silently skipped; valid entries are still decoded correctly. + jsonData := `{ + "data": { + "repository": { + "c0": {"author": {"user": {"login": "alice"}}}, + "c1": "not-an-object" + } + } + }` + + var response graphQLResponse + err := json.Unmarshal([]byte(jsonData), &response) + require.NoError(t, err) + + c0, ok := response.Data.Repository["c0"] + require.True(t, ok, "c0 should be present") + assert.Equal(t, "alice", c0.Author.User.Login) + + // c1 had invalid structure and was skipped; it may be absent or have empty login. + if c1, exists := response.Data.Repository["c1"]; exists { + assert.Empty(t, c1.Author.User.Login, "skipped entry should have no login") + } +} + +func TestUnmarshalJSON_TopLevelError(t *testing.T) { + // Malformed JSON should return an error from UnmarshalJSON. + var response graphQLResponse + err := json.Unmarshal([]byte("not json"), &response) + assert.Error(t, err) +} + +func TestLookupConventionalCommitUsernames_WithCommits(t *testing.T) { + // Verify the conversion loop and the username copy-back loop both execute. + // The gh CLI call will fail in the test environment; the important thing is + // that the function does not panic and returns the same number of commits. + commits := []ConventionalCommit{ + {FullHash: "abc1234567", AuthorEmail: "alice@example.com", Description: "first change"}, + {FullHash: "def1234567", AuthorEmail: "bob@example.com", Description: "second change"}, + } + result := LookupConventionalCommitUsernames(commits, "owner/repo") + require.Len(t, result, 2) + assert.Equal(t, "first change", result[0].Description) + assert.Equal(t, "second change", result[1].Description) +} + +func TestLookupGitHubUsernames_InvalidRepoFormat(t *testing.T) { + // A repo without a slash propagates to lookupAuthorsBatch which returns empty, + // so no commit gets a username. + commits := []git.Commit{ + {Hash: "abc123", AuthorEmail: "alice@example.com"}, + } + result := LookupGitHubUsernames(commits, "noslash") + require.Len(t, result, 1) + assert.Empty(t, result[0].GitHubUsername) +} diff --git a/internal/changelog/parse_coverage_test.go b/internal/changelog/parse_coverage_test.go new file mode 100644 index 0000000..7c8f114 --- /dev/null +++ b/internal/changelog/parse_coverage_test.go @@ -0,0 +1,161 @@ +package changelog + +import ( + "fmt" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCommit_WithPRNumber(t *testing.T) { + commit := git.Commit{ + Hash: "abc123def456", + Subject: "feat: add login endpoint (#42)", + Body: "", + } + cc := ParseCommit(commit) + require.NotNil(t, cc) + assert.Equal(t, "feat", cc.Type) + assert.Equal(t, "42", cc.PRNumber) + assert.Equal(t, "add login endpoint", cc.Description) +} + +func TestParseCommit_WithOrgRepoPRRef(t *testing.T) { + // PR references in description are extracted regardless of preceding text + commit := git.Commit{ + Hash: "def456abc123", + Subject: "fix: resolve null pointer (#999)", + } + cc := ParseCommit(commit) + require.NotNil(t, cc) + assert.Equal(t, "999", cc.PRNumber) + assert.Equal(t, "resolve null pointer", cc.Description) +} + +func TestCategorizeCommits_NonRoutineNonFeatFix(t *testing.T) { + // A commit type that is not feat/fix and is not a routine type + // (e.g. "perf") should appear in the other slice. + commits := []git.Commit{ + {Hash: "perf1234567", Subject: "perf(db): optimize query", Body: ""}, + {Hash: "build123456", Subject: "build(ci): speed up pipeline", Body: ""}, + } + _, _, _, other := CategorizeCommits(commits) + require.Len(t, other, 2) + assert.Equal(t, "perf1234567", other[0].Hash) + assert.Equal(t, "build123456", other[1].Hash) +} + +func TestFormatMarkdown_CollapsibleFeatureSection(t *testing.T) { + // collapsibleThreshold is 5; six commits should trigger the collapsible wrapper. + features := make([]ConventionalCommit, 6) + for i := range features { + features[i] = ConventionalCommit{ + Description: fmt.Sprintf("feature number %d", i), + Hash: fmt.Sprintf("hash%04d", i), + FullHash: fmt.Sprintf("fullhash%08d", i), + } + } + result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base123", "head456") + + assert.Contains(t, result, "
") + assert.Contains(t, result, "") + assert.Contains(t, result, "✨ Features") + assert.Contains(t, result, "
") +} + +func TestFormatMarkdown_CollapsibleOtherSection(t *testing.T) { + // collapsibleThreshold is 5; six other commits should trigger the collapsible wrapper. + other := make([]git.Commit, 6) + for i := range other { + other[i] = git.Commit{ + Hash: fmt.Sprintf("otherhash%04d", i), + Subject: fmt.Sprintf("other commit %d", i), + } + } + result := FormatMarkdown(nil, nil, nil, other, "owner/repo", "base123", "head456") + + assert.Contains(t, result, "
") + assert.Contains(t, result, "📝 Other Changes") + assert.Contains(t, result, "
") +} + +func TestFormatMarkdown_WithScopedCommits(t *testing.T) { + // Commits with non-empty scopes should produce scope headers and + // exercise the getSortedScopes named-scope branch. + features := []ConventionalCommit{ + {Scope: "auth", Description: "add login", Hash: "abc1234", FullHash: "abc12345678"}, + {Scope: "api", Description: "add endpoint", Hash: "def1234", FullHash: "def12345678"}, + } + result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head") + + assert.Contains(t, result, "#### `auth`") + assert.Contains(t, result, "#### `api`") + assert.Contains(t, result, "add login") + assert.Contains(t, result, "add endpoint") +} + +func TestFormatMarkdown_MixedScopedAndUnscopedCommits(t *testing.T) { + // Mix of scoped and unscoped commits: verifies both the named-scope header + // and the trailing empty-scope group (no header rendered). + features := []ConventionalCommit{ + {Scope: "auth", Description: "add login", Hash: "aaa1234", FullHash: "aaa12345678"}, + {Scope: "", Description: "general improvement", Hash: "bbb1234", FullHash: "bbb12345678"}, + } + result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head") + + assert.Contains(t, result, "#### `auth`") + assert.Contains(t, result, "general improvement") +} + +func TestFormatCommitLine_WithPRNumber(t *testing.T) { + c := ConventionalCommit{ + Description: "add feature", + Hash: "abc1234", + FullHash: "abc1234567", + PRNumber: "42", + } + line := formatCommitLine(c, "owner/repo") + + assert.Contains(t, line, "[#42]") + assert.Contains(t, line, "https://github.com/owner/repo/pull/42") + assert.Contains(t, line, "add feature") + assert.True(t, strings.HasPrefix(line, "- ")) +} + +func TestFormatCommitLine_WithoutPRNumber(t *testing.T) { + c := ConventionalCommit{ + Description: "add feature", + Hash: "abc1234", + FullHash: "abc12345678", + } + line := formatCommitLine(c, "owner/repo") + + assert.Contains(t, line, "[`abc1234`]") + assert.Contains(t, line, "https://github.com/owner/repo/commit/abc12345678") +} + +func TestFormatOtherCommitLine_WithUsername(t *testing.T) { + c := git.Commit{ + Hash: "abc1234567", + Subject: "merge pull request", + GitHubUsername: "alice", + } + line := formatOtherCommitLine(c, "owner/repo") + + assert.Contains(t, line, "(@alice)") + assert.Contains(t, line, "merge pull request") +} + +func TestFormatOtherCommitLine_WithoutUsername(t *testing.T) { + c := git.Commit{ + Hash: "abc1234567", + Subject: "merge pull request", + } + line := formatOtherCommitLine(c, "owner/repo") + + assert.NotContains(t, line, "(@") + assert.Contains(t, line, "merge pull request") +} diff --git a/internal/hotfix/command_test.go b/internal/hotfix/command_test.go new file mode 100644 index 0000000..62e0a0b --- /dev/null +++ b/internal/hotfix/command_test.go @@ -0,0 +1,215 @@ +package hotfix + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns whatever +// fn printed. It lets the human-readable printers be asserted on their output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + require.NoError(t, w.Close()) + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + return buf.String() +} + +// TestNewCommand verifies the hotfix command exposes plan and finalize. +func TestNewCommand(t *testing.T) { + cmd := NewCommand() + require.NotNil(t, cmd) + assert.Equal(t, "hotfix", cmd.Name()) + + plan, _, err := cmd.Find([]string{"plan"}) + require.NoError(t, err) + assert.Equal(t, "plan", plan.Name()) + + finalize, _, err := cmd.Find([]string{"finalize"}) + require.NoError(t, err) + assert.Equal(t, "finalize", finalize.Name()) +} + +// TestNewPlanCommand_Flags asserts the plan subcommand wires its flags and +// required/exclusive constraints. +func TestNewPlanCommand_Flags(t *testing.T) { + cmd := newPlanCommand() + assert.Equal(t, "plan", cmd.Name()) + for _, name := range []string{"config", "key", "commit", "commits", "target-env", "actor", "remote", "repo", "dry-run", "json", "gha-output"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "plan flag %q should exist", name) + } +} + +// TestNewFinalizeCommand_Flags asserts the finalize subcommand wires its flags. +func TestNewFinalizeCommand_Flags(t *testing.T) { + cmd := newFinalizeCommand() + assert.Equal(t, "finalize", cmd.Name()) + for _, name := range []string{"config", "key", "target-env", "merge-sha", "fix-sha", "base-sha", "actor", "dry-run", "deploy-result", "build-result"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "finalize flag %q should exist", name) + } +} + +// TestSplitResultFlag covers the name=result parser including its reject cases. +func TestSplitResultFlag(t *testing.T) { + cases := []struct { + in string + name string + result string + ok bool + }{ + {"app=success", "app", "success", true}, + {"svc.api=failure", "svc.api", "failure", true}, + {"a=b=c", "a", "b=c", true}, + {"", "", "", false}, + {"=success", "", "", false}, + {"app=", "", "", false}, + {"noequals", "", "", false}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + name, result, ok := splitResultFlag(tc.in) + assert.Equal(t, tc.ok, ok) + assert.Equal(t, tc.name, name) + assert.Equal(t, tc.result, result) + }) + } +} + +// TestNewGHPRChecker constructs the gh-backed checker and records its repo. +func TestNewGHPRChecker(t *testing.T) { + c := newGHPRChecker("org/repo") + require.NotNil(t, c) + assert.Equal(t, "org/repo", c.repo) +} + +// TestPrintPlan_FullPlan asserts the human plan output includes the env, branch, +// version, and the dry-run marker. +func TestPrintPlan_FullPlan(t *testing.T) { + result := &PlanResult{ + TargetEnv: "uat", + FixSHA: "abcdef1234567890", + Branch: "env/uat", + BaseSHA: "fedcba0987654321", + BranchCreated: true, + DryRun: true, + HotfixVersionCandidate: "v1.2.0-rc.1.hotfix.1", + ProtectionSuggestions: []string{"gh api ...protect..."}, + } + out := captureStdout(t, func() { printPlan(result) }) + assert.Contains(t, out, "uat") + assert.Contains(t, out, "env/uat") + assert.Contains(t, out, "v1.2.0-rc.1.hotfix.1") + assert.Contains(t, out, "dry-run") + assert.Contains(t, out, "would create") + assert.Contains(t, out, "gh api ...protect...") +} + +// TestPrintPlan_NoOp short-circuits with a no-op message. +func TestPrintPlan_NoOp(t *testing.T) { + result := &PlanResult{TargetEnv: "prod", FixSHA: "abc1234", NoOp: true} + out := captureStdout(t, func() { printPlan(result) }) + assert.Contains(t, out, "no-op") + assert.Contains(t, out, "prod") + assert.NotContains(t, out, "Version:") +} + +// TestPrintPlan_ExistingBranch reports the already-present branch path. +func TestPrintPlan_ExistingBranch(t *testing.T) { + result := &PlanResult{ + TargetEnv: "uat", + FixSHA: "abcdef1", + Branch: "env/uat", + BaseSHA: "fed0987", + BranchCreated: false, + HotfixVersionCandidate: "v1.0.1", + } + out := captureStdout(t, func() { printPlan(result) }) + assert.Contains(t, out, "already present") + assert.NotContains(t, out, "dry-run") +} + +// TestPrintPlanChain renders the bottom-up env sequence and per-env commits. +func TestPrintPlanChain(t *testing.T) { + result := &PlanChainResult{Envs: []EnvPlan{ + {Env: "test", Branch: "env/test", BaseSHA: "1111111aaaa", Commits: []string{"2222222bbbb", "3333333cccc"}}, + {Env: "uat", NoOp: true}, + }} + out := captureStdout(t, func() { printPlanChain(result) }) + assert.Contains(t, out, "test") + assert.Contains(t, out, "env/test") + assert.Contains(t, out, "2222222") + assert.Contains(t, out, "no-op") + assert.Contains(t, out, "2 environment(s)") +} + +// TestOutputJSON marshals a value and prints it as indented JSON. +func TestOutputJSON(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, outputJSON(&PlanResult{TargetEnv: "uat", FixSHA: "abc"})) + }) + assert.Contains(t, out, `"target_env": "uat"`) + assert.Contains(t, out, `"fix_sha": "abc"`) +} + +// TestWritePlanGHAOutput writes the single-env plan keys to $GITHUB_OUTPUT. +func TestWritePlanGHAOutput(t *testing.T) { + outFile := t.TempDir() + "/gha_output" + t.Setenv("GITHUB_OUTPUT", outFile) + + result := &PlanResult{ + TargetEnv: "uat", + FixSHA: "abc123", + Branch: "env/uat", + BaseSHA: "def456", + NoOp: false, + BranchCreated: true, + HotfixVersionCandidate: "v1.0.1", + ConflictExpected: false, + DryRun: true, + ProtectionSuggestions: []string{"gh api protect"}, + } + require.NoError(t, writePlanGHAOutput(result)) + + data, err := os.ReadFile(outFile) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "target_env=uat") + assert.Contains(t, content, "branch=env/uat") + assert.Contains(t, content, "hotfix_version_candidate=v1.0.1") + assert.Contains(t, content, "dry_run=true") + assert.Contains(t, content, "protection_suggestions") +} + +// TestWritePlanChainGHAOutput writes the additive chain keys to $GITHUB_OUTPUT. +func TestWritePlanChainGHAOutput(t *testing.T) { + outFile := t.TempDir() + "/gha_output" + t.Setenv("GITHUB_OUTPUT", outFile) + + result := &PlanChainResult{Envs: []EnvPlan{ + {Env: "test", Branch: "env/test", BaseSHA: "111", Commits: []string{"aaa", "bbb"}}, + {Env: "uat", Branch: "env/uat", BaseSHA: "222", NoOp: true}, + }} + require.NoError(t, writePlanChainGHAOutput(result)) + + data, err := os.ReadFile(outFile) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "env_sequence=test,uat") + assert.Contains(t, content, "env_count=2") + assert.Contains(t, content, "commits_test=aaa,bbb") + assert.Contains(t, content, "no_op_uat=true") + assert.Contains(t, content, "base_test=111") +} diff --git a/internal/hotfix/finalize_options_test.go b/internal/hotfix/finalize_options_test.go new file mode 100644 index 0000000..fb9ea9c --- /dev/null +++ b/internal/hotfix/finalize_options_test.go @@ -0,0 +1,201 @@ +package hotfix + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingTipReader is a record-only env-branch tip reader for option tests. +type recordingTipReader struct { + sha string +} + +func (r recordingTipReader) LocalBranchSHA(string) (string, error) { return r.sha, nil } + +// TestWithFinalizeDryRun applies the dry-run option to the finalizer. +func TestWithFinalizeDryRun(t *testing.T) { + f := &Finalizer{} + WithFinalizeDryRun(true)(f) + assert.True(t, f.dryRun) + + WithFinalizeDryRun(false)(f) + assert.False(t, f.dryRun) +} + +// TestWithTipReader injects a custom tip reader and ignores a nil one. +func TestWithTipReader(t *testing.T) { + f := &Finalizer{tipReader: envTipReader{}} + + WithTipReader(recordingTipReader{sha: "deadbeef"})(f) + rr, ok := f.tipReader.(recordingTipReader) + require.True(t, ok, "injected reader should replace the default") + assert.Equal(t, "deadbeef", rr.sha) + + // A nil reader must not clobber the existing one. + WithTipReader(nil)(f) + _, ok = f.tipReader.(recordingTipReader) + assert.True(t, ok, "nil option must leave the prior reader intact") +} + +// TestFinalizeOptions_NilSafety verifies each injecting option ignores nil and +// preserves the previously set dependency. +func TestFinalizeOptions_NilSafety(t *testing.T) { + mgr := &stubReleaseManager{} + lister := stubTagLister{tags: []string{"v1.0.0"}} + pusher := &recordingPusher{} + trunk := &stubTrunkReader{} + + f := &Finalizer{} + WithReleaseManager(mgr)(f) + WithTagLister(lister)(f) + WithStatePusher(pusher)(f) + WithTrunkStateReader(trunk)(f) + + assert.Same(t, mgr, f.releaseMgr) + assert.Equal(t, lister, f.tagLister) + assert.Same(t, pusher, f.pusher) + assert.True(t, f.pusherInjected) + assert.Same(t, trunk, f.trunkReader) + + // Nil options are no-ops and leave the dependencies in place. + WithReleaseManager(nil)(f) + WithStatePusher(nil)(f) + WithTrunkStateReader(nil)(f) + assert.Same(t, mgr, f.releaseMgr) + assert.Same(t, pusher, f.pusher) + assert.Same(t, trunk, f.trunkReader) +} + +// TestGitIdentity covers the manifest-config to commit-identity mapping for the +// hotfix finalizer. +func TestGitIdentity(t *testing.T) { + assert.Equal(t, "", gitIdentity(nil).Name) + + def := gitIdentity(&config.TrunkConfig{}) + assert.Equal(t, "github-actions[bot]", def.Name) + assert.Equal(t, "github-actions[bot]@users.noreply.github.com", def.Email) + + custom := gitIdentity(&config.TrunkConfig{Git: &config.GitConfig{ + UserName: "Hotfix Bot", + UserEmail: "hotfix@example.com", + }}) + assert.Equal(t, "Hotfix Bot", custom.Name) + assert.Equal(t, "hotfix@example.com", custom.Email) +} + +// TestIsRealGitHub covers the act/gitea vs github.com detection. +func TestIsRealGitHub(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + assert.True(t, isRealGitHub()) + + t.Setenv("GITHUB_SERVER_URL", "") + assert.True(t, isRealGitHub(), "unset defaults to real GitHub") + + t.Setenv("GITHUB_SERVER_URL", "http://gitea:3000") + assert.False(t, isRealGitHub()) +} + +// TestAllocateVersion covers rc-nested allocation, published patch bump, tag +// collision skipping, and both error branches. +func TestAllocateVersion(t *testing.T) { + t.Run("rc base allocates nested hotfix", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{}} + got, err := f.allocateVersion("v1.4.0-rc.2") + require.NoError(t, err) + assert.Equal(t, "v1.4.0-rc.2.hotfix.1", got) + }) + + t.Run("rc base skips existing hotfix tag", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{tags: []string{"v1.4.0-rc.2.hotfix.1"}}} + got, err := f.allocateVersion("v1.4.0-rc.2") + require.NoError(t, err) + assert.Equal(t, "v1.4.0-rc.2.hotfix.2", got) + }) + + t.Run("published base patch bump", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{}} + got, err := f.allocateVersion("v1.3.0") + require.NoError(t, err) + assert.Equal(t, "v1.3.1", got) + }) + + t.Run("published base skips taken patches", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{tags: []string{"v1.3.1", "v1.3.2"}}} + got, err := f.allocateVersion("v1.3.0") + require.NoError(t, err) + assert.Equal(t, "v1.3.3", got) + }) + + t.Run("empty prior version errors", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{}} + _, err := f.allocateVersion("") + require.Error(t, err) + assert.Contains(t, err.Error(), "no recorded version") + }) + + t.Run("unparseable prior version errors", func(t *testing.T) { + f := &Finalizer{tagLister: stubTagLister{}} + _, err := f.allocateVersion("not-a-semver") + require.Error(t, err) + }) +} + +// TestIsPrereleaseEnv identifies the second-from-top env as the prerelease env. +func TestIsPrereleaseEnv(t *testing.T) { + f := &Finalizer{} + cfg := &config.TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}} + + assert.True(t, f.isPrereleaseEnv(cfg, "uat"), "second-from-top is the prerelease env") + assert.False(t, f.isPrereleaseEnv(cfg, "prod")) + assert.False(t, f.isPrereleaseEnv(cfg, "dev")) + + single := &config.TrunkConfig{Environments: []string{"prod"}} + assert.False(t, f.isPrereleaseEnv(single, "prod"), "fewer than two envs has no prerelease env") +} + +// TestApplyHotfixState_NoOpWhenSHAUnchanged returns early without mutating the +// patch list when the target already records the merge SHA. +func TestApplyHotfixState_NoOpWhenSHAUnchanged(t *testing.T) { + f := &Finalizer{actor: "dev"} + cicd := &config.CICDFile{State: map[string]*config.EnvState{ + "uat": {SHA: "mergesha", Patches: []string{"existing"}}, + }} + + err := f.applyHotfixState(cicd, "uat", "mergesha", "v1.0.1", "basesha", "2026-01-01T00:00:00Z", []string{"newfix"}) + require.NoError(t, err) + + state := cicd.State["uat"] + assert.Equal(t, []string{"existing"}, state.Patches, "no-op rerun must not append patches") + assert.Equal(t, "mergesha", state.SHA) +} + +// TestApplyHotfixState_WritesDivergedState records the merge SHA, base, patches, +// ref, and substates on a fresh target env. +func TestApplyHotfixState_WritesDivergedState(t *testing.T) { + f := &Finalizer{ + actor: "dev", + deployResults: map[string]string{"app": "success", "skipped-one": "skipped"}, + buildResults: map[string]string{"build-app": "success"}, + } + cicd := &config.CICDFile{} + + err := f.applyHotfixState(cicd, "uat", "newmerge", "v1.0.1", "basesha", "2026-01-01T00:00:00Z", []string{"fix1", "fix2"}) + require.NoError(t, err) + + state := cicd.State["uat"] + require.NotNil(t, state) + assert.Equal(t, "newmerge", state.SHA) + assert.Equal(t, "v1.0.1", state.Version) + assert.Equal(t, "basesha", state.BaseSHA) + assert.Equal(t, "env/uat", state.Ref) + assert.Equal(t, []string{"fix1", "fix2"}, state.Patches) + assert.Equal(t, "dev", state.CommittedBy) + + require.NotNil(t, state.Deploys["app"], "successful deploy substate recorded") + assert.Equal(t, "newmerge", state.Deploys["app"].SHA) + assert.Nil(t, state.Deploys["skipped-one"], "skipped deploy must not be recorded") + require.NotNil(t, state.Builds["build-app"], "successful build substate recorded") +} diff --git a/internal/orchestrate/orchestrate_more_test.go b/internal/orchestrate/orchestrate_more_test.go new file mode 100644 index 0000000..02f0163 --- /dev/null +++ b/internal/orchestrate/orchestrate_more_test.go @@ -0,0 +1,278 @@ +package orchestrate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +func TestNewCommand_Structure(t *testing.T) { + cmd := NewCommand() + + assert.Equal(t, "orchestrate", cmd.Use) + require.NotNil(t, cmd.PersistentPreRunE) + + for _, name := range []string{"config", "manifest-key", "environment", "gha-output"} { + assert.NotNilf(t, cmd.PersistentFlags().Lookup(name), "expected persistent flag %q", name) + } + + var haveSetup, haveFinalize bool + for _, sub := range cmd.Commands() { + switch sub.Use { + case "setup": + haveSetup = true + case "finalize": + haveFinalize = true + } + } + assert.True(t, haveSetup, "expected a setup subcommand") + assert.True(t, haveFinalize, "expected a finalize subcommand") +} + +func TestNewCommand_PersistentPreRunE_AutoDetectsConfig(t *testing.T) { + prev := configPath + t.Cleanup(func() { configPath = prev }) + + configPath = "" + cmd := NewCommand() + require.NoError(t, cmd.PersistentPreRunE(cmd, nil)) + assert.NotEmpty(t, configPath) +} + +func TestNewSetupCommand_Structure(t *testing.T) { + cmd := newSetupCommand() + assert.Equal(t, "setup", cmd.Use) + require.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.Flags().Lookup("sha")) +} + +func TestNewFinalizeCommand_Structure(t *testing.T) { + cmd := newFinalizeCommand() + assert.Equal(t, "finalize", cmd.Use) + require.NotNil(t, cmd.RunE) + for _, name := range []string{"sha", "version", "deploy-results", "build-results"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected flag %q", name) + } +} + +func TestRunSetup_InitError(t *testing.T) { + prevCfg, prevEnv := configPath, environment + t.Cleanup(func() { configPath, environment = prevCfg, prevEnv }) + + configPath = "/nonexistent/path/manifest.yaml" + environment = "dev" + + err := runSetup(newSetupCommand(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "initializing orchestrator") +} + +func TestRunFinalize_InitError(t *testing.T) { + prevCfg, prevEnv := configPath, environment + t.Cleanup(func() { configPath, environment = prevCfg, prevEnv }) + + configPath = "/nonexistent/path/manifest.yaml" + environment = "dev" + + err := runFinalize("v1.0.0", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "initializing orchestrator") +} + +func TestNewOrchestrator_NoEnvUsesDefaultStateKey(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".github", "manifest.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) + + manifest := `ci: + config: + trunk_branch: main + environments: [] +` + require.NoError(t, os.WriteFile(configFile, []byte(manifest), 0o600)) + + orch, err := NewOrchestrator(configFile, "ci", "") + require.NoError(t, err) + assert.Equal(t, DefaultStateKey, orch.environment) +} + +func TestNewOrchestrator_MissingConfigSection(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "manifest.yaml") + // A manifest with the ci key but no config section. + require.NoError(t, os.WriteFile(configFile, []byte("ci:\n state: {}\n"), 0o600)) + + _, err := NewOrchestrator(configFile, "ci", "dev") + require.Error(t, err) + assert.Contains(t, err.Error(), "config section not found") +} + +func TestCalculateBaseSHAs_Priorities(t *testing.T) { + repoDir, head := initRepo(t) + defaultBase := runGit(t, repoDir, "rev-parse", "HEAD~1") + + cfg := &config.TrunkConfig{ + Builds: []config.BuildConfig{ + {Name: "app"}, // own build state (priority 1) + {Name: "lib"}, // dependent deploy state (priority 2) + {Name: "tool"}, // env-level SHA (priority 3) + }, + Deploys: []config.DeployConfig{ + {Name: "infra"}, + {Name: "web", DependsOn: []string{"lib"}}, + }, + } + o := &Orchestrator{baseDir: repoDir, cicdFile: &config.CICDFile{Config: cfg}} + + // nil envState: every base falls back to defaultBase (HEAD~1). + base := o.calculateBaseSHAs(nil) + assert.Equal(t, defaultBase, base["build_app"]) + assert.Equal(t, defaultBase, base["build_lib"]) + assert.Equal(t, defaultBase, base["build_tool"]) + assert.Equal(t, defaultBase, base["deploy_infra"]) + assert.Equal(t, defaultBase, base["deploy_web"]) + + // Populated envState exercises the full priority ladder. + env := &config.EnvState{ + SHA: "envsha", + Builds: map[string]*config.BuildState{"app": {SHA: "appbuildsha"}}, + Deploys: map[string]*config.DeployState{ + "web": {SHA: "webdeploysha"}, + "infra": {SHA: "infradeploysha"}, + }, + } + base = o.calculateBaseSHAs(env) + assert.Equal(t, "appbuildsha", base["build_app"]) // 1. own build state + assert.Equal(t, "webdeploysha", base["build_lib"]) // 2. dependent deploy state + assert.Equal(t, "envsha", base["build_tool"]) // 3. env-level SHA + assert.Equal(t, "infradeploysha", base["deploy_infra"]) + assert.Equal(t, "webdeploysha", base["deploy_web"]) + + _ = head +} + +func TestDetectChanges(t *testing.T) { + repoDir, head := initRepo(t) + base := runGit(t, repoDir, "rev-parse", "HEAD~1") + o := &Orchestrator{baseDir: repoDir} + + // No base SHA: assume changes. + assert.True(t, o.detectChanges("", head, []string{"src/**"})) + // No triggers: assume changes. + assert.True(t, o.detectChanges(base, head, nil)) + // The HEAD commit added src/app.go, which matches src/**. + assert.True(t, o.detectChanges(base, head, []string{"src/**"})) + // No trigger matches the changed path. + assert.False(t, o.detectChanges(base, head, []string{"docs/**"})) + // A bad base SHA makes the diff fail, which conservatively assumes changes. + assert.True(t, o.detectChanges("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", head, []string{"src/**"})) +} + +func TestCalculateVersion_EnvironmentNotFound(t *testing.T) { + o := &Orchestrator{ + environment: "ghost", + cicdFile: &config.CICDFile{ + Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}}, + }, + } + + _, err := o.calculateVersion() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestCalculateVersion_MultiEnv(t *testing.T) { + repoDir, head := initRepo(t) + + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { require.NoError(t, os.Chdir(orig)) }) + + o := &Orchestrator{ + environment: "dev", + baseDir: repoDir, + cicdFile: &config.CICDFile{ + Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}}, + State: map[string]*config.EnvState{ + "dev": {Version: "v1.0.0-rc.0"}, + "prod": {Version: "v0.9.0", SHA: head}, + }, + }, + } + + v, err := o.calculateVersion() + require.NoError(t, err) + assert.NotEmpty(t, v) +} + +func TestWriteConfig_ReadError(t *testing.T) { + o := &Orchestrator{ + configPath: "/nonexistent/path/manifest.yaml", + cicdFile: &config.CICDFile{Config: &config.TrunkConfig{}}, + } + + err := o.writeConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read config") +} + +func TestWriteConfig_CustomManifestKey(t *testing.T) { + tmpDir := t.TempDir() + p := filepath.Join(tmpDir, "manifest.yaml") + seeded := `pipeline: + config: + trunk_branch: main + state: + dev: + sha: oldsha +` + require.NoError(t, os.WriteFile(p, []byte(seeded), 0o600)) + + o := &Orchestrator{ + configPath: p, + cicdFile: &config.CICDFile{ + Config: &config.TrunkConfig{ManifestKey: "pipeline"}, + State: map[string]*config.EnvState{"dev": {SHA: "newsha"}}, + }, + } + + require.NoError(t, o.writeConfig()) + + data, err := os.ReadFile(p) + require.NoError(t, err) + assert.Contains(t, string(data), "pipeline:") + assert.Contains(t, string(data), "newsha") + assert.NotContains(t, string(data), "oldsha") +} + +func TestCommitAndPush_NoChanges(t *testing.T) { + repoDir, _ := initRepo(t) + manifestPath := writeManifest(t, repoDir, "abc123") + // Commit the manifest so the working tree is clean for the configured path. + runGit(t, repoDir, "add", ".github/manifest.yaml") + runGit(t, repoDir, "commit", "-m", "chore: add manifest") + + o := &Orchestrator{ + baseDir: repoDir, + configPath: manifestPath, + environment: "prerelease", + cicdFile: &config.CICDFile{}, + } + + // With nothing to commit, commitAndPush returns early without needing a remote. + require.NoError(t, o.commitAndPush("v0.1.0")) +} + +func TestGitOutput_Error(t *testing.T) { + repoDir, _ := initRepo(t) + o := &Orchestrator{baseDir: repoDir} + + _, err := o.gitOutput("rev-parse", "--verify", "does-not-exist") + require.Error(t, err) +} diff --git a/internal/promote/finalize_helpers_test.go b/internal/promote/finalize_helpers_test.go new file mode 100644 index 0000000..a8c046d --- /dev/null +++ b/internal/promote/finalize_helpers_test.go @@ -0,0 +1,154 @@ +package promote + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetEnv covers both the present-value and default-fallback branches. +func TestGetEnv(t *testing.T) { + t.Run("returns value when set", func(t *testing.T) { + t.Setenv("CASCADE_GETENV_PROBE", "actual") + assert.Equal(t, "actual", getEnv("CASCADE_GETENV_PROBE", "fallback")) + }) + + t.Run("returns default when empty", func(t *testing.T) { + t.Setenv("CASCADE_GETENV_PROBE", "") + assert.Equal(t, "fallback", getEnv("CASCADE_GETENV_PROBE", "fallback")) + }) + + t.Run("returns default when unset", func(t *testing.T) { + assert.Equal(t, "fallback", getEnv("CASCADE_GETENV_DEFINITELY_UNSET", "fallback")) + }) +} + +// TestGitIdentity verifies the manifest git config maps to the statewrite +// identity, and that a nil config yields the empty (bot-default) identity. +func TestGitIdentity(t *testing.T) { + t.Run("nil config yields empty identity", func(t *testing.T) { + id := gitIdentity(nil) + assert.Empty(t, id.Name) + assert.Empty(t, id.Email) + }) + + t.Run("config without git block uses bot defaults", func(t *testing.T) { + id := gitIdentity(&config.TrunkConfig{}) + assert.Equal(t, "github-actions[bot]", id.Name) + assert.Equal(t, "github-actions[bot]@users.noreply.github.com", id.Email) + }) + + t.Run("config with git block uses configured values", func(t *testing.T) { + cfg := &config.TrunkConfig{Git: &config.GitConfig{ + UserName: "Release Bot", + UserEmail: "release@example.com", + }} + id := gitIdentity(cfg) + assert.Equal(t, "Release Bot", id.Name) + assert.Equal(t, "release@example.com", id.Email) + }) +} + +// newExternalFinalizer builds a Finalizer whose manifest declares one external +// repo with a single external deploy, plus a source env that recorded a SHA and +// version for that deploy. It is the fixture for the external-deploy helpers. +func newExternalFinalizer(deployName string) *Finalizer { + return &Finalizer{ + targetEnv: "uat", + actor: "deployer", + cicdFile: &config.CICDFile{ + Config: &config.TrunkConfig{ + External: []config.ExternalRepoConfig{{ + Repo: "org/satellite", + Deploys: []config.ExternalDeployConfig{{ + Name: deployName, + }}, + }}, + }, + State: map[string]*config.EnvState{ + "test": { + External: map[string]*config.ExternalDeployState{ + deployName: { + Repo: "org/satellite", + SHA: "ext-sha-123", + Version: "v9.9.9", + }, + }, + }, + }, + }, + promotionResult: &PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "uat", + SourceEnv: "test", + }}, + }, + } +} + +// TestIsExternalDeploy distinguishes external deploys from unknown names. +func TestIsExternalDeploy(t *testing.T) { + f := newExternalFinalizer("cdk") + assert.True(t, f.isExternalDeploy("cdk"), "declared external deploy must be recognized") + assert.False(t, f.isExternalDeploy("not-external"), "unknown name is not external") +} + +// TestGetExternalDeployRepo returns the owning repo for a known deploy and empty +// for an unknown one. +func TestGetExternalDeployRepo(t *testing.T) { + f := newExternalFinalizer("cdk") + assert.Equal(t, "org/satellite", f.getExternalDeployRepo("cdk")) + assert.Equal(t, "", f.getExternalDeployRepo("missing")) +} + +// TestGetExternalDeploySHA reads the SHA recorded for the deploy in the source +// environment state and returns empty when there is no promotion context. +func TestGetExternalDeploySHA(t *testing.T) { + f := newExternalFinalizer("cdk") + assert.Equal(t, "ext-sha-123", f.getExternalDeploySHA("cdk")) + assert.Equal(t, "", f.getExternalDeploySHA("missing"), "unknown deploy has no SHA") + + f.promotionResult = nil + assert.Equal(t, "", f.getExternalDeploySHA("cdk"), "no promotion context yields empty SHA") +} + +// TestGetExternalDeployVersion mirrors the SHA lookup for the version field. +func TestGetExternalDeployVersion(t *testing.T) { + f := newExternalFinalizer("cdk") + assert.Equal(t, "v9.9.9", f.getExternalDeployVersion("cdk")) + assert.Equal(t, "", f.getExternalDeployVersion("missing")) + + f.promotionResult = &PromotionResult{} + assert.Equal(t, "", f.getExternalDeployVersion("cdk"), "empty promotions yields empty version") +} + +// TestUpdateExternalDeployState writes the source SHA, version, repo, and actor +// onto the target env's external state. +func TestUpdateExternalDeployState(t *testing.T) { + f := newExternalFinalizer("cdk") + f.updateExternalDeployState("cdk", "2026-01-02T03:04:05Z") + + target := f.cicdFile.State["uat"] + require.NotNil(t, target, "target env state must be created") + es := target.External["cdk"] + require.NotNil(t, es, "external deploy state must be recorded") + assert.Equal(t, "org/satellite", es.Repo) + assert.Equal(t, "ext-sha-123", es.SHA) + assert.Equal(t, "v9.9.9", es.Version) + assert.Equal(t, "2026-01-02T03:04:05Z", es.DeployedAt) + assert.Equal(t, "deployer", es.DeployedBy) +} + +// TestUpdateExternalDeployState_NoSourceSHA is a no-op when the source env has +// not recorded a SHA for the deploy. +func TestUpdateExternalDeployState_NoSourceSHA(t *testing.T) { + f := newExternalFinalizer("cdk") + // Drop the source SHA so the helper takes its early-return branch. + f.cicdFile.State["test"].External["cdk"].SHA = "" + + f.updateExternalDeployState("cdk", "2026-01-02T03:04:05Z") + + assert.Nil(t, f.cicdFile.State["uat"], "no SHA means no target state is created") +} diff --git a/internal/promote/preflight_glob_test.go b/internal/promote/preflight_glob_test.go new file mode 100644 index 0000000..0d9cc1c --- /dev/null +++ b/internal/promote/preflight_glob_test.go @@ -0,0 +1,84 @@ +package promote + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestMatchGlob covers the single-segment and doublestar dispatch of matchGlob. +func TestMatchGlob(t *testing.T) { + cases := []struct { + name string + pattern string + path string + want bool + }{ + {"exact match", "go.mod", "go.mod", true}, + {"exact non-match", "go.mod", "go.sum", false}, + {"single star matches within a segment", "src/*.go", "src/main.go", true}, + {"single star does not span directories", "src/*.go", "src/sub/main.go", false}, + {"single star same dir", "*.go", "main.go", true}, + {"single star does not cross slash", "*.go", "src/main.go", false}, + {"doublestar prefix matches nested", "src/**", "src/a/b/c.go", true}, + {"doublestar prefix matches direct child", "src/**", "src/main.go", true}, + {"doublestar non-match wrong root", "src/**", "lib/main.go", false}, + {"doublestar middle segment", "a/**/d.go", "a/b/c/d.go", true}, + {"doublestar middle non-match", "a/**/d.go", "a/b/c/e.go", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, matchGlob(tc.pattern, tc.path)) + }) + } +} + +// TestMatchDoublestar exercises the ** handling directly, including the +// trailing-** "matches everything" branch and exhausted-pattern checks. +func TestMatchDoublestar(t *testing.T) { + cases := []struct { + name string + pattern string + path string + want bool + }{ + {"trailing doublestar matches deep path", "infra/**", "infra/modules/vpc/main.tf", true}, + {"trailing doublestar matches single", "infra/**", "infra/main.tf", true}, + {"leading doublestar matches suffix", "**/main.go", "a/b/main.go", true}, + {"leading doublestar matches root file", "**/main.go", "main.go", true}, + {"leading doublestar suffix mismatch", "**/main.go", "a/b/other.go", false}, + {"doublestar with wildcard segment", "src/**/*.go", "src/a/b.go", true}, + {"prefix mismatch before doublestar", "x/**", "y/z", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, matchDoublestar(tc.pattern, tc.path)) + }) + } +} + +// TestMatchParts covers the recursive segment matcher edge cases that the +// higher-level helpers route into: exhausted pattern, exhausted path, and the +// zero-segment ** match. +func TestMatchParts(t *testing.T) { + cases := []struct { + name string + pattern []string + path []string + want bool + }{ + {"equal literal segments", []string{"a", "b"}, []string{"a", "b"}, true}, + {"pattern longer than path", []string{"a", "b", "c"}, []string{"a", "b"}, false}, + {"path longer than pattern without doublestar", []string{"a"}, []string{"a", "b"}, false}, + {"doublestar absorbs zero segments", []string{"a", "**"}, []string{"a"}, true}, + {"doublestar absorbs many segments", []string{"a", "**"}, []string{"a", "b", "c"}, true}, + {"trailing doublestar after full match", []string{"a", "b", "**"}, []string{"a", "b"}, true}, + {"both empty", []string{}, []string{}, true}, + {"empty pattern non-empty path", []string{}, []string{"a"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, matchParts(tc.pattern, tc.path)) + }) + } +} diff --git a/internal/release/coverage_test.go b/internal/release/coverage_test.go new file mode 100644 index 0000000..0407ad7 --- /dev/null +++ b/internal/release/coverage_test.go @@ -0,0 +1,244 @@ +package release + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewManager_DefaultBaseURL(t *testing.T) { + t.Setenv("GITHUB_API_URL", "") + m := NewManager("owner/repo", "tok") + assert.Equal(t, "https://api.github.com", m.baseURL) + assert.Equal(t, "owner/repo", m.repo) + assert.Equal(t, "tok", m.token) + assert.NotNil(t, m.client) + assert.NotNil(t, m.sleepFn) +} + +func TestNewManager_RespectsAPIURLEnv(t *testing.T) { + t.Setenv("GITHUB_API_URL", "https://ghe.example.com/api/v3/") + m := NewManager("owner/repo", "tok") + // Trailing slash is trimmed. + assert.Equal(t, "https://ghe.example.com/api/v3", m.baseURL) +} + +func TestNewManagerWithURL_TrimsTrailingSlash(t *testing.T) { + m := NewManagerWithURL("owner/repo", "tok", "https://api.example.com/") + assert.Equal(t, "https://api.example.com", m.baseURL) + assert.Equal(t, "owner/repo", m.repo) + assert.Equal(t, "tok", m.token) + assert.NotNil(t, m.client) + assert.NotNil(t, m.sleepFn) +} + +func TestIsGitHubHost(t *testing.T) { + assert.True(t, isGitHubHost("https://api.github.com")) + assert.True(t, isGitHubHost("https://ghe.github.example.com")) + assert.False(t, isGitHubHost("http://localhost:3000")) + assert.False(t, isGitHubHost("http://gitea.local")) +} + +func TestSplitVersionPrefix(t *testing.T) { + tests := []struct { + tag string + wantPrefix string + wantErr bool + }{ + {"v1.2.3", "v", false}, + {"1.0.0", "", false}, + {"rel-1.0.0", "rel-", false}, + {"release/2.0.0", "release/", false}, + {"no-core-here", "", true}, + {"v1.2", "", true}, + } + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + prefix, v, err := splitVersionPrefix(tt.tag) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, v) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantPrefix, prefix) + require.NotNil(t, v) + }) + } +} + +func TestNewRequest_InvalidMethodErrors(t *testing.T) { + m := NewManagerWithURL("owner/repo", "tok", "https://api.github.com") + _, err := m.newRequest("BAD METHOD", "/releases", nil) + require.Error(t, err) +} + +func TestNewRequest_SetsHeaders(t *testing.T) { + m := NewManagerWithURL("owner/repo", "tok", "https://api.github.com") + req, err := m.newRequest("POST", "/releases", map[string]interface{}{"a": 1}) + require.NoError(t, err) + assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept")) + assert.Equal(t, "Bearer tok", req.Header.Get("Authorization")) + assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + assert.Equal(t, "https://api.github.com/repos/owner/repo/releases", req.URL.String()) +} + +func TestListTags_ParsesRefs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/git/refs/tags") + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"ref": "refs/tags/v1.0.0"}, + {"ref": "refs/tags/v1.1.0-rc.1"}, + }) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + tags, err := m.listTags() + require.NoError(t, err) + assert.Equal(t, []string{"v1.0.0", "v1.1.0-rc.1"}, tags) +} + +func TestListTags_NotFoundReturnsEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + tags, err := m.listTags() + require.NoError(t, err) + assert.Empty(t, tags) +} + +func TestListTags_ServerErrorReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + _, err := m.listTags() + require.Error(t, err) +} + +func TestDeleteGitTag_AcceptsNoContentAndNotFound(t *testing.T) { + for _, status := range []int{http.StatusNoContent, http.StatusNotFound} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(status) + })) + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + err := m.deleteGitTag("v1.0.0-rc.1") + server.Close() + require.NoError(t, err, "status %d should be accepted", status) + } +} + +func TestDeleteGitTag_ServerErrorReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("nope")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + err := m.deleteGitTag("v1.0.0-rc.1") + require.Error(t, err) +} + +func TestCreateGitTag_SkipsNonGitHubHost(t *testing.T) { + m := &Manager{client: http.DefaultClient, baseURL: "http://gitea.local", token: "t", repo: "owner/repo"} + // No server is contacted on a non-GitHub host. + require.NoError(t, m.createGitTag("v1.0.0", "abc123")) +} + +func TestCreateGitTag_AcceptsCreatedAndExists(t *testing.T) { + for _, status := range []int{http.StatusCreated, http.StatusUnprocessableEntity} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Contains(t, r.URL.Path, "/git/refs") + w.WriteHeader(status) + })) + m := &Manager{client: server.Client(), baseURL: server.URL + "/github", token: "t", repo: "owner/repo"} + err := m.createGitTag("v1.0.0", "abc123") + server.Close() + require.NoError(t, err, "status %d should be accepted", status) + } +} + +func TestCreateGitTag_ServerErrorReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("bad ref")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL + "/github", token: "t", repo: "owner/repo"} + err := m.createGitTag("v1.0.0", "abc123") + require.Error(t, err) +} + +func TestApiRequest_NonSuccessReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + _, err := m.apiRequest("POST", "/releases", map[string]interface{}{"a": 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "403") +} + +func TestApiRequest_InvalidJSONReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + _, err := m.apiRequest("GET", "/releases/1", nil) + require.Error(t, err) +} + +func TestFindRelease_DirectTagHit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/releases/tags/v1.0.0") + _ = json.NewEncoder(w).Encode(GitHubRelease{ID: 42, TagName: "v1.0.0"}) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + rel, err := m.findRelease("v1.0.0", "") + require.NoError(t, err) + require.NotNil(t, rel) + assert.Equal(t, int64(42), rel.ID) +} + +func TestFindRelease_DirectErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("server error")) + })) + defer server.Close() + + m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"} + _, err := m.findRelease("v1.0.0", "") + require.Error(t, err) +} + +func TestCleanupRCTags_InvalidPublishedVersionErrors(t *testing.T) { + m := NewManagerWithURL("owner/repo", "t", "https://api.github.com") + err := m.cleanupRCTags("not-a-version") + require.Error(t, err) +} diff --git a/internal/reset/reset_more_test.go b/internal/reset/reset_more_test.go new file mode 100644 index 0000000..32a04e4 --- /dev/null +++ b/internal/reset/reset_more_test.go @@ -0,0 +1,227 @@ +package reset + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +// newResetRepo creates a real git repo with a deterministic identity, an initial +// commit (so HEAD and the current branch resolve), and an origin remote pointing +// at a GitHub-style SSH URL so getRepoInfo can parse owner/name without any +// network access. +func newResetRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitInDir(t, "", "init", "-b", "main", dir) + configureGitIdentity(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# fixture\n"), 0o600)) + gitInDir(t, dir, "add", "README.md") + gitInDir(t, dir, "commit", "-m", "chore: init") + gitInDir(t, dir, "remote", "add", "origin", "git@github.com:test/repo.git") + return dir +} + +func TestNewCommand_Structure(t *testing.T) { + cmd := NewCommand() + + assert.Equal(t, "reset", cmd.Use) + require.NotNil(t, cmd.RunE) + require.NotNil(t, cmd.PersistentPreRunE) + + for _, name := range []string{"state", "dry-run", "push", "repo", "config", "manifest-key"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected flag %q to be registered", name) + } + + // The manifest-key flag defaults to the package default. + mk := cmd.Flags().Lookup("manifest-key") + require.NotNil(t, mk) + assert.Equal(t, config.DefaultManifestKey, mk.DefValue) +} + +func TestNewCommand_PersistentPreRunE_AutoDetectsConfig(t *testing.T) { + cmd := NewCommand() + // With no --config provided, the pre-run hook resolves a default config path + // rather than leaving it empty. + require.NoError(t, cmd.PersistentPreRunE(cmd, nil)) + cfg := cmd.Flags().Lookup("config") + require.NotNil(t, cfg) + assert.NotEmpty(t, cfg.Value.String()) +} + +func TestRunReset_InitError(t *testing.T) { + // A non-git directory makes New fail, surfacing through runReset. + dir := t.TempDir() + err := runReset(Options{RepoPath: dir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to initialize resetter") +} + +func TestRunReset_DryRunInitError(t *testing.T) { + // Exercises the dry-run banner branch while still failing at initialization. + dir := t.TempDir() + err := runReset(Options{RepoPath: dir, DryRun: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to initialize resetter") +} + +func TestNew_Success(t *testing.T) { + dir := newResetRepo(t) + + r, err := New(Options{RepoPath: dir}) + require.NoError(t, err) + assert.Equal(t, dir, r.repoPath) + assert.Equal(t, "test", r.repoOwner) + assert.Equal(t, "repo", r.repoName) + // ManifestKey defaults when unset. + assert.Equal(t, config.DefaultManifestKey, r.manifestKey) + // Without ResetState the manifest is not parsed. + assert.Nil(t, r.cicdFile) +} + +func TestNew_ResetStateLoadsManifest(t *testing.T) { + dir := newResetRepo(t) + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + state: + dev: + sha: abc123 + version: v1.0.0 +` + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), []byte(manifest), 0o600)) + + r, err := New(Options{RepoPath: dir, ResetState: true}) + require.NoError(t, err) + require.NotNil(t, r.cicdFile) + require.NotNil(t, r.cicdFile.State["dev"]) + assert.Equal(t, "abc123", r.cicdFile.State["dev"].SHA) +} + +func TestNew_ResetStateMissingManifest(t *testing.T) { + dir := newResetRepo(t) + + // No manifest on disk: parsing for ResetState must surface an error. + _, err := New(Options{RepoPath: dir, ResetState: true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse manifest") +} + +func TestNew_CustomConfigAndManifestKey(t *testing.T) { + dir := newResetRepo(t) + cfgPath := filepath.Join(dir, "pipeline.yaml") + manifest := `pipeline: + config: + trunk_branch: main + environments: + - dev + state: + dev: + sha: zzz999 +` + require.NoError(t, os.WriteFile(cfgPath, []byte(manifest), 0o600)) + + r, err := New(Options{RepoPath: dir, ConfigPath: cfgPath, ManifestKey: "pipeline", ResetState: true}) + require.NoError(t, err) + assert.Equal(t, cfgPath, r.configPath) + assert.Equal(t, "pipeline", r.manifestKey) + require.NotNil(t, r.cicdFile) + require.NotNil(t, r.cicdFile.State["dev"]) + assert.Equal(t, "zzz999", r.cicdFile.State["dev"].SHA) +} + +func TestGetRepoInfo_Success(t *testing.T) { + dir := newResetRepo(t) + owner, name, err := getRepoInfo(dir) + require.NoError(t, err) + assert.Equal(t, "test", owner) + assert.Equal(t, "repo", name) +} + +func TestGetRepoInfo_NoRemote(t *testing.T) { + dir := t.TempDir() + gitInDir(t, "", "init", "-b", "main", dir) + configureGitIdentity(t, dir) + + _, _, err := getRepoInfo(dir) + require.Error(t, err) +} + +func TestCurrentBranch(t *testing.T) { + dir := newResetRepo(t) + r := &Resetter{repoPath: dir} + + branch, err := r.currentBranch() + require.NoError(t, err) + assert.Equal(t, "main", branch) +} + +func TestGitOutput_Error(t *testing.T) { + dir := newResetRepo(t) + r := &Resetter{repoPath: dir} + + // rev-parse of a missing ref exits non-zero, surfacing the error path. + _, err := r.gitOutput("rev-parse", "--verify", "does-not-exist") + require.Error(t, err) +} + +func TestDeleteAllTags_NoTags(t *testing.T) { + dir := newResetRepo(t) + r := &Resetter{repoPath: dir, opts: Options{}} + + n, err := r.deleteAllTags() + require.NoError(t, err) + assert.Equal(t, 0, n) +} + +func TestDeleteAllTags_DryRun(t *testing.T) { + dir := newResetRepo(t) + gitInDir(t, dir, "tag", "v1.0.0") + gitInDir(t, dir, "tag", "v1.1.0") + + r := &Resetter{repoPath: dir, opts: Options{DryRun: true}} + n, err := r.deleteAllTags() + require.NoError(t, err) + assert.Equal(t, 2, n) + + // Dry-run must not delete the local tags. + out, err := r.gitOutput("tag", "-l") + require.NoError(t, err) + assert.Contains(t, out, "v1.0.0") + assert.Contains(t, out, "v1.1.0") +} + +func TestDeleteAllTags_DeletesLocalAndRemote(t *testing.T) { + // A local bare remote keeps the remote-tag deletion fast and hermetic + // (no GitHub network), while still exercising the delete loops. + remote := t.TempDir() + gitInDir(t, "", "init", "--bare", "-b", "main", remote) + + dir := t.TempDir() + gitInDir(t, "", "clone", remote, dir) + configureGitIdentity(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# fixture\n"), 0o600)) + gitInDir(t, dir, "add", "README.md") + gitInDir(t, dir, "commit", "-m", "chore: init") + gitInDir(t, dir, "push", "origin", "HEAD:main") + gitInDir(t, dir, "tag", "v1.0.0") + gitInDir(t, dir, "push", "origin", "v1.0.0") + + r := &Resetter{repoPath: dir, opts: Options{}} + n, err := r.deleteAllTags() + require.NoError(t, err) + assert.Equal(t, 1, n) + + // The local tag is gone. + out, err := r.gitOutput("tag", "-l") + require.NoError(t, err) + assert.Empty(t, out) +} diff --git a/internal/rollback/coverage_test.go b/internal/rollback/coverage_test.go new file mode 100644 index 0000000..eb1638b --- /dev/null +++ b/internal/rollback/coverage_test.go @@ -0,0 +1,375 @@ +package rollback + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/statewrite" +) + +// stateOnlyManifest writes a manifest that declares no config block at all, only +// recorded state. It exercises the no-config branches (DeployNames returning nil, +// knownEnvironment falling back to recorded state). +func stateOnlyManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + content := `ci: + state: + prod: + sha: prodnew7654321 + version: v2.0.0 + committed_at: "2026-03-01T11:00:00Z" + committed_by: alice + previous: + - sha: prodold1112223 + version: v1.9.0 + committed_at: "2026-02-15T11:00:00Z" + committed_by: alice +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + return path +} + +func TestConfigPath_ReturnsResolvedPath(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + if got := rb.ConfigPath(); got != path { + t.Errorf("ConfigPath() = %q, want %q", got, path) + } +} + +func TestGitIdentity_DefaultsToBot(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + id := rb.GitIdentity() + if id.Name != "github-actions[bot]" { + t.Errorf("Name = %q, want github-actions[bot]", id.Name) + } + if id.Email != "github-actions[bot]@users.noreply.github.com" { + t.Errorf("Email = %q, want bot noreply", id.Email) + } +} + +func TestGitIdentity_HonorsManifestGitConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + content := `ci: + config: + trunk_branch: main + environments: + - prod + git: + user_name: release-bot + user_email: release-bot@example.com + state: + prod: + sha: prodsha9999999 + version: v1.9.0 + committed_at: "2026-02-01T11:00:00Z" + committed_by: alice +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + rb := newRollbacker(t, path, fakeHistory{}) + + id := rb.GitIdentity() + if id.Name != "release-bot" { + t.Errorf("Name = %q, want release-bot", id.Name) + } + if id.Email != "release-bot@example.com" { + t.Errorf("Email = %q, want release-bot@example.com", id.Email) + } +} + +func TestDeployNames_WithAndWithoutConfig(t *testing.T) { + dir := t.TempDir() + withConfig := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, withConfig, fakeHistory{}) + names := rb.DeployNames() + if len(names) != 1 || names[0] != "services" { + t.Errorf("DeployNames() = %v, want [services]", names) + } + + noConfig := stateOnlyManifest(t) + rb2 := newRollbacker(t, noConfig, fakeHistory{}) + if got := rb2.DeployNames(); got != nil { + t.Errorf("DeployNames() with no config = %v, want nil", got) + } +} + +func TestKnownEnvironment_FallsBackToRecordedState(t *testing.T) { + path := stateOnlyManifest(t) + rb := newRollbacker(t, path, fakeHistory{}) + // prod has recorded state but no config.environments declaration. + if !rb.knownEnvironment("prod") { + t.Error("prod should be known via recorded state") + } + if rb.knownEnvironment("ghost") { + t.Error("ghost should not be known") + } +} + +func TestOrDash(t *testing.T) { + if got := orDash(""); got != "-" { + t.Errorf("orDash(\"\") = %q, want -", got) + } + if got := orDash("v1.0.0"); got != "v1.0.0" { + t.Errorf("orDash(v1.0.0) = %q, want v1.0.0", got) + } +} + +func TestTruncate(t *testing.T) { + cases := []struct{ in, want string }{ + {"abcdef1234567", "abcdef1"}, + {"abc", "abc"}, + {"", "-"}, + {"1234567", "1234567"}, + } + for _, c := range cases { + if got := truncate(c.in); got != c.want { + t.Errorf("truncate(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestGetEnv(t *testing.T) { + t.Setenv("ROLLBACK_TEST_KEY", "value") + if got := getEnv("ROLLBACK_TEST_KEY", "fallback"); got != "value" { + t.Errorf("getEnv set = %q, want value", got) + } + if got := getEnv("ROLLBACK_TEST_UNSET_KEY", "fallback"); got != "fallback" { + t.Errorf("getEnv unset = %q, want fallback", got) + } +} + +func TestMatchHelpers_NilInputs(t *testing.T) { + if got := matchEnv(nil, "x", "state"); got != nil { + t.Errorf("matchEnv(nil) = %+v, want nil", got) + } + if got := matchDeploy(nil, "x", "svc", "state"); got != nil { + t.Errorf("matchDeploy(nil) = %+v, want nil", got) + } +} + +func TestResolveDefaultTarget_GitHistoryFallback(t *testing.T) { + dir := t.TempDir() + // Live prod is at v2.0.0 with no ring; the distinct prior lives in history. + path := writeManifest(t, dir, "newprodsha1234", "v2.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": {{SHA: "oldprodsha5678", Version: "v1.8.0"}}, + }} + rb := newRollbacker(t, path, hist) + + plan, err := rb.Plan("prod", "", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "oldprodsha5678" { + t.Errorf("target sha = %q, want oldprodsha5678", plan.Target.SHA) + } + if plan.Target.Source != "git-history" { + t.Errorf("source = %q, want git-history", plan.Target.Source) + } +} + +func TestResolveDefaultTarget_DeployableScopedFromHistory(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "newprodsha1234", "v2.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": {{SHA: "oldprodsha5678", Version: "v1.8.0", + Deploys: map[string]*config.DeployState{ + "services": {SHA: "svcsha111", Version: "v1.8.0-svc"}, + }}}, + }} + rb := newRollbacker(t, path, hist) + + plan, err := rb.Plan("prod", "", "services") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "svcsha111" { + t.Errorf("target sha = %q, want svcsha111", plan.Target.SHA) + } + if plan.Target.Deployable != "services" { + t.Errorf("deployable = %q, want services", plan.Target.Deployable) + } +} + +func TestResolveDefaultTarget_NoPriorErrors(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "newprodsha1234", "v2.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + + if _, err := rb.Plan("prod", "", ""); err == nil { + t.Error("expected error when no prior version exists (env-scoped)") + } + if _, err := rb.Plan("prod", "", "services"); err == nil { + t.Error("expected error when no prior version exists (deployable-scoped)") + } +} + +func TestReport_JSONOutput(t *testing.T) { + plan := &Plan{ + Environment: "prod", + Target: Target{SHA: "abcdef1234567", Version: "v1.8.0", Source: "git-history"}, + CurrentSHA: "newsha7654321", + CurrentVersion: "v2.0.0", + } + if err := report(plan, true, false); err != nil { + t.Fatalf("report json: %v", err) + } +} + +func TestReport_TextNonNoOpAndDeployable(t *testing.T) { + plan := &Plan{ + Environment: "prod", + Deployable: "services", + Target: Target{SHA: "abcdef1234567", Version: "v1.8.0", Source: "previous-ring"}, + CurrentSHA: "newsha7654321", + CurrentVersion: "v2.0.0", + } + if err := report(plan, false, true); err != nil { + t.Fatalf("report text dry-run: %v", err) + } + if err := report(plan, false, false); err != nil { + t.Fatalf("report text apply: %v", err) + } +} + +func TestRun_ErrorOnBadConfig(t *testing.T) { + err := run(runOptions{configPath: filepath.Join(t.TempDir(), "does-not-exist.yaml"), env: "prod"}) + if err == nil { + t.Fatal("expected error for unreadable manifest") + } +} + +func TestRunPreflight_JSONOutput(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + err := runPreflight(preflightOptions{configPath: path, env: "prod", to: "v1.9.0", jsonOutput: true}) + if err != nil { + t.Fatalf("runPreflight json: %v", err) + } +} + +func TestRunPreflight_NewErrorWritesCannotProceed(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "gha_output") + t.Setenv("GITHUB_OUTPUT", outFile) + + err := runPreflight(preflightOptions{ + configPath: filepath.Join(t.TempDir(), "missing.yaml"), + env: "prod", + ghaOutput: true, + }) + if err == nil { + t.Fatal("expected error for unreadable manifest") + } + data, readErr := os.ReadFile(outFile) + if readErr != nil { + t.Fatalf("read gha output: %v", readErr) + } + if string(data) == "" { + t.Error("expected can_proceed=false written on New failure") + } +} + +func TestRunFinalize_ErrorOnBadConfig(t *testing.T) { + err := runFinalize(finalizeOptions{ + configPath: filepath.Join(t.TempDir(), "missing.yaml"), + env: "prod", + }) + if err == nil { + t.Fatal("expected error for unreadable manifest") + } +} + +func TestIsRealGitHub(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "") + if !isRealGitHub() { + t.Error("empty GITHUB_SERVER_URL should be treated as real GitHub") + } + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + if !isRealGitHub() { + t.Error("github.com should be real GitHub") + } + t.Setenv("GITHUB_SERVER_URL", "https://gitea.local") + if isRealGitHub() { + t.Error("gitea host should not be real GitHub") + } +} + +func TestTrunkBranchFromEnv(t *testing.T) { + t.Setenv("GITHUB_REF", "refs/heads/release") + if got := trunkBranchFromEnv(); got != "release" { + t.Errorf("trunkBranchFromEnv() = %q, want release", got) + } + t.Setenv("GITHUB_REF", "some-branch") + if got := trunkBranchFromEnv(); got != "some-branch" { + t.Errorf("trunkBranchFromEnv() = %q, want some-branch", got) + } + t.Setenv("GITHUB_REF", "") + if got := trunkBranchFromEnv(); got != "main" { + t.Errorf("trunkBranchFromEnv() = %q, want main", got) + } +} + +func TestWriteStateViaAPI_RequiresRepository(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY", "") + err := writeStateViaAPI("manifest.yaml", "msg", statewrite.Identity{}) + if err == nil { + t.Fatal("expected error when GITHUB_REPOSITORY is unset") + } +} + +func TestCommitAndPush_NoChangesIsNoOp(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitInit(t, dir) + gitCommitFile(t, dir, "manifest.yaml", manifestAt("prodsha9999999", "v1.9.0"), "seed") + + t.Chdir(dir) + // The committed manifest is unchanged, so commitAndPush observes a clean tree + // and returns nil without attempting any push. + if err := commitAndPush("manifest.yaml", "prod", statewrite.Identity{}); err != nil { + t.Fatalf("commitAndPush no-op: %v", err) + } +} + +func TestExtractEnvState_EdgeCases(t *testing.T) { + // Invalid YAML returns nil. + if s := extractEnvState([]byte("::: not yaml :::"), config.DefaultManifestKey, "prod"); s != nil { + t.Errorf("invalid yaml = %+v, want nil", s) + } + // Missing top-level key returns nil. + if s := extractEnvState([]byte("other:\n state: {}\n"), config.DefaultManifestKey, "prod"); s != nil { + t.Errorf("missing key = %+v, want nil", s) + } + // Env absent returns nil. + missingEnv := []byte("ci:\n state:\n dev:\n sha: x\n") + if s := extractEnvState(missingEnv, config.DefaultManifestKey, "prod"); s != nil { + t.Errorf("absent env = %+v, want nil", s) + } + // Empty (zero-value) env state returns nil. + emptyEnv := []byte("ci:\n state:\n prod: {}\n") + if s := extractEnvState(emptyEnv, config.DefaultManifestKey, "prod"); s != nil { + t.Errorf("empty env state = %+v, want nil", s) + } + // A populated env state is returned, and an empty manifest key defaults. + good := []byte("ci:\n state:\n prod:\n sha: prodsha9999999\n version: v1.9.0\n") + s := extractEnvState(good, "", "prod") + if s == nil || s.SHA != "prodsha9999999" { + t.Errorf("populated env state not returned: %+v", s) + } +} diff --git a/internal/version/command_test.go b/internal/version/command_test.go new file mode 100644 index 0000000..5899ec6 --- /dev/null +++ b/internal/version/command_test.go @@ -0,0 +1,167 @@ +package version + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionNewCommand_Structure(t *testing.T) { + cmd := NewCommand() + + assert.Equal(t, "next-version", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + + assert.NotNil(t, cmd.Flags().Lookup("config")) + assert.NotNil(t, cmd.Flags().Lookup("environment")) + assert.NotNil(t, cmd.Flags().Lookup("base-sha")) + assert.NotNil(t, cmd.Flags().Lookup("head-sha")) + assert.NotNil(t, cmd.Flags().Lookup("json")) +} + +func TestVersionNewCommand_RunE_BadConfigPath(t *testing.T) { + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--environment", "dev", + "--config", "/nonexistent/path/manifest.yaml", + }) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "loading config") +} + +func TestVersionNewCommand_RunE_MissingEnvironmentFlag(t *testing.T) { + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + // environment is required; omitting it produces an error before RunE + cmd.SetArgs([]string{"--config", "/some/path.yaml"}) + err := cmd.Execute() + assert.Error(t, err) +} + +// minimalConfig returns a valid manifest.yaml in a temp dir suitable for +// exercising the version command RunE without hitting infrastructure. +func minimalVersionConfig(t *testing.T, environments []string) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + + // Build environments list + envLines := "" + for _, e := range environments { + envLines += " - " + e + "\n" + } + + content := "ci:\n config:\n trunk_branch: main\n environments:\n" + envLines + require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600)) + return configPath +} + +func TestVersionNewCommand_RunE_EnvironmentNotFound(t *testing.T) { + configPath := minimalVersionConfig(t, []string{"dev", "test"}) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--environment", "staging", + "--config", configPath, + }) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "environment") +} + +func TestVersionNewCommand_RunE_ValidConfigTextOutput(t *testing.T) { + configPath := minimalVersionConfig(t, []string{"dev", "test"}) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + // HEAD~1..HEAD gets exactly one real commit; exercises the commit-parsing loop. + cmd.SetArgs([]string{ + "--environment", "dev", + "--config", configPath, + "--base-sha", "HEAD~1", + "--head-sha", "HEAD", + }) + // May succeed or fail depending on git availability; exercise the code path. + _ = cmd.Execute() +} + +func TestVersionNewCommand_RunE_ValidConfigJSONOutput(t *testing.T) { + configPath := minimalVersionConfig(t, []string{"dev", "test"}) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--environment", "dev", + "--config", configPath, + "--base-sha", "HEAD~1", + "--head-sha", "HEAD", + "--json", + }) + _ = cmd.Execute() +} + +func TestVersionNewCommand_RunE_EmptyRange(t *testing.T) { + configPath := minimalVersionConfig(t, []string{"dev", "test"}) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + // HEAD..HEAD yields zero commits; exercises the no-baseSHA fallback branch. + cmd.SetArgs([]string{ + "--environment", "dev", + "--config", configPath, + }) + _ = cmd.Execute() +} + +func TestVersionNewCommand_RunE_NoConfigFlag(t *testing.T) { + // When --config is omitted, FindConfigFile is called to locate a manifest. + // From the package test directory there is no .github/manifest.yaml, so + // config.Parse fails with "loading config". + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--environment", "dev"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "loading config") +} + +func TestBumpTypeString(t *testing.T) { + tests := []struct { + bump BumpType + want string + }{ + {BumpMajor, "major"}, + {BumpMinor, "minor"}, + {BumpPatch, "patch"}, + {BumpNone, "none"}, + {BumpType(99), "none"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, bumpTypeString(tt.bump)) + }) + } +} diff --git a/internal/version/version_coverage_test.go b/internal/version/version_coverage_test.go new file mode 100644 index 0000000..2b1195e --- /dev/null +++ b/internal/version/version_coverage_test.go @@ -0,0 +1,116 @@ +package version + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/changelog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalculateNext_InvalidNextEnvVersion(t *testing.T) { + calc := NewCalculator("v") + _, err := calc.CalculateNext("", "not-a-version", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing next env version") +} + +func TestCalculateNext_ChoreOnlyCommits(t *testing.T) { + // Chore commits yield BumpNone; when commits are present BumpNone is promoted + // to BumpPatch so that real work always produces a version increment. + calc := NewCalculator("v") + commits := []changelog.ConventionalCommit{ + {Type: "chore", Description: "update dependencies"}, + } + got, err := calc.CalculateNext("", "v1.0.0", commits) + require.NoError(t, err) + assert.Equal(t, "v1.0.1-rc.0", got.String()) +} + +func TestCalculateNext_UnparseableCurrentDevVersion(t *testing.T) { + // If currentDevVersion is non-empty but cannot be parsed, the RC counter + // resets to 0 rather than erroring. + calc := NewCalculator("v") + commits := []changelog.ConventionalCommit{ + {Type: "feat", Description: "new feature"}, + } + got, err := calc.CalculateNext("not-a-version", "v1.0.0", commits) + require.NoError(t, err) + assert.Equal(t, 0, got.PreRelease) +} + +func TestCalculateNext_NoCommitsNoNextEnv(t *testing.T) { + // Zero commits and no next env version: no bump, but minimum v0.1.0 rule applies. + calc := NewCalculator("v") + got, err := calc.CalculateNext("", "", nil) + require.NoError(t, err) + assert.Equal(t, 0, got.Major) + assert.Equal(t, 1, got.Minor) + assert.Equal(t, 0, got.PreRelease) +} + +func TestVersion_Compare_ReleaseVsPreRelease(t *testing.T) { + tests := []struct { + name string + a string + b string + want int + }{ + { + name: "pre-release sorts before its release", + a: "v1.0.0-rc.0", + b: "v1.0.0", + want: -1, + }, + { + name: "release sorts after any pre-release", + a: "v1.0.0", + b: "v1.0.0-rc.0", + want: 1, + }, + { + name: "two equal releases", + a: "v1.0.0", + b: "v1.0.0", + want: 0, + }, + { + name: "same rc without hotfix is equal to itself", + a: "v1.0.0-rc.2", + b: "v1.0.0-rc.2", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + va := mustParse(t, tt.a) + vb := mustParse(t, tt.b) + assert.Equal(t, tt.want, va.Compare(vb)) + }) + } +} + +func TestVersion_Compare_MajorMinorPatch(t *testing.T) { + tests := []struct { + name string + a string + b string + want int + }{ + {"major greater", "v2.0.0", "v1.0.0", 1}, + {"major lesser", "v1.0.0", "v2.0.0", -1}, + {"minor greater", "v1.2.0", "v1.1.0", 1}, + {"minor lesser", "v1.1.0", "v1.2.0", -1}, + {"patch greater", "v1.0.2", "v1.0.1", 1}, + {"patch lesser", "v1.0.1", "v1.0.2", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + va := mustParse(t, tt.a) + vb := mustParse(t, tt.b) + assert.Equal(t, tt.want, va.Compare(vb)) + }) + } +}