diff --git a/server/events/automerger_internal_test.go b/server/events/automerger_internal_test.go new file mode 100644 index 0000000000..6def1cc107 --- /dev/null +++ b/server/events/automerger_internal_test.go @@ -0,0 +1,205 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +// Internal tests for AutoMerger (package events, not events_test) so that we +// can access the unexported automerge / automergeEnabled / deleteSourceBranchOnMergeEnabled +// methods directly. +package events + +import ( + "errors" + "testing" + + . "github.com/petergtz/pegomock/v4" + "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/events/models" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +// buildApplyContext builds a minimal *command.Context suitable for automerge tests. +func buildApplyContext(t *testing.T, vcsClient *vcsmocks.MockClient) *command.Context { + t.Helper() + return &command.Context{ + Log: logging.NewNoopLogger(t), + Pull: models.PullRequest{ + Num: 1, + BaseRepo: models.Repo{ + FullName: "owner/repo", + VCSHost: models.VCSHost{Type: models.Github}, + }, + }, + } +} + +// --------------------------------------------------------------------------- +// automerge +// --------------------------------------------------------------------------- + +func TestAutomerge_AllApplied_MergesAndComments(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + am := &AutoMerger{VCSClient: vcsClient, GlobalAutomerge: true} + ctx := buildApplyContext(t, vcsClient) + + pullStatus := models.PullStatus{ + Projects: []models.ProjectStatus{ + {Status: models.AppliedPlanStatus}, + {Status: models.AppliedPlanStatus}, + }, + } + + am.automerge(ctx, pullStatus, false, "") + + // Should have commented (once for the automerge announcement) and merged once. + vcsClient.VerifyWasCalledOnce().CreateComment( + Any[logging.SimpleLogging](), + Eq(ctx.Pull.BaseRepo), + Eq(ctx.Pull.Num), + Eq(automergeComment), + Eq(command.Apply.String()), + ) + vcsClient.VerifyWasCalledOnce().MergePull( + Any[logging.SimpleLogging](), + Eq(ctx.Pull), + Eq(models.PullRequestOptions{DeleteSourceBranchOnMerge: false, MergeMethod: ""}), + ) +} + +func TestAutomerge_NotAllApplied_DoesNotMerge(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + am := &AutoMerger{VCSClient: vcsClient, GlobalAutomerge: true} + ctx := buildApplyContext(t, vcsClient) + + pullStatus := models.PullStatus{ + Projects: []models.ProjectStatus{ + {Status: models.AppliedPlanStatus, RepoRelDir: "a", Workspace: "default"}, + {Status: models.PlannedPlanStatus, RepoRelDir: "b", Workspace: "default"}, + }, + } + + am.automerge(ctx, pullStatus, false, "") + + // Neither comment nor merge should have been called. + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string](), + ) + vcsClient.VerifyWasCalled(Never()).MergePull( + Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions](), + ) +} + +func TestAutomerge_MergeFails_PostsFailureComment(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + am := &AutoMerger{VCSClient: vcsClient, GlobalAutomerge: true} + ctx := buildApplyContext(t, vcsClient) + + pullStatus := models.PullStatus{ + Projects: []models.ProjectStatus{ + {Status: models.AppliedPlanStatus}, + }, + } + + mergeErr := errors.New("merge conflict") + When(vcsClient.MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]())). + ThenReturn(mergeErr) + + am.automerge(ctx, pullStatus, false, "") + + // Two CreateComment calls expected: announcement + failure. + vcsClient.VerifyWasCalled(Twice()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string](), + ) +} + +func TestAutomerge_DeleteSourceBranch_PassedToMerge(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + am := &AutoMerger{VCSClient: vcsClient, GlobalAutomerge: true} + ctx := buildApplyContext(t, vcsClient) + + pullStatus := models.PullStatus{ + Projects: []models.ProjectStatus{ + {Status: models.AppliedPlanStatus}, + }, + } + + am.automerge(ctx, pullStatus, true, "squash") + + vcsClient.VerifyWasCalledOnce().MergePull( + Any[logging.SimpleLogging](), + Eq(ctx.Pull), + Eq(models.PullRequestOptions{DeleteSourceBranchOnMerge: true, MergeMethod: "squash"}), + ) +} + +func TestAutomerge_EmptyProjects_VacuouslyMerges(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + am := &AutoMerger{VCSClient: vcsClient, GlobalAutomerge: true} + ctx := buildApplyContext(t, vcsClient) + + // No projects == vacuously all applied → should still merge. + am.automerge(ctx, models.PullStatus{}, false, "") + + vcsClient.VerifyWasCalledOnce().MergePull( + Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions](), + ) +} + +// --------------------------------------------------------------------------- +// automergeEnabled +// --------------------------------------------------------------------------- + +func TestAutomergeEnabled_GlobalTrue_NoProjects(t *testing.T) { + am := &AutoMerger{GlobalAutomerge: true} + Equals(t, true, am.automergeEnabled(nil)) + Equals(t, true, am.automergeEnabled([]command.ProjectContext{})) +} + +func TestAutomergeEnabled_GlobalFalse_NoProjects(t *testing.T) { + am := &AutoMerger{GlobalAutomerge: false} + Equals(t, false, am.automergeEnabled(nil)) + Equals(t, false, am.automergeEnabled([]command.ProjectContext{})) +} + +func TestAutomergeEnabled_ProjectOverridesGlobal(t *testing.T) { + // Even when GlobalAutomerge is false, a project with AutomergeEnabled=true should win. + am := &AutoMerger{GlobalAutomerge: false} + Equals(t, true, am.automergeEnabled([]command.ProjectContext{ + {AutomergeEnabled: true}, + })) + + // And when GlobalAutomerge is true, a project with AutomergeEnabled=false should win. + am2 := &AutoMerger{GlobalAutomerge: true} + Equals(t, false, am2.automergeEnabled([]command.ProjectContext{ + {AutomergeEnabled: false}, + })) +} + +// --------------------------------------------------------------------------- +// deleteSourceBranchOnMergeEnabled +// --------------------------------------------------------------------------- + +func TestDeleteSourceBranchOnMergeEnabled_NoProjects_ReturnsFalse(t *testing.T) { + am := &AutoMerger{} + Equals(t, false, am.deleteSourceBranchOnMergeEnabled(nil)) + Equals(t, false, am.deleteSourceBranchOnMergeEnabled([]command.ProjectContext{})) +} + +func TestDeleteSourceBranchOnMergeEnabled_WithFlag(t *testing.T) { + am := &AutoMerger{} + Equals(t, true, am.deleteSourceBranchOnMergeEnabled([]command.ProjectContext{ + {DeleteSourceBranchOnMerge: true}, + })) +} + +func TestDeleteSourceBranchOnMergeEnabled_FlagFalse(t *testing.T) { + am := &AutoMerger{} + Equals(t, false, am.deleteSourceBranchOnMergeEnabled([]command.ProjectContext{ + {DeleteSourceBranchOnMerge: false}, + })) +} diff --git a/server/events/db_updater_internal_test.go b/server/events/db_updater_internal_test.go new file mode 100644 index 0000000000..90f2110fa0 --- /dev/null +++ b/server/events/db_updater_internal_test.go @@ -0,0 +1,194 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +// Internal tests for DBUpdater so that we can call the unexported updateDB method. +package events + +import ( + "errors" + "testing" + + dbmocks "github.com/runatlantis/atlantis/server/core/db/mocks" + "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" + "go.uber.org/mock/gomock" +) + +// buildDBContext returns a minimal *command.Context for DB-updater tests. +func buildDBContext(t *testing.T) *command.Context { + t.Helper() + return &command.Context{ + Log: logging.NewNoopLogger(t), + } +} + +// TestDBUpdater_UpdateDB_PassesFilteredResults verifies that updateDB removes +// DirNotExistErr results before forwarding to the database. +func TestDBUpdater_UpdateDB_PassesFilteredResults(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 1} + okResult := command.ProjectResult{RepoRelDir: "dir/ok", Workspace: "default"} + dirErrResult := command.ProjectResult{ + RepoRelDir: "dir/missing", + Workspace: "default", + ProjectCommandOutput: command.ProjectCommandOutput{ + Error: DirNotExistErr{RepoRelDir: "dir/missing"}, + }, + } + + wantPullStatus := models.PullStatus{Pull: pull} + // Only okResult should be forwarded; dirErrResult must be filtered out. + mockDB.EXPECT(). + UpdatePullWithResults(pull, []command.ProjectResult{okResult}). + Return(wantPullStatus, nil) + + got, err := updater.updateDB(ctx, pull, []command.ProjectResult{okResult, dirErrResult}) + Ok(t, err) + Equals(t, wantPullStatus, got) +} + +// TestDBUpdater_UpdateDB_AllResultsOK verifies that when none of the results +// carry a DirNotExistErr, all results are forwarded to the database. +func TestDBUpdater_UpdateDB_AllResultsOK(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 2} + results := []command.ProjectResult{ + {RepoRelDir: "dir/a", Workspace: "default"}, + {RepoRelDir: "dir/b", Workspace: "staging"}, + } + wantPullStatus := models.PullStatus{Pull: pull} + + mockDB.EXPECT(). + UpdatePullWithResults(pull, results). + Return(wantPullStatus, nil) + + got, err := updater.updateDB(ctx, pull, results) + Ok(t, err) + Equals(t, wantPullStatus, got) +} + +// TestDBUpdater_UpdateDB_AllDirNotExist verifies that when every result is a +// DirNotExistErr, an empty slice is forwarded to the database. +func TestDBUpdater_UpdateDB_AllDirNotExist(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 3} + results := []command.ProjectResult{ + { + RepoRelDir: "a", + ProjectCommandOutput: command.ProjectCommandOutput{ + Error: DirNotExistErr{RepoRelDir: "a"}, + }, + }, + { + RepoRelDir: "b", + ProjectCommandOutput: command.ProjectCommandOutput{ + Error: DirNotExistErr{RepoRelDir: "b"}, + }, + }, + } + wantPullStatus := models.PullStatus{} + + // Expect an empty (nil) slice since all were filtered. + mockDB.EXPECT(). + UpdatePullWithResults(pull, []command.ProjectResult(nil)). + Return(wantPullStatus, nil) + + got, err := updater.updateDB(ctx, pull, results) + Ok(t, err) + Equals(t, wantPullStatus, got) +} + +// TestDBUpdater_UpdateDB_DatabaseError verifies that database errors are +// propagated back to the caller. +func TestDBUpdater_UpdateDB_DatabaseError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 4} + results := []command.ProjectResult{{RepoRelDir: "dir/x"}} + wantErr := errors.New("database write failed") + + mockDB.EXPECT(). + UpdatePullWithResults(pull, results). + Return(models.PullStatus{}, wantErr) + + _, err := updater.updateDB(ctx, pull, results) + Assert(t, err != nil, "expected database error to be returned") + Equals(t, wantErr, err) +} + +// TestDBUpdater_UpdateDB_NonDirNotExistErrorKept verifies that a non-DirNotExistErr +// error result is NOT filtered – it must be forwarded to the database. +func TestDBUpdater_UpdateDB_NonDirNotExistErrorKept(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 5} + otherErr := errors.New("some other error") + result := command.ProjectResult{ + RepoRelDir: "dir/y", + ProjectCommandOutput: command.ProjectCommandOutput{ + Error: otherErr, + }, + } + + wantPullStatus := models.PullStatus{} + // The result with a generic error must NOT be filtered out. + mockDB.EXPECT(). + UpdatePullWithResults(pull, []command.ProjectResult{result}). + Return(wantPullStatus, nil) + + got, err := updater.updateDB(ctx, pull, []command.ProjectResult{result}) + Ok(t, err) + Equals(t, wantPullStatus, got) +} + +// TestDBUpdater_UpdateDB_EmptyResults verifies that an empty input slice +// results in UpdatePullWithResults being called with an empty (nil) slice. +func TestDBUpdater_UpdateDB_EmptyResults(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := dbmocks.NewMockDatabase(ctrl) + updater := &DBUpdater{Database: mockDB} + ctx := buildDBContext(t) + + pull := models.PullRequest{Num: 6} + wantPullStatus := models.PullStatus{} + + mockDB.EXPECT(). + UpdatePullWithResults(pull, []command.ProjectResult(nil)). + Return(wantPullStatus, nil) + + got, err := updater.updateDB(ctx, pull, []command.ProjectResult{}) + Ok(t, err) + Equals(t, wantPullStatus, got) +} diff --git a/server/events/vcs/common/instrumented_client_test.go b/server/events/vcs/common/instrumented_client_test.go new file mode 100644 index 0000000000..25c39298c1 --- /dev/null +++ b/server/events/vcs/common/instrumented_client_test.go @@ -0,0 +1,351 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package common_test + +import ( + "errors" + "testing" + + . "github.com/petergtz/pegomock/v4" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs/common" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/metrics/metricstest" + . "github.com/runatlantis/atlantis/testing" +) + +// newInstrumentedClient is a test helper that wires up an InstrumentedClient +// with a mock backing client and a real (but silent) metrics scope. +func newInstrumentedClient(t *testing.T, inner *vcsmocks.MockClient) *common.InstrumentedClient { + t.Helper() + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + return &common.InstrumentedClient{ + Client: inner, + StatsScope: scope, + Logger: logger, + } +} + +// makeTestRepo returns a minimal Repo with the given full name. +func makeTestRepo(fullName string) models.Repo { + return models.Repo{FullName: fullName} +} + +// makeTestPull returns a minimal PullRequest with the given Num. +func makeTestPull(num int) models.PullRequest { + return models.PullRequest{Num: num, BaseRepo: makeTestRepo("owner/repo")} +} + +// --------------------------------------------------------------------------- +// GetModifiedFiles +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_GetModifiedFiles_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantFiles := []string{"main.tf", "variables.tf"} + + When(inner.GetModifiedFiles(logger, repo, pull)).ThenReturn(wantFiles, nil) + + files, err := client.GetModifiedFiles(logger, repo, pull) + Ok(t, err) + Equals(t, wantFiles, files) + inner.VerifyWasCalledOnce().GetModifiedFiles(logger, repo, pull) +} + +func TestInstrumentedClient_GetModifiedFiles_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantErr := errors.New("api failure") + + When(inner.GetModifiedFiles(logger, repo, pull)).ThenReturn(nil, wantErr) + + _, err := client.GetModifiedFiles(logger, repo, pull) + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// CreateComment +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_CreateComment_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + When(inner.CreateComment(logger, repo, 1, "msg", "plan")).ThenReturn(nil) + + err := client.CreateComment(logger, repo, 1, "msg", "plan") + Ok(t, err) + inner.VerifyWasCalledOnce().CreateComment(logger, repo, 1, "msg", "plan") +} + +func TestInstrumentedClient_CreateComment_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + wantErr := errors.New("create comment failed") + When(inner.CreateComment(logger, repo, 1, "msg", "plan")).ThenReturn(wantErr) + + err := client.CreateComment(logger, repo, 1, "msg", "plan") + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// ReactToComment +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_ReactToComment_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + When(inner.ReactToComment(logger, repo, 1, int64(42), "eyes")).ThenReturn(nil) + + err := client.ReactToComment(logger, repo, 1, int64(42), "eyes") + Ok(t, err) + inner.VerifyWasCalledOnce().ReactToComment(logger, repo, 1, int64(42), "eyes") +} + +func TestInstrumentedClient_ReactToComment_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + wantErr := errors.New("react failed") + When(inner.ReactToComment(logger, repo, 1, int64(42), "eyes")).ThenReturn(wantErr) + + err := client.ReactToComment(logger, repo, 1, int64(42), "eyes") + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// HidePrevCommandComments +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_HidePrevCommandComments_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + When(inner.HidePrevCommandComments(logger, repo, 1, "plan", "")).ThenReturn(nil) + + err := client.HidePrevCommandComments(logger, repo, 1, "plan", "") + Ok(t, err) + inner.VerifyWasCalledOnce().HidePrevCommandComments(logger, repo, 1, "plan", "") +} + +func TestInstrumentedClient_HidePrevCommandComments_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + wantErr := errors.New("hide failed") + When(inner.HidePrevCommandComments(logger, repo, 1, "plan", "")).ThenReturn(wantErr) + + err := client.HidePrevCommandComments(logger, repo, 1, "plan", "") + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// PullIsApproved +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_PullIsApproved_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantStatus := models.ApprovalStatus{IsApproved: true, ApprovedBy: "alice"} + + When(inner.PullIsApproved(logger, repo, pull)).ThenReturn(wantStatus, nil) + + got, err := client.PullIsApproved(logger, repo, pull) + Ok(t, err) + Equals(t, wantStatus, got) +} + +func TestInstrumentedClient_PullIsApproved_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantErr := errors.New("approval check failed") + + When(inner.PullIsApproved(logger, repo, pull)).ThenReturn(models.ApprovalStatus{}, wantErr) + + _, err := client.PullIsApproved(logger, repo, pull) + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// PullIsMergeable +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_PullIsMergeable_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantStatus := models.MergeableStatus{IsMergeable: true} + + When(inner.PullIsMergeable(logger, repo, pull, "atlantis", []string{})).ThenReturn(wantStatus, nil) + + got, err := client.PullIsMergeable(logger, repo, pull, "atlantis", []string{}) + Ok(t, err) + Equals(t, wantStatus, got) +} + +func TestInstrumentedClient_PullIsMergeable_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantErr := errors.New("mergeable check failed") + + When(inner.PullIsMergeable(logger, repo, pull, "atlantis", []string{})).ThenReturn(models.MergeableStatus{}, wantErr) + + _, err := client.PullIsMergeable(logger, repo, pull, "atlantis", []string{}) + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// UpdateStatus +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_UpdateStatus_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + When(inner.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url")).ThenReturn(nil) + + err := client.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Ok(t, err) + inner.VerifyWasCalledOnce().UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") +} + +func TestInstrumentedClient_UpdateStatus_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(1) + wantErr := errors.New("status update failed") + When(inner.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url")).ThenReturn(wantErr) + + err := client.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Equals(t, wantErr, err) +} + +// TestInstrumentedClient_UpdateStatus_NoPR verifies that UpdateStatus is a +// no-op when the pull request number is 0 (i.e. plan triggered outside a PR). +func TestInstrumentedClient_UpdateStatus_NoPR(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + repo := makeTestRepo("owner/repo") + pull := makeTestPull(0) // no PR number + + // The underlying client must NOT be called. + err := client.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Ok(t, err) + inner.VerifyWasCalled(Never()).UpdateStatus( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Any[models.CommitStatus](), Any[string](), Any[string](), Any[string](), + ) +} + +// --------------------------------------------------------------------------- +// MergePull +// --------------------------------------------------------------------------- + +func TestInstrumentedClient_MergePull_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + pull := makeTestPull(1) + pull.BaseRepo = makeTestRepo("owner/repo") + opts := models.PullRequestOptions{DeleteSourceBranchOnMerge: true} + When(inner.MergePull(logger, pull, opts)).ThenReturn(nil) + + err := client.MergePull(logger, pull, opts) + Ok(t, err) + inner.VerifyWasCalledOnce().MergePull(logger, pull, opts) +} + +func TestInstrumentedClient_MergePull_Error(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + inner := vcsmocks.NewMockClient() + client := newInstrumentedClient(t, inner) + + pull := makeTestPull(1) + pull.BaseRepo = makeTestRepo("owner/repo") + opts := models.PullRequestOptions{} + wantErr := errors.New("merge failed") + When(inner.MergePull(logger, pull, opts)).ThenReturn(wantErr) + + err := client.MergePull(logger, pull, opts) + Equals(t, wantErr, err) +} + +// --------------------------------------------------------------------------- +// SetGitScopeTags +// --------------------------------------------------------------------------- + +func TestSetGitScopeTags_ReturnsTaggedScope(t *testing.T) { + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + + // Calling SetGitScopeTags must not panic and must return a non-nil scope. + tagged := common.SetGitScopeTags(scope, "owner/repo", 42) + Assert(t, tagged != nil, "expected non-nil tagged scope") +} diff --git a/server/events/vcs/not_configured_vcs_client_test.go b/server/events/vcs/not_configured_vcs_client_test.go new file mode 100644 index 0000000000..ceec1a5c6d --- /dev/null +++ b/server/events/vcs/not_configured_vcs_client_test.go @@ -0,0 +1,108 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package vcs_test + +import ( + "testing" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +// TestNotConfiguredVCSClient_ErrorMethods verifies that methods that should +// return an error all do so with a message that mentions the host type. +func TestNotConfiguredVCSClient_ErrorMethods(t *testing.T) { + logger := logging.NewNoopLogger(t) + client := &vcs.NotConfiguredVCSClient{Host: models.Github} + + repo := models.Repo{} + pull := models.PullRequest{} + + _, err := client.GetModifiedFiles(logger, repo, pull) + Assert(t, err != nil, "GetModifiedFiles should return an error") + ErrContains(t, "Github", err) + + err = client.CreateComment(logger, repo, 1, "msg", "plan") + Assert(t, err != nil, "CreateComment should return an error") + + _, err = client.PullIsApproved(logger, repo, pull) + Assert(t, err != nil, "PullIsApproved should return an error") + + _, err = client.PullIsMergeable(logger, repo, pull, "atlantis", nil) + Assert(t, err != nil, "PullIsMergeable should return an error") + + err = client.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Assert(t, err != nil, "UpdateStatus should return an error") + + err = client.MergePull(logger, pull, models.PullRequestOptions{}) + Assert(t, err != nil, "MergePull should return an error") + + _, err = client.MarkdownPullLink(pull) + Assert(t, err != nil, "MarkdownPullLink should return an error") + + _, err = client.GetTeamNamesForUser(logger, repo, models.User{}) + Assert(t, err != nil, "GetTeamNamesForUser should return an error") + + _, _, err = client.GetFileContent(logger, repo, "main", "foo.tf") + Assert(t, err != nil, "GetFileContent should return an error") + + _, err = client.GetCloneURL(logger, models.Github, "owner/repo") + Assert(t, err != nil, "GetCloneURL should return an error") + + _, err = client.GetPullLabels(logger, repo, pull) + Assert(t, err != nil, "GetPullLabels should return an error") +} + +// TestNotConfiguredVCSClient_NoopMethods verifies that no-op methods return +// no error (they silently ignore calls). +func TestNotConfiguredVCSClient_NoopMethods(t *testing.T) { + logger := logging.NewNoopLogger(t) + client := &vcs.NotConfiguredVCSClient{Host: models.Gitlab} + + repo := models.Repo{} + pull := models.PullRequest{} + + // HidePrevCommandComments is a no-op – returns nil. + err := client.HidePrevCommandComments(logger, repo, 1, "plan", "") + Ok(t, err) + + // ReactToComment is a no-op – returns nil. + err = client.ReactToComment(logger, repo, 1, int64(99), "eyes") + Ok(t, err) + + // DiscardReviews is a no-op – returns nil. + err = client.DiscardReviews(logger, repo, pull) + Ok(t, err) + + // SupportsSingleFileDownload always returns false. + Equals(t, false, client.SupportsSingleFileDownload(repo)) +} + +// TestNotConfiguredVCSClient_ErrorMessageContainsHost verifies that the error +// message returned by error-producing methods always identifies the host. +func TestNotConfiguredVCSClient_ErrorMessageContainsHost(t *testing.T) { + cases := []models.VCSHostType{ + models.Github, + models.Gitlab, + models.BitbucketCloud, + models.BitbucketServer, + models.AzureDevops, + models.Gitea, + } + + logger := logging.NewNoopLogger(t) + for _, hostType := range cases { + t.Run(hostType.String(), func(t *testing.T) { + client := &vcs.NotConfiguredVCSClient{Host: hostType} + repo := models.Repo{} + pull := models.PullRequest{} + + _, err := client.GetModifiedFiles(logger, repo, pull) + Assert(t, err != nil, "expected error") + ErrContains(t, hostType.String(), err) + }) + } +} diff --git a/server/events/vcs/proxy_test.go b/server/events/vcs/proxy_test.go new file mode 100644 index 0000000000..deef24d128 --- /dev/null +++ b/server/events/vcs/proxy_test.go @@ -0,0 +1,236 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package vcs_test + +import ( + "errors" + "testing" + + . "github.com/petergtz/pegomock/v4" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +// makeRepo creates a Repo with the given VCS host type. +func makeRepo(hostType models.VCSHostType) models.Repo { + return models.Repo{ + VCSHost: models.VCSHost{Type: hostType}, + } +} + +// makePull creates a PullRequest whose BaseRepo uses the given VCS host type. +func makePull(hostType models.VCSHostType) models.PullRequest { + return models.PullRequest{BaseRepo: makeRepo(hostType)} +} + +// TestNewClientProxy_NilClientsUseNotConfigured verifies that passing nil for any +// VCS client causes NewClientProxy to install a NotConfiguredVCSClient placeholder. +func TestNewClientProxy_NilClientsUseNotConfigured(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + // Build a proxy where every slot is nil – all should become NotConfiguredVCSClient. + proxy := vcs.NewClientProxy(nil, nil, nil, nil, nil, nil) + + repo := makeRepo(models.Github) + pull := makePull(models.Github) + // NotConfiguredVCSClient returns an error for most methods. + _, err := proxy.GetModifiedFiles(logger, repo, pull) + Assert(t, err != nil, "expected error from NotConfiguredVCSClient") +} + +// TestClientProxy_RoutesToCorrectClient verifies that each proxy method dispatches +// to the mock client that matches the VCS host type embedded in the repo/pull. +func TestClientProxy_RoutesToCorrectClient(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + hostTypes := []models.VCSHostType{ + models.Github, + models.Gitlab, + models.BitbucketCloud, + models.BitbucketServer, + models.AzureDevops, + models.Gitea, + } + + for _, hostType := range hostTypes { + t.Run(hostType.String(), func(t *testing.T) { + RegisterMockTestingT(t) + + gh := vcsmocks.NewMockClient() + gl := vcsmocks.NewMockClient() + bbc := vcsmocks.NewMockClient() + bbs := vcsmocks.NewMockClient() + ado := vcsmocks.NewMockClient() + gitea := vcsmocks.NewMockClient() + + proxy := vcs.NewClientProxy(gh, gl, bbc, bbs, ado, gitea) + + repo := makeRepo(hostType) + pull := makePull(hostType) + + // Identify which mock corresponds to this host type. + var expected *vcsmocks.MockClient + switch hostType { + case models.Github: + expected = gh + case models.Gitlab: + expected = gl + case models.BitbucketCloud: + expected = bbc + case models.BitbucketServer: + expected = bbs + case models.AzureDevops: + expected = ado + case models.Gitea: + expected = gitea + } + + // --- GetModifiedFiles --- + When(expected.GetModifiedFiles(logger, repo, pull)).ThenReturn([]string{"a.tf"}, nil) + files, err := proxy.GetModifiedFiles(logger, repo, pull) + Ok(t, err) + Equals(t, []string{"a.tf"}, files) + expected.VerifyWasCalledOnce().GetModifiedFiles(logger, repo, pull) + + // --- CreateComment --- + When(expected.CreateComment(logger, repo, pull.Num, "msg", "plan")).ThenReturn(nil) + err = proxy.CreateComment(logger, repo, pull.Num, "msg", "plan") + Ok(t, err) + expected.VerifyWasCalledOnce().CreateComment(logger, repo, pull.Num, "msg", "plan") + + // --- HidePrevCommandComments --- + When(expected.HidePrevCommandComments(logger, repo, pull.Num, "plan", "")).ThenReturn(nil) + err = proxy.HidePrevCommandComments(logger, repo, pull.Num, "plan", "") + Ok(t, err) + expected.VerifyWasCalledOnce().HidePrevCommandComments(logger, repo, pull.Num, "plan", "") + + // --- PullIsApproved --- + approvalStatus := models.ApprovalStatus{IsApproved: true} + When(expected.PullIsApproved(logger, repo, pull)).ThenReturn(approvalStatus, nil) + gotApproval, err := proxy.PullIsApproved(logger, repo, pull) + Ok(t, err) + Equals(t, approvalStatus, gotApproval) + expected.VerifyWasCalledOnce().PullIsApproved(logger, repo, pull) + + // --- DiscardReviews --- + When(expected.DiscardReviews(logger, repo, pull)).ThenReturn(nil) + err = proxy.DiscardReviews(logger, repo, pull) + Ok(t, err) + expected.VerifyWasCalledOnce().DiscardReviews(logger, repo, pull) + + // --- PullIsMergeable --- + mergeableStatus := models.MergeableStatus{IsMergeable: true} + When(expected.PullIsMergeable(logger, repo, pull, "atlantis", []string{})).ThenReturn(mergeableStatus, nil) + gotMergeable, err := proxy.PullIsMergeable(logger, repo, pull, "atlantis", []string{}) + Ok(t, err) + Equals(t, mergeableStatus, gotMergeable) + expected.VerifyWasCalledOnce().PullIsMergeable(logger, repo, pull, "atlantis", []string{}) + + // --- UpdateStatus --- + When(expected.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url")).ThenReturn(nil) + err = proxy.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Ok(t, err) + expected.VerifyWasCalledOnce().UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + + // --- MergePull --- + opts := models.PullRequestOptions{DeleteSourceBranchOnMerge: true} + When(expected.MergePull(logger, pull, opts)).ThenReturn(nil) + err = proxy.MergePull(logger, pull, opts) + Ok(t, err) + expected.VerifyWasCalledOnce().MergePull(logger, pull, opts) + + // --- MarkdownPullLink --- + When(expected.MarkdownPullLink(pull)).ThenReturn("[#1](url)", nil) + link, err := proxy.MarkdownPullLink(pull) + Ok(t, err) + Equals(t, "[#1](url)", link) + expected.VerifyWasCalledOnce().MarkdownPullLink(pull) + + // --- GetTeamNamesForUser --- + user := models.User{Username: "bob"} + When(expected.GetTeamNamesForUser(logger, repo, user)).ThenReturn([]string{"team-a"}, nil) + teams, err := proxy.GetTeamNamesForUser(logger, repo, user) + Ok(t, err) + Equals(t, []string{"team-a"}, teams) + expected.VerifyWasCalledOnce().GetTeamNamesForUser(logger, repo, user) + + // --- GetFileContent --- + When(expected.GetFileContent(logger, repo, "main", "foo.tf")).ThenReturn(true, []byte("content"), nil) + found, content, err := proxy.GetFileContent(logger, repo, "main", "foo.tf") + Ok(t, err) + Equals(t, true, found) + Equals(t, []byte("content"), content) + expected.VerifyWasCalledOnce().GetFileContent(logger, repo, "main", "foo.tf") + + // --- SupportsSingleFileDownload --- + When(expected.SupportsSingleFileDownload(repo)).ThenReturn(true) + Equals(t, true, proxy.SupportsSingleFileDownload(repo)) + expected.VerifyWasCalledOnce().SupportsSingleFileDownload(repo) + + // --- GetCloneURL --- + When(expected.GetCloneURL(logger, hostType, "owner/repo")).ThenReturn("https://clone.url", nil) + cloneURL, err := proxy.GetCloneURL(logger, hostType, "owner/repo") + Ok(t, err) + Equals(t, "https://clone.url", cloneURL) + expected.VerifyWasCalledOnce().GetCloneURL(logger, hostType, "owner/repo") + + // --- GetPullLabels --- + When(expected.GetPullLabels(logger, repo, pull)).ThenReturn([]string{"label-a"}, nil) + labels, err := proxy.GetPullLabels(logger, repo, pull) + Ok(t, err) + Equals(t, []string{"label-a"}, labels) + expected.VerifyWasCalledOnce().GetPullLabels(logger, repo, pull) + + // --- ReactToComment --- + When(expected.ReactToComment(logger, repo, pull.Num, int64(42), "eyes")).ThenReturn(nil) + err = proxy.ReactToComment(logger, repo, pull.Num, int64(42), "eyes") + Ok(t, err) + expected.VerifyWasCalledOnce().ReactToComment(logger, repo, pull.Num, int64(42), "eyes") + }) + } +} + +// TestClientProxy_PropagatesErrors verifies that errors returned by the underlying +// client are propagated back to the caller unchanged. +func TestClientProxy_PropagatesErrors(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + gh := vcsmocks.NewMockClient() + proxy := vcs.NewClientProxy(gh, nil, nil, nil, nil, nil) + + repo := makeRepo(models.Github) + pull := makePull(models.Github) + wantErr := errors.New("api error") + + When(gh.GetModifiedFiles(logger, repo, pull)).ThenReturn(nil, wantErr) + _, err := proxy.GetModifiedFiles(logger, repo, pull) + Assert(t, err != nil, "expected error") + Equals(t, wantErr, err) + + When(gh.CreateComment(logger, repo, pull.Num, "msg", "plan")).ThenReturn(wantErr) + err = proxy.CreateComment(logger, repo, pull.Num, "msg", "plan") + Assert(t, err != nil, "expected error") + Equals(t, wantErr, err) + + When(gh.PullIsApproved(logger, repo, pull)).ThenReturn(models.ApprovalStatus{}, wantErr) + _, err = proxy.PullIsApproved(logger, repo, pull) + Assert(t, err != nil, "expected error") + Equals(t, wantErr, err) + + When(gh.PullIsMergeable(logger, repo, pull, "atlantis", []string{})).ThenReturn(models.MergeableStatus{}, wantErr) + _, err = proxy.PullIsMergeable(logger, repo, pull, "atlantis", []string{}) + Assert(t, err != nil, "expected error") + Equals(t, wantErr, err) + + When(gh.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url")).ThenReturn(wantErr) + err = proxy.UpdateStatus(logger, repo, pull, models.SuccessCommitStatus, "src", "desc", "url") + Assert(t, err != nil, "expected error") + Equals(t, wantErr, err) +} diff --git a/server/events/vcs/pull_status_fetcher_test.go b/server/events/vcs/pull_status_fetcher_test.go new file mode 100644 index 0000000000..ce1244418f --- /dev/null +++ b/server/events/vcs/pull_status_fetcher_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package vcs_test + +import ( + "errors" + "testing" + + . "github.com/petergtz/pegomock/v4" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func TestFetchPullStatus_Success(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + mockClient := vcsmocks.NewMockClient() + pull := models.PullRequest{ + Num: 1, + BaseRepo: models.Repo{FullName: "owner/repo"}, + } + wantApproval := models.ApprovalStatus{IsApproved: true, ApprovedBy: "alice"} + wantMergeable := models.MergeableStatus{IsMergeable: true} + + When(mockClient.PullIsApproved(logger, pull.BaseRepo, pull)).ThenReturn(wantApproval, nil) + When(mockClient.PullIsMergeable(logger, pull.BaseRepo, pull, "atlantis", []string{"skip-me"})).ThenReturn(wantMergeable, nil) + + fetcher := vcs.NewPullReqStatusFetcher(mockClient, "atlantis", []string{"skip-me"}) + got, err := fetcher.FetchPullStatus(logger, pull) + + Ok(t, err) + Equals(t, wantApproval, got.ApprovalStatus) + Equals(t, wantMergeable, got.MergeableStatus) +} + +func TestFetchPullStatus_ApprovalError(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + mockClient := vcsmocks.NewMockClient() + pull := models.PullRequest{ + Num: 2, + BaseRepo: models.Repo{FullName: "owner/repo"}, + } + wantErr := errors.New("approval API failed") + + When(mockClient.PullIsApproved(logger, pull.BaseRepo, pull)).ThenReturn(models.ApprovalStatus{}, wantErr) + + fetcher := vcs.NewPullReqStatusFetcher(mockClient, "atlantis", nil) + _, err := fetcher.FetchPullStatus(logger, pull) + + Assert(t, err != nil, "expected error") + // The error message should mention the repo name and pull number. + ErrContains(t, "owner/repo", err) + ErrContains(t, "2", err) +} + +func TestFetchPullStatus_MergeableError(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + mockClient := vcsmocks.NewMockClient() + pull := models.PullRequest{ + Num: 3, + BaseRepo: models.Repo{FullName: "owner/repo"}, + } + wantApproval := models.ApprovalStatus{IsApproved: false} + wantErr := errors.New("mergeable API failed") + + When(mockClient.PullIsApproved(logger, pull.BaseRepo, pull)).ThenReturn(wantApproval, nil) + When(mockClient.PullIsMergeable(logger, pull.BaseRepo, pull, "atlantis", []string(nil))).ThenReturn(models.MergeableStatus{}, wantErr) + + fetcher := vcs.NewPullReqStatusFetcher(mockClient, "atlantis", nil) + _, err := fetcher.FetchPullStatus(logger, pull) + + Assert(t, err != nil, "expected error") + // The error message should mention the repo name and pull number. + ErrContains(t, "owner/repo", err) + ErrContains(t, "3", err) +} + +func TestFetchPullStatus_PassesVCSStatusNameAndIgnoreList(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + + mockClient := vcsmocks.NewMockClient() + pull := models.PullRequest{ + Num: 4, + BaseRepo: models.Repo{FullName: "owner/repo"}, + } + ignoreList := []string{"ci/skip", "bot-check"} + + When(mockClient.PullIsApproved(logger, pull.BaseRepo, pull)).ThenReturn(models.ApprovalStatus{}, nil) + When(mockClient.PullIsMergeable(logger, pull.BaseRepo, pull, "my-status", ignoreList)).ThenReturn(models.MergeableStatus{IsMergeable: true}, nil) + + fetcher := vcs.NewPullReqStatusFetcher(mockClient, "my-status", ignoreList) + got, err := fetcher.FetchPullStatus(logger, pull) + + Ok(t, err) + Equals(t, true, got.MergeableStatus.IsMergeable) + + // Verify the correct args were passed to PullIsMergeable. + mockClient.VerifyWasCalledOnce().PullIsMergeable(logger, pull.BaseRepo, pull, "my-status", ignoreList) +}