test(gitlab): expand client and service coverage (#2545)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 01:33:38 +02:00
committed by GitHub
parent 6732c16385
commit 1b26646e67
6 changed files with 868 additions and 0 deletions
@@ -0,0 +1,128 @@
package gitlab
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWatchHTTPRoutesRequireWorkspaceAndValidatePayloads(t *testing.T) {
_, _, router := newWatchScopeFixture(t)
tests := []struct {
name string
method string
path string
body string
}{
{name: "missing workspace", method: http.MethodGet, path: "/api/v1/gitlab/projects"},
{name: "missing project", method: http.MethodGet, path: "/api/v1/gitlab/projects/branches?workspace_id=workspace-a"},
{name: "invalid merge payload", method: http.MethodPut, path: "/api/v1/gitlab/mrs/merge?workspace_id=workspace-a", body: `{}`},
{name: "invalid labels payload", method: http.MethodPut, path: "/api/v1/gitlab/mrs/labels?workspace_id=workspace-a", body: `{`},
{name: "invalid assignees payload", method: http.MethodPut, path: "/api/v1/gitlab/mrs/assignees?workspace_id=workspace-a", body: `{}`},
{name: "missing files iid", method: http.MethodGet, path: "/api/v1/gitlab/mrs/files?workspace_id=workspace-a&project=acme/widget"},
{name: "missing commits project", method: http.MethodGet, path: "/api/v1/gitlab/mrs/commits?workspace_id=workspace-a&iid=2"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptestRequest(tc.method, tc.path, tc.body)
response := executeRequest(router, req)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", response.Code, response.Body.String())
}
})
}
}
func TestWatchHTTPProjectAndMRActionRoutes(t *testing.T) {
_, service, router := newWatchScopeFixture(t)
client := service.workspaceClients["workspace-a"].(*MockClient)
client.SeedProjectMembers("acme/widget", []ProjectMember{{ID: 7, Username: "alice", Name: "Alice"}})
client.SeedBranches("acme/widget", []RepoBranch{{Name: "main"}, {Name: "release"}})
client.SeedMR("acme/widget", &MR{IID: 2, Title: "Feature", State: mrStateOpen})
client.SeedFiles("acme/widget", 2, []MRFile{{Filename: "main.go", Additions: 3}})
client.SeedCommits("acme/widget", 2, []MRCommitInfo{{SHA: "abc123", Message: "change"}})
tests := []struct {
name string
method string
path string
body any
wantString string
}{
{name: "list projects", method: http.MethodGet, path: "/projects", wantString: "kandev/sample"},
{name: "search projects", method: http.MethodGet, path: "/projects/search?query=sample", wantString: "kandev/sample"},
{name: "list branches", method: http.MethodGet, path: "/projects/branches?project=acme%2Fwidget", wantString: "release"},
{name: "merge methods", method: http.MethodGet, path: "/projects/merge-methods?project=acme%2Fwidget", wantString: `"merge":true`},
{name: "approve", method: http.MethodPost, path: "/mrs/approve", body: map[string]any{"project": "acme/widget", "iid": 2}, wantString: `"approved":true`},
{name: "unapprove", method: http.MethodPost, path: "/mrs/unapprove", body: map[string]any{"project": "acme/widget", "iid": 2}, wantString: `"unapproved":true`},
{name: "set labels", method: http.MethodPut, path: "/mrs/labels", body: map[string]any{"project": "acme/widget", "iid": 2, "labels": []string{"backend"}}, wantString: `"updated":true`},
{name: "set assignees", method: http.MethodPut, path: "/mrs/assignees", body: map[string]any{"project": "acme/widget", "iid": 2, "assignee_ids": []int{7}}, wantString: `"updated":true`},
{name: "files", method: http.MethodGet, path: "/mrs/files?project=acme%2Fwidget&iid=2", wantString: "main.go"},
{name: "commits", method: http.MethodGet, path: "/mrs/commits?project=acme%2Fwidget&iid=2", wantString: "abc123"},
{name: "merge", method: http.MethodPut, path: "/mrs/merge", body: map[string]any{"project": "acme/widget", "iid": 2, "method": "squash", "squash_commit_message": "combined"}, wantString: `"state":"merged"`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.body != nil {
encoded, err := json.Marshal(tc.body)
if err != nil {
t.Fatal(err)
}
body = string(encoded)
}
separator := "?"
if strings.Contains(tc.path, "?") {
separator = "&"
}
request := httptestRequest(tc.method, "/api/v1/gitlab"+tc.path+separator+"workspace_id=workspace-a", body)
response := executeRequest(router, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), tc.wantString) {
t.Fatalf("status=%d body=%s, want 200 containing %q", response.Code, response.Body.String(), tc.wantString)
}
})
}
mr, err := client.GetMR(t.Context(), "acme/widget", 2)
if err != nil {
t.Fatal(err)
}
if len(mr.Labels) != 1 || mr.Labels[0] != "backend" || len(mr.Assignees) != 1 || mr.Assignees[0].Username != "alice" || mr.State != gitlabStateMerged {
t.Fatalf("mutated MR = %#v", mr)
}
}
func TestWatchHTTPActionPresetRoutes(t *testing.T) {
_, _, router := newWatchScopeFixture(t)
get := watchScopeRequest(t, router, http.MethodGet, "/api/v1/gitlab/action-presets?workspace_id=workspace-a", nil)
if get.Code != http.StatusOK || !bytes.Contains(get.Body.Bytes(), []byte(`"mr"`)) {
t.Fatalf("get presets status=%d body=%s", get.Code, get.Body.String())
}
custom := []ActionPreset{{ID: "review", Label: "Review", PromptTemplate: "Review {{url}}"}}
update := watchScopeRequest(t, router, http.MethodPut, "/api/v1/gitlab/action-presets?workspace_id=workspace-a", UpdateActionPresetsRequest{MR: &custom})
if update.Code != http.StatusOK || !bytes.Contains(update.Body.Bytes(), []byte(`"id":"review"`)) {
t.Fatalf("update presets status=%d body=%s", update.Code, update.Body.String())
}
reset := watchScopeRequest(t, router, http.MethodPost, "/api/v1/gitlab/action-presets/reset?workspace_id=workspace-a", nil)
if reset.Code != http.StatusOK || bytes.Contains(reset.Body.Bytes(), []byte(`"prompt_template":"Review {{url}}"`)) {
t.Fatalf("reset presets status=%d body=%s", reset.Code, reset.Body.String())
}
}
func httptestRequest(method, path, body string) *http.Request {
request, _ := http.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
return request
}
func executeRequest(router http.Handler, request *http.Request) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
return recorder
}
@@ -192,3 +192,60 @@ func TestMockControllerRejectsIncompleteRepoFilesRequest(t *testing.T) {
t.Fatalf("status=%d body=%s", res.Code, res.Body.String())
}
}
func TestMockControllerSeedsRemainingResourceTypes(t *testing.T) {
router, workspaceA, _ := newMockControllerFixture(t)
workspaceA.SeedMR("group/project", &MR{IID: 7, Title: "Review"})
requests := []struct {
method string
path string
body any
}{
{http.MethodPut, "/user", map[string]string{"username": "reviewer"}},
{http.MethodPost, "/issues", mockIssueRequest{Project: "group/project", Issues: []Issue{{IID: 3, Title: "Bug", State: "opened"}}}},
{http.MethodPost, "/pipelines", mockPipelineRequest{Project: "group/project", Pipelines: []Pipeline{{ID: 10, Status: "failed"}}}},
{http.MethodPost, "/pipeline-jobs", mockPipelineJobsRequest{PipelineID: 10, Jobs: []PipelineJob{{ID: 11, Name: "test", Status: "failed"}}}},
{http.MethodPost, "/discussions", mockDiscussionRequest{Project: "group/project", IID: 7, Discussions: []MRDiscussion{{ID: "thread-1", Resolvable: true}}}},
{http.MethodPost, "/approvals", mockApprovalRequest{Project: "group/project", IID: 7, Approvals: []MRApproval{{Username: "alice"}}, Required: 2}},
{http.MethodPost, "/branches", mockBranchesRequest{Project: "group/project", Branches: []RepoBranch{{Name: "main"}}}},
}
for _, item := range requests {
response := mockControlRequest(t, router, item.method,
"/api/v1/gitlab/mock"+item.path+"?workspace_id=workspace-a", item.body)
if response.Code != http.StatusOK {
t.Fatalf("seed %s status=%d body=%s", item.path, response.Code, response.Body.String())
}
}
if username, err := workspaceA.GetAuthenticatedUser(t.Context()); err != nil || username != "reviewer" {
t.Fatalf("user = (%q, %v), want reviewer", username, err)
}
if state, err := workspaceA.GetIssueState(t.Context(), "group/project", 3); err != nil || state != "opened" {
t.Fatalf("issue state = (%q, %v), want opened", state, err)
}
if jobs, err := workspaceA.ListPipelineJobs(t.Context(), "group/project", 10); err != nil || len(jobs) != 1 || jobs[0].ID != 11 {
t.Fatalf("jobs = (%#v, %v), want job 11", jobs, err)
}
if discussions, err := workspaceA.ListMRDiscussions(t.Context(), "group/project", 7, nil); err != nil || len(discussions) != 1 || discussions[0].ID != "thread-1" {
t.Fatalf("discussions = (%#v, %v), want thread-1", discussions, err)
}
if approvals, err := workspaceA.ListMRApprovals(t.Context(), "group/project", 7); err != nil || len(approvals) != 1 || approvals[0].Username != "alice" {
t.Fatalf("approvals = (%#v, %v), want alice", approvals, err)
}
if branches, err := workspaceA.ListProjectBranches(t.Context(), "group/project"); err != nil || len(branches) != 1 || branches[0].Name != "main" {
t.Fatalf("branches = (%#v, %v), want main", branches, err)
}
}
func TestMockControllerSeedRoutesRejectInvalidPayloads(t *testing.T) {
router, _, _ := newMockControllerFixture(t)
for _, path := range []string{
"/mrs", "/issues", "/pipelines", "/pipeline-jobs", "/discussions",
"/approvals", "/branches", "/members", "/files", "/commits",
} {
response := mockControlRequest(t, router, http.MethodPost, "/api/v1/gitlab/mock"+path, map[string]any{})
if response.Code != http.StatusBadRequest {
t.Errorf("%s status=%d body=%s, want 400", path, response.Code, response.Body.String())
}
}
}
@@ -0,0 +1,327 @@
package gitlab
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
)
func TestPATClientSearchMRsPagedClampsPaginationAndReadsTotal(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/merge_requests"; got != want {
t.Errorf("path = %q, want %q", got, want)
}
query := r.URL.Query()
if got, want := query.Get("page"), "1"; got != want {
t.Errorf("page = %q, want %q", got, want)
}
if got, want := query.Get("per_page"), "100"; got != want {
t.Errorf("per_page = %q, want %q", got, want)
}
if got, want := query.Get("state"), "opened"; got != want {
t.Errorf("state = %q, want %q", got, want)
}
w.Header().Set("X-Total", "137")
_ = json.NewEncoder(w).Encode([]rawMR{{IID: 8, Title: "Review me", State: "opened"}})
}))
t.Cleanup(stop)
page, err := NewPATClient(host, "tok").SearchMRsPaged(t.Context(), "labels=backend", "", 0, 500)
if err != nil {
t.Fatalf("SearchMRsPaged() error = %v", err)
}
if page.TotalCount != 137 || page.Page != 1 || page.PerPage != 100 {
t.Fatalf("pagination = (%d, %d, %d), want (137, 1, 100)", page.TotalCount, page.Page, page.PerPage)
}
if len(page.MRs) != 1 || page.MRs[0].IID != 8 || page.MRs[0].State != "open" {
t.Fatalf("MRs = %#v, want converted MR !8", page.MRs)
}
}
func TestPATClientListIssuesUsesDefaultPagination(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
if got, want := query.Get("page"), "1"; got != want {
t.Errorf("page = %q, want %q", got, want)
}
if got, want := query.Get("per_page"), "50"; got != want {
t.Errorf("per_page = %q, want %q", got, want)
}
w.Header().Set("X-Total", "1")
_ = json.NewEncoder(w).Encode([]rawIssue{{IID: 3, Title: "Fix it", State: "opened"}})
}))
t.Cleanup(stop)
issues, err := NewPATClient(host, "tok").ListIssues(t.Context(), "open", "assignee_username=alice")
if err != nil {
t.Fatalf("ListIssues() error = %v", err)
}
if len(issues) != 1 || issues[0].IID != 3 || issues[0].State != "opened" {
t.Fatalf("issues = %#v, want converted issue #3", issues)
}
}
func TestPATClientProjectDiscovery(t *testing.T) {
tests := []struct {
name string
call func(*PATClient) ([]Project, error)
wantPath string
want map[string]string
}{
{
name: "group projects with subgroup and search",
call: func(c *PATClient) ([]Project, error) {
return c.SearchGroupProjects(t.Context(), "acme/team", "api client", 0)
},
wantPath: "/groups/acme%2Fteam/projects",
want: map[string]string{"per_page": "20", "simple": "true", "include_subgroups": "true", "search": "api client"},
},
{
name: "member projects",
call: func(c *PATClient) ([]Project, error) {
return c.ListUserProjects(t.Context())
},
wantPath: "/projects",
want: map[string]string{"membership": "true", "simple": "true", "per_page": "100", "order_by": "last_activity_at"},
},
{
name: "global search trims blank query",
call: func(c *PATClient) ([]Project, error) {
return c.SearchProjects(t.Context(), " ", -1)
},
wantPath: "/projects",
want: map[string]string{"membership": "true", "simple": "true", "per_page": "20"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.EscapedPath(); got != tc.wantPath {
t.Errorf("path = %q, want %q", got, tc.wantPath)
}
for key, want := range tc.want {
if got := r.URL.Query().Get(key); got != want {
t.Errorf("query %s = %q, want %q", key, got, want)
}
}
if _, ok := tc.want["search"]; !ok && r.URL.Query().Has("search") {
t.Errorf("unexpected search query %q", r.URL.Query().Get("search"))
}
_, _ = w.Write([]byte(`[{"id":12,"path_with_namespace":"acme/widget","name":"Widget","web_url":"https://gitlab.example/acme/widget","default_branch":"main"}]`))
}))
t.Cleanup(stop)
projects, err := tc.call(NewPATClient(host, "tok"))
if err != nil {
t.Fatalf("project discovery error = %v", err)
}
want := []Project{{ID: 12, PathWithNamespace: "acme/widget", Namespace: "acme", Name: "Widget", WebURL: "https://gitlab.example/acme/widget", DefaultBranch: "main"}}
if !reflect.DeepEqual(projects, want) {
t.Fatalf("projects = %#v, want %#v", projects, want)
}
})
}
}
func TestPATClientMergeMRRequestAndResponse(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Method, http.MethodPut; got != want {
t.Errorf("method = %q, want %q", got, want)
}
if got, want := r.URL.EscapedPath(), "/projects/acme%2Fwidget/merge_requests/9/merge"; got != want {
t.Errorf("path = %q, want %q", got, want)
}
var request MergeMRRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if !request.Squash || request.SquashCommitMessage != "squashed changes" || request.ShouldRemoveSourceBranch {
t.Errorf("request = %#v, want squash without source removal", request)
}
_ = json.NewEncoder(w).Encode(rawMR{IID: 9, State: "merged", Title: "Feature"})
}))
t.Cleanup(stop)
mr, err := NewPATClient(host, "tok").MergeMR(t.Context(), "acme/widget", 9, true, "squashed changes")
if err != nil {
t.Fatalf("MergeMR() error = %v", err)
}
if mr.IID != 9 || mr.State != "merged" {
t.Fatalf("MR = %#v, want merged MR !9", mr)
}
}
func TestPATClientProtectedBranchResponses(t *testing.T) {
t.Run("not protected", func(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"404 Not Found"}`, http.StatusNotFound)
}))
t.Cleanup(stop)
branch, err := NewPATClient(host, "tok").GetProtectedBranch(t.Context(), "acme/widget", "feature/x")
if err != nil || branch != nil {
t.Fatalf("GetProtectedBranch() = (%#v, %v), want (nil, nil)", branch, err)
}
})
t.Run("protected", func(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.EscapedPath(), "/projects/acme%2Fwidget/protected_branches/feature%2Fx"; got != want {
t.Errorf("path = %q, want %q", got, want)
}
_, _ = w.Write([]byte(`{"name":"feature/x","push_access_levels":[{"access_level":30}],"merge_access_levels":[{"access_level":40}],"code_owner_approval_required":true}`))
}))
t.Cleanup(stop)
branch, err := NewPATClient(host, "tok").GetProtectedBranch(t.Context(), "acme/widget", "feature/x")
if err != nil {
t.Fatalf("GetProtectedBranch() error = %v", err)
}
want := &ProtectedBranch{Name: "feature/x", PushAccessLevel: 30, MergeAccessLevel: 40, CodeOwnerApprovalRequired: true}
if !reflect.DeepEqual(branch, want) {
t.Fatalf("branch = %#v, want %#v", branch, want)
}
})
}
func TestPATClientGetProjectMergeMethods(t *testing.T) {
tests := []struct {
name string
body string
want ProjectMergeMethods
}{
{name: "merge", body: `{"merge_method":"merge","squash_option":"never"}`, want: ProjectMergeMethods{Merge: true}},
{name: "rebase and squash", body: `{"merge_method":"rebase_merge","squash_option":"default_on"}`, want: ProjectMergeMethods{RebaseMerge: true, AllowSquash: true}},
{name: "fast forward", body: `{"merge_method":"ff","squash_option":"always"}`, want: ProjectMergeMethods{FastForward: true, AllowSquash: true}},
{name: "unknown defaults to merge", body: `{"merge_method":"future"}`, want: ProjectMergeMethods{Merge: true}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(stop)
got, err := NewPATClient(host, "tok").GetProjectMergeMethods(t.Context(), "acme/widget")
if err != nil {
t.Fatalf("GetProjectMergeMethods() error = %v", err)
}
if !reflect.DeepEqual(*got, tc.want) {
t.Fatalf("methods = %#v, want %#v", *got, tc.want)
}
})
}
}
func TestPATClientActionErrorsPreserveAPIError(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
t.Cleanup(stop)
_, err := NewPATClient(host, "tok").MergeMR(t.Context(), "acme/widget", 9, false, "")
if err == nil {
t.Fatal("MergeMR() error = nil, want upstream error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("error = %v, want wrapped APIError status 503", err)
}
}
func TestPATClientMRListQueries(t *testing.T) {
tests := []struct {
name string
call func(*PATClient) ([]*MR, error)
check func(*testing.T, *http.Request)
}{
{
name: "authored",
call: func(c *PATClient) ([]*MR, error) { return c.ListAuthoredMRs(t.Context(), "acme/widget") },
check: func(t *testing.T, r *http.Request) {
if got, want := r.URL.Query().Get("author_username"), "alice"; got != want {
t.Errorf("author_username = %q, want %q", got, want)
}
},
},
{
name: "review requested",
call: func(c *PATClient) ([]*MR, error) {
return c.ListReviewRequestedMRs(t.Context(), "", "reviewer_username=alice&state=opened")
},
check: func(t *testing.T, r *http.Request) {
if got, want := r.URL.Query().Get("reviewer_username"), "alice"; got != want {
t.Errorf("reviewer_username = %q, want %q", got, want)
}
},
},
{
name: "search wrapper",
call: func(c *PATClient) ([]*MR, error) { return c.SearchMRs(t.Context(), "state=closed", "") },
check: func(t *testing.T, r *http.Request) {
if got, want := r.URL.Query().Get("state"), "closed"; got != want {
t.Errorf("state = %q, want %q", got, want)
}
if got, want := r.URL.Query().Get("page"), "1"; got != want {
t.Errorf("page = %q, want %q", got, want)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/user" {
_, _ = w.Write([]byte(`{"username":"alice"}`))
return
}
tc.check(t, r)
_, _ = w.Write([]byte(`[{"iid":11,"title":"MR","state":"opened"}]`))
}))
t.Cleanup(stop)
mrs, err := tc.call(NewPATClient(host, "tok"))
if err != nil {
t.Fatalf("list MRs error = %v", err)
}
if len(mrs) != 1 || mrs[0].IID != 11 || mrs[0].State != "open" {
t.Fatalf("MRs = %#v, want converted MR !11", mrs)
}
})
}
}
func TestPATClientIssueStateAndGroups(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/projects/acme/widget/issues/6":
_, _ = w.Write([]byte(`{"state":"closed"}`))
case "/groups":
if got, want := r.URL.Query().Get("min_access_level"), "10"; got != want {
t.Errorf("min_access_level = %q, want %q", got, want)
}
_, _ = w.Write([]byte(`[{"id":4,"path":"team","name":"Team","avatar_url":"https://img/team"}]`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
t.Cleanup(stop)
client := NewPATClient(host, "tok")
state, err := client.GetIssueState(t.Context(), "acme/widget", 6)
if err != nil || state != "closed" {
t.Fatalf("GetIssueState() = (%q, %v), want closed", state, err)
}
groups, err := client.ListUserGroups(t.Context())
if err != nil {
t.Fatalf("ListUserGroups() error = %v", err)
}
want := []Group{{ID: 4, Path: "team", Name: "Team", AvatarURL: "https://img/team"}}
if !reflect.DeepEqual(groups, want) {
t.Fatalf("groups = %#v, want %#v", groups, want)
}
}
@@ -0,0 +1,115 @@
package gitlab
import (
"errors"
"strings"
"testing"
)
func TestServiceMergeMRValidatesMethodAndUpdatesMR(t *testing.T) {
client := NewMockClient("https://gitlab.example")
client.SeedMR("acme/widget", &MR{IID: 4, Title: "Feature", State: mrStateOpen})
service := NewService(client.Host(), client, "mock", nil, newTestLogger(t))
mr, err := service.MergeMR(t.Context(), "acme/widget", 4, "squash", "combined changes")
if err != nil {
t.Fatalf("MergeMR() error = %v", err)
}
if mr.State != gitlabStateMerged || mr.MergedAt == nil {
t.Fatalf("MR = %#v, want merged state and timestamp", mr)
}
stored, err := client.GetMR(t.Context(), "acme/widget", 4)
if err != nil {
t.Fatalf("GetMR() error = %v", err)
}
if stored.State != gitlabStateMerged {
t.Fatalf("stored state = %q, want %q", stored.State, gitlabStateMerged)
}
}
func TestServiceMergeMRRejectsUnsupportedMethods(t *testing.T) {
service := NewService(DefaultHost, NewMockClient(DefaultHost), "mock", nil, newTestLogger(t))
tests := []struct {
method string
want string
}{
{method: "rebase_merge", want: "does not allow rebase merge"},
{method: "ff", want: "does not allow fast-forward merge"},
{method: "octopus", want: "unknown merge method"},
}
for _, tc := range tests {
t.Run(tc.method, func(t *testing.T) {
_, err := service.MergeMR(t.Context(), "acme/widget", 4, tc.method, "")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("MergeMR() error = %v, want containing %q", err, tc.want)
}
})
}
}
func TestServiceClientActionsReturnErrNoClient(t *testing.T) {
service := NewService(DefaultHost, nil, AuthMethodNone, nil, newTestLogger(t))
tests := []struct {
name string
call func() error
}{
{name: "merge", call: func() error { _, err := service.MergeMR(t.Context(), "acme/widget", 1, "merge", ""); return err }},
{name: "merge methods", call: func() error { _, err := service.GetProjectMergeMethods(t.Context(), "acme/widget"); return err }},
{name: "approve", call: func() error { return service.SubmitMRApproval(t.Context(), "acme/widget", 1) }},
{name: "unapprove", call: func() error { return service.SubmitMRUnapproval(t.Context(), "acme/widget", 1) }},
{name: "labels", call: func() error { return service.SetMRLabels(t.Context(), "acme/widget", 1, nil) }},
{name: "assignees", call: func() error { return service.SetMRAssignees(t.Context(), "acme/widget", 1, nil) }},
{name: "files", call: func() error { _, err := service.GetMRFiles(t.Context(), "acme/widget", 1); return err }},
{name: "commits", call: func() error { _, err := service.GetMRCommits(t.Context(), "acme/widget", 1); return err }},
{name: "projects", call: func() error { _, err := service.ListUserProjects(t.Context()); return err }},
{name: "search projects", call: func() error { _, err := service.SearchProjects(t.Context(), "widget", 10); return err }},
{name: "branches", call: func() error { _, err := service.ListProjectBranches(t.Context(), "acme/widget"); return err }},
{name: "search MRs", call: func() error { _, err := service.SearchUserMRs(t.Context(), "", ""); return err }},
{name: "search MRs paged", call: func() error { _, err := service.SearchUserMRsPaged(t.Context(), "", "", 1, 10); return err }},
{name: "search issues", call: func() error { _, err := service.SearchUserIssues(t.Context(), "", ""); return err }},
{name: "search issues paged", call: func() error { _, err := service.SearchUserIssuesPaged(t.Context(), "", "", 1, 10); return err }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); !errors.Is(err, ErrNoClient) {
t.Fatalf("error = %v, want ErrNoClient", err)
}
})
}
}
func TestServiceMockClientSearchAndActions(t *testing.T) {
client := NewMockClient(DefaultHost)
client.SeedProjectMembers("acme/widget", []ProjectMember{{ID: 7, Username: "alice", Name: "Alice"}})
client.SeedBranches("acme/widget", []RepoBranch{{Name: "main"}})
client.SeedMR("acme/widget", &MR{IID: 2, Title: "Feature", State: mrStateOpen})
service := NewService(DefaultHost, client, "mock", nil, newTestLogger(t))
if err := service.SetMRLabels(t.Context(), "acme/widget", 2, []string{"backend"}); err != nil {
t.Fatalf("SetMRLabels() error = %v", err)
}
if err := service.SetMRAssignees(t.Context(), "acme/widget", 2, []int{7}); err != nil {
t.Fatalf("SetMRAssignees() error = %v", err)
}
mr, err := client.GetMR(t.Context(), "acme/widget", 2)
if err != nil {
t.Fatalf("GetMR() error = %v", err)
}
if len(mr.Labels) != 1 || mr.Labels[0] != "backend" || len(mr.Assignees) != 1 || mr.Assignees[0].Username != "alice" {
t.Fatalf("MR labels/assignees = %#v/%#v, want backend/alice", mr.Labels, mr.Assignees)
}
branches, err := service.ListProjectBranches(t.Context(), "acme/widget")
if err != nil || len(branches) != 1 || branches[0].Name != "main" {
t.Fatalf("ListProjectBranches() = (%#v, %v), want main", branches, err)
}
projects, err := service.SearchProjects(t.Context(), "sample", 5)
if err != nil || len(projects) != 1 || projects[0].PathWithNamespace != "kandev/sample" {
t.Fatalf("SearchProjects() = (%#v, %v), want kandev/sample", projects, err)
}
missing, err := service.SearchProjects(t.Context(), "absent", 5)
if err != nil || len(missing) != 0 {
t.Fatalf("SearchProjects(absent) = (%#v, %v), want empty", missing, err)
}
}
@@ -0,0 +1,102 @@
package gitlab
import (
"errors"
"testing"
)
func TestServiceMRAutomationOperationsRequireStore(t *testing.T) {
service := NewService(DefaultHost, NewMockClient(DefaultHost), "mock", nil, newTestLogger(t))
tests := []struct {
name string
call func() error
}{
{name: "enabled prompts", call: func() error { _, err := service.HasEnabledTaskMRAgentPrompts(t.Context(), "task"); return err }},
{name: "rebind reviewer", call: func() error { _, _, err := service.RebindTaskMRReviewer(t.Context(), "task"); return err }},
{name: "get lifecycle state", call: func() error {
_, err := service.GetTaskMRLifecycleState(t.Context(), "task", "repo", "group/project", 1)
return err
}},
{name: "review request", call: func() error {
return service.SetTaskMRReviewRequestState(t.Context(), "task", "repo", "group/project", 1, true)
}},
{name: "observed state", call: func() error {
return service.SetTaskMRObservedState(t.Context(), "task", "repo", "group/project", 1, "merged")
}},
{name: "lifecycle prompt", call: func() error {
return service.RecordTaskMRLifecyclePrompt(t.Context(), TaskMRLifecyclePrompt{TaskID: "task"})
}},
{name: "automation error", call: func() error {
return service.RecordTaskMRAutomationError(t.Context(), "task", "repo", "group/project", 1, "failed")
}},
{name: "clear automation error", call: func() error {
return service.ClearTaskMRAutomationError(t.Context(), "task", "repo", "group/project", 1)
}},
{name: "sync error", call: func() error {
return service.RecordTaskMRSyncError(t.Context(), "task", "repo", "group/project", 1, "failed")
}},
{name: "clear sync error", call: func() error { return service.ClearTaskMRSyncError(t.Context(), "task", "repo", "group/project", 1) }},
{name: "fix attempt", call: func() error { return service.RecordTaskMRFixAttempt(t.Context(), TaskMRFixAttempt{TaskID: "task"}) }},
{name: "refresh fix checkpoint", call: func() error {
return service.RefreshTaskMRFixCheckpoint(t.Context(), "task", "repo", "group/project", 1, "sig", `{}`)
}},
{name: "fix exhausted", call: func() error {
return service.MarkTaskMRAutoFixExhausted(t.Context(), "task", "repo", "group/project", 1, "limit")
}},
{name: "merge attempt", call: func() error { return service.RecordTaskMRMergeAttempt(t.Context(), TaskMRMergeAttempt{TaskID: "task"}) }},
{name: "subscribed MRs", call: func() error { _, err := service.ListAutomationSubscribedTaskMRs(t.Context()); return err }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); !errors.Is(err, errStoreUnavailable) {
t.Fatalf("error = %v, want errStoreUnavailable", err)
}
})
}
}
func TestServiceMRAutomationStateRoundTrip(t *testing.T) {
store := newTestStore(t)
seedWorkspace(t, store, "workspace")
seedTask(t, store, "task", "workspace")
service := NewService(DefaultHost, NewMockClient(DefaultHost), "mock", nil, newTestLogger(t))
service.SetStore(store)
if err := service.SetTaskMRReviewRequestState(t.Context(), "task", "repo", "group/project", 1, true); err != nil {
t.Fatal(err)
}
if err := service.SetTaskMRObservedState(t.Context(), "task", "repo", "group/project", 1, "opened"); err != nil {
t.Fatal(err)
}
if err := service.RecordTaskMRAutomationError(t.Context(), "task", "repo", "group/project", 1, " delivery failed "); err != nil {
t.Fatal(err)
}
if err := service.RecordTaskMRSyncError(t.Context(), "task", "repo", "group/project", 1, " sync failed "); err != nil {
t.Fatal(err)
}
state, err := service.GetTaskMRLifecycleState(t.Context(), "task", "repo", "group/project", 1)
if err != nil {
t.Fatal(err)
}
if !state.LastReviewRequested || state.LastObservedState != "opened" || stringValue(state.LastError) != " delivery failed " || stringValue(state.LastSyncError) != " sync failed " {
t.Fatalf("state = %#v", state)
}
if err := service.ClearTaskMRAutomationError(t.Context(), "task", "repo", "group/project", 1); err != nil {
t.Fatal(err)
}
if err := service.ClearTaskMRSyncError(t.Context(), "task", "repo", "group/project", 1); err != nil {
t.Fatal(err)
}
state, err = service.GetTaskMRLifecycleState(t.Context(), "task", "repo", "group/project", 1)
if err != nil || state.LastError != nil || state.LastSyncError != nil {
t.Fatalf("cleared state = (%#v, %v)", state, err)
}
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,139 @@
package gitlab
import (
"testing"
)
func TestServiceIssueWatchLifecycle(t *testing.T) {
store := newTestStore(t)
client := NewMockClient(DefaultHost)
client.SeedIssue("group/project", &Issue{IID: 3, Title: "Bug", State: "opened", ProjectPath: "group/project"})
service := NewService(DefaultHost, client, "mock", nil, newTestLogger(t))
service.SetStore(store)
service.workspaceClients["workspace"] = client
watch, err := service.CreateIssueWatch(t.Context(), &CreateIssueWatchRequest{
WorkspaceID: "workspace", WorkflowID: "workflow", WorkflowStepID: "step",
AgentProfileID: "agent", ExecutorProfileID: "executor", Projects: []ProjectFilter{{Path: " group/project "}},
Labels: []string{"bug"}, PollIntervalSeconds: 1,
})
if err != nil {
t.Fatalf("CreateIssueWatch() error = %v", err)
}
if watch.PollIntervalSeconds != minWatchPollIntervalSec || len(watch.Projects) != 1 || watch.Projects[0].Path != "group/project" {
t.Fatalf("normalized watch = %#v", watch)
}
listed, err := service.ListIssueWatches(t.Context(), "workspace")
if err != nil || len(listed) != 1 || listed[0].ID != watch.ID {
t.Fatalf("ListIssueWatches() = (%#v, %v)", listed, err)
}
all, err := service.ListAllIssueWatches(t.Context())
if err != nil || len(all) != 1 {
t.Fatalf("ListAllIssueWatches() = (%#v, %v)", all, err)
}
prompt, enabled, max := "Investigate", true, 2
if err := service.UpdateIssueWatch(t.Context(), watch.ID, &UpdateIssueWatchRequest{Prompt: &prompt, Enabled: &enabled, MaxInflightTasks: &max}); err != nil {
t.Fatalf("UpdateIssueWatch() error = %v", err)
}
updated, err := service.GetIssueWatch(t.Context(), watch.ID)
if err != nil || updated.Prompt != prompt || updated.MaxInflightTasks == nil || *updated.MaxInflightTasks != 2 {
t.Fatalf("updated watch = (%#v, %v)", updated, err)
}
found, err := service.TriggerIssueWatch(t.Context(), watch.ID)
if err != nil || len(found) != 1 || found[0].IID != 3 {
t.Fatalf("TriggerIssueWatch() = (%#v, %v)", found, err)
}
if ok, err := store.ReserveIssueWatchTask(t.Context(), watch.ID, "group/project", 3, "url"); err != nil || !ok {
t.Fatalf("reserve issue = (%v, %v)", ok, err)
}
found, err = service.CheckIssueWatch(t.Context(), watch)
if err != nil || len(found) != 0 {
t.Fatalf("deduplicated CheckIssueWatch() = (%#v, %v)", found, err)
}
if err := service.DeleteIssueWatch(t.Context(), watch.ID); err != nil {
t.Fatalf("DeleteIssueWatch() error = %v", err)
}
if deleted, err := service.GetIssueWatch(t.Context(), watch.ID); err != nil || deleted != nil {
t.Fatalf("deleted watch = (%#v, %v)", deleted, err)
}
}
func TestServiceReviewWatchLifecycle(t *testing.T) {
store := newTestStore(t)
client := NewMockClient(DefaultHost)
client.SeedMR("group/project", &MR{IID: 5, Title: "Feature", State: mrStateOpen, ProjectPath: "group/project"})
service := NewService(DefaultHost, client, "mock", nil, newTestLogger(t))
service.SetStore(store)
service.workspaceClients["workspace"] = client
watch, err := service.CreateReviewWatch(t.Context(), &CreateReviewWatchRequest{
WorkspaceID: "workspace", WorkflowID: "workflow", WorkflowStepID: "step",
AgentProfileID: "agent", ExecutorProfileID: "executor", Projects: []ProjectFilter{{Path: "group/project"}},
PollIntervalSeconds: 1,
})
if err != nil {
t.Fatalf("CreateReviewWatch() error = %v", err)
}
if watch.ReviewScope != ReviewScopeUserAndTeams || watch.PollIntervalSeconds != minWatchPollIntervalSec {
t.Fatalf("normalized watch = %#v", watch)
}
listed, err := service.ListReviewWatches(t.Context(), "workspace")
if err != nil || len(listed) != 1 || listed[0].ID != watch.ID {
t.Fatalf("ListReviewWatches() = (%#v, %v)", listed, err)
}
all, err := service.ListAllReviewWatches(t.Context())
if err != nil || len(all) != 1 {
t.Fatalf("ListAllReviewWatches() = (%#v, %v)", all, err)
}
prompt, scope := "Review carefully", ReviewScopeUser
if err := service.UpdateReviewWatch(t.Context(), watch.ID, &UpdateReviewWatchRequest{Prompt: &prompt, ReviewScope: &scope}); err != nil {
t.Fatalf("UpdateReviewWatch() error = %v", err)
}
updated, err := service.GetReviewWatch(t.Context(), watch.ID)
if err != nil || updated.Prompt != prompt || updated.ReviewScope != scope {
t.Fatalf("updated watch = (%#v, %v)", updated, err)
}
found, err := service.TriggerReviewWatch(t.Context(), watch.ID)
if err != nil || len(found) != 1 || found[0].IID != 5 {
t.Fatalf("TriggerReviewWatch() = (%#v, %v)", found, err)
}
if ok, err := store.ReserveReviewMRTask(t.Context(), watch.ID, "group/project", 5, "url"); err != nil || !ok {
t.Fatalf("reserve MR = (%v, %v)", ok, err)
}
found, err = service.CheckReviewWatch(t.Context(), watch)
if err != nil || len(found) != 0 {
t.Fatalf("deduplicated CheckReviewWatch() = (%#v, %v)", found, err)
}
if err := service.DeleteReviewWatch(t.Context(), watch.ID); err != nil {
t.Fatalf("DeleteReviewWatch() error = %v", err)
}
if deleted, err := service.GetReviewWatch(t.Context(), watch.ID); err != nil || deleted != nil {
t.Fatalf("deleted watch = (%#v, %v)", deleted, err)
}
}
func TestServiceWatchValidationErrors(t *testing.T) {
service := NewService(DefaultHost, NewMockClient(DefaultHost), "mock", nil, newTestLogger(t))
if _, err := service.CreateIssueWatch(t.Context(), nil); err == nil {
t.Fatal("CreateIssueWatch(nil) error = nil")
}
if _, err := service.CreateReviewWatch(t.Context(), nil); err == nil {
t.Fatal("CreateReviewWatch(nil) error = nil")
}
if _, err := service.CheckIssueWatch(t.Context(), nil); err == nil {
t.Fatal("CheckIssueWatch(nil) error = nil")
}
if _, err := service.CheckReviewWatch(t.Context(), nil); err == nil {
t.Fatal("CheckReviewWatch(nil) error = nil")
}
disabledIssue := &IssueWatch{Enabled: false}
if found, err := service.CheckIssueWatch(t.Context(), disabledIssue); err != nil || found != nil {
t.Fatalf("disabled issue watch = (%#v, %v)", found, err)
}
disabledReview := &ReviewWatch{Enabled: false}
if found, err := service.CheckReviewWatch(t.Context(), disabledReview); err != nil || found != nil {
t.Fatalf("disabled review watch = (%#v, %v)", found, err)
}
}