Files
kandev/apps/backend/internal/gitlab/service_mr_automation_auth_test.go
T

187 lines
7.4 KiB
Go

package gitlab
import (
"context"
"errors"
"testing"
"time"
)
func newMRAutomationAuthFixture(t *testing.T) (*Service, *MockClient) {
t.Helper()
store := newTestStore(t)
seedWorkspace(t, store, "ws-1")
if err := store.SaveConfigForWorkspace(context.Background(), "ws-1", &GitLabConfig{
Host: "https://gitlab.example.com", AuthMethod: AuthMethodPAT,
}); err != nil {
t.Fatalf("save config: %v", err)
}
secrets := &configTestSecrets{values: map[string]string{SecretKeyForWorkspace("ws-1"): "token"}}
svc := newWorkspaceConfigService(t, store, secrets)
mock := NewMockClient("https://gitlab.example.com")
mock.SetUser("alice")
svc.workspaceClientFn = func(_ context.Context, _ *GitLabConfig, _ string) (Client, error) {
return mock, nil
}
return svc, mock
}
func TestService_GetMRAutomationSnapshot_IncludesFailingJobsAndUnresolvedDiscussions(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
pipelineID := int64(99)
mock.SeedPipelines("group/project", []Pipeline{{ID: pipelineID, Status: "failed"}})
mock.SeedPipelineJobs(pipelineID, []PipelineJob{
{ID: 1, Name: "build", Status: "success"},
{ID: 2, Name: "test", Status: "failed"},
{ID: 3, Name: "lint", Status: "failed", AllowFailure: true},
})
mock.SeedDiscussions("group/project", 1, []MRDiscussion{
{ID: "d1", Resolvable: true, Resolved: false},
{ID: "d2", Resolvable: true, Resolved: true},
})
snapshot, err := svc.GetMRAutomationSnapshot(context.Background(), "ws-1", "https://gitlab.example.com", "group/project", 1)
if err != nil {
t.Fatalf("GetMRAutomationSnapshot() error = %v", err)
}
if len(snapshot.FailingJobs) != 1 || snapshot.FailingJobs[0].Name != "test" {
t.Errorf("FailingJobs = %+v, want only the non-allow_failure failed job", snapshot.FailingJobs)
}
if snapshot.UnresolvedDiscussions != 1 {
t.Errorf("UnresolvedDiscussions = %d, want 1", snapshot.UnresolvedDiscussions)
}
if snapshot.MR == nil || snapshot.MR.IID != 1 {
t.Errorf("MR = %+v, want IID 1", snapshot.MR)
}
if snapshot.PipelineStatus != "failed" {
t.Errorf("PipelineStatus = %q, want failed", snapshot.PipelineStatus)
}
}
func TestService_GetMRAutomationSnapshot_NoPipelineYet(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
snapshot, err := svc.GetMRAutomationSnapshot(context.Background(), "ws-1", "https://gitlab.example.com", "group/project", 1)
if err != nil {
t.Fatalf("GetMRAutomationSnapshot() error = %v", err)
}
if len(snapshot.FailingJobs) != 0 {
t.Errorf("FailingJobs = %+v, want empty when there is no pipeline yet", snapshot.FailingJobs)
}
}
func TestService_GetMRAutomationSnapshot_HostMismatchFailsClosed(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
_, err := svc.GetMRAutomationSnapshot(context.Background(), "ws-1", "https://gitlab.other.example.com", "group/project", 1)
if !errors.Is(err, ErrWorkspaceHostMismatch) {
t.Fatalf("err = %v, want ErrWorkspaceHostMismatch", err)
}
}
func TestMockClient_GetMRFeedback_PopulatesLatestPipelineJobs(t *testing.T) {
mock := NewMockClient("https://gitlab.example.com")
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
pipelineID := int64(99)
mock.SeedPipelines("group/project", []Pipeline{{ID: pipelineID, Status: "failed"}})
mock.SeedPipelineJobs(pipelineID, []PipelineJob{
{ID: 1, Name: "build", Status: "success"},
{ID: 2, Name: "test", Status: "failed"},
})
feedback, err := mock.GetMRFeedback(context.Background(), "group/project", 1)
if err != nil {
t.Fatalf("GetMRFeedback() error = %v", err)
}
if len(feedback.Pipelines) != 1 {
t.Fatalf("Pipelines = %+v, want 1", feedback.Pipelines)
}
pipeline := feedback.Pipelines[0]
if pipeline.JobsTotal != 2 || pipeline.JobsPassing != 1 {
t.Errorf("JobsTotal/JobsPassing = %d/%d, want 2/1", pipeline.JobsTotal, pipeline.JobsPassing)
}
if len(pipeline.Jobs) != 2 {
t.Fatalf("Jobs = %+v, want 2 raw jobs for the popover's stage grouping", pipeline.Jobs)
}
}
func TestService_MergeMRForAutomation_MergesAndReturnsMR(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
merged, err := svc.MergeMRForAutomation(context.Background(), "ws-1", "https://gitlab.example.com", "group/project", 1)
if err != nil {
t.Fatalf("MergeMRForAutomation() error = %v", err)
}
if merged.State != gitlabStateMerged {
t.Errorf("merged.State = %q, want merged", merged.State)
}
if merged.MergedAt == nil || merged.MergedAt.After(time.Now()) {
t.Errorf("merged.MergedAt = %v, want a recent timestamp", merged.MergedAt)
}
}
// squashRecordingClient records the squash flag MergeMR was actually called
// with. It embeds *MockClient so it satisfies the full Client interface and
// only overrides the one method under test.
type squashRecordingClient struct {
*MockClient
gotSquash bool
calls int
}
func (c *squashRecordingClient) MergeMR(ctx context.Context, projectPath string, iid int, squash bool, msg string) (*MR, error) {
c.gotSquash = squash
c.calls++
return c.MockClient.MergeMR(ctx, projectPath, iid, squash, msg)
}
// TestService_MergeMRForAutomation_HonoursProjectSquashPolicy is the
// regression for the QA finding that auto-merge hardcoded squash=false,
// bypassing the project's configured merge methods. The fixture project
// allows squash (MockClient.GetProjectMergeMethods returns AllowSquash:
// true), so the empty-method resolution must select squash — the same
// default the interactive merge path applies. Before the fix this asserted
// false, meaning a squash-required project would have had its merge
// rejected and a squash-by-default project would have been merged unsquashed.
func TestService_MergeMRForAutomation_HonoursProjectSquashPolicy(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
recorder := &squashRecordingClient{MockClient: mock}
svc.workspaceClientFn = func(_ context.Context, _ *GitLabConfig, _ string) (Client, error) {
return recorder, nil
}
if _, err := svc.MergeMRForAutomation(
context.Background(), "ws-1", "https://gitlab.example.com", "group/project", 1,
); err != nil {
t.Fatalf("MergeMRForAutomation() error = %v", err)
}
if recorder.calls != 1 {
t.Fatalf("MergeMR calls = %d, want 1", recorder.calls)
}
if !recorder.gotSquash {
t.Error("MergeMR squash = false, want true — automation must honour the project's squash policy, not hardcode false")
}
}
func TestService_MergeMRForAutomation_HostMismatchNeverMerges(t *testing.T) {
svc, mock := newMRAutomationAuthFixture(t)
mock.SeedMR("group/project", &MR{IID: 1, HeadBranch: "feature", State: mrStateOpen})
_, err := svc.MergeMRForAutomation(context.Background(), "ws-1", "https://gitlab.other.example.com", "group/project", 1)
if !errors.Is(err, ErrWorkspaceHostMismatch) {
t.Fatalf("err = %v, want ErrWorkspaceHostMismatch", err)
}
status, statusErr := mock.GetMRStatus(context.Background(), "group/project", 1)
if statusErr != nil {
t.Fatalf("GetMRStatus() error = %v", statusErr)
}
if status.MR.State == gitlabStateMerged {
t.Fatal("MR was merged despite the host mismatch — automation must fail closed")
}
}