From 1b26646e677e8dd65b2e610a45196b201cdfa74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 12 Aug 2026 01:33:38 +0200 Subject: [PATCH] test(gitlab): expand client and service coverage (#2545) Co-authored-by: Kandev Agent --- .../gitlab/controller_watches_actions_test.go | 128 +++++++ .../internal/gitlab/mock_controller_test.go | 57 +++ .../gitlab/pat_client_search_actions_test.go | 327 ++++++++++++++++++ .../gitlab/service_merge_search_test.go | 115 ++++++ .../service_mr_automation_boundaries_test.go | 102 ++++++ .../gitlab/service_watch_lifecycle_test.go | 139 ++++++++ 6 files changed, 868 insertions(+) create mode 100644 apps/backend/internal/gitlab/controller_watches_actions_test.go create mode 100644 apps/backend/internal/gitlab/pat_client_search_actions_test.go create mode 100644 apps/backend/internal/gitlab/service_merge_search_test.go create mode 100644 apps/backend/internal/gitlab/service_mr_automation_boundaries_test.go create mode 100644 apps/backend/internal/gitlab/service_watch_lifecycle_test.go diff --git a/apps/backend/internal/gitlab/controller_watches_actions_test.go b/apps/backend/internal/gitlab/controller_watches_actions_test.go new file mode 100644 index 0000000000..33b10c734f --- /dev/null +++ b/apps/backend/internal/gitlab/controller_watches_actions_test.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/mock_controller_test.go b/apps/backend/internal/gitlab/mock_controller_test.go index 3b400c2430..990347f87a 100644 --- a/apps/backend/internal/gitlab/mock_controller_test.go +++ b/apps/backend/internal/gitlab/mock_controller_test.go @@ -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()) + } + } +} diff --git a/apps/backend/internal/gitlab/pat_client_search_actions_test.go b/apps/backend/internal/gitlab/pat_client_search_actions_test.go new file mode 100644 index 0000000000..3854049115 --- /dev/null +++ b/apps/backend/internal/gitlab/pat_client_search_actions_test.go @@ -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) + } +} diff --git a/apps/backend/internal/gitlab/service_merge_search_test.go b/apps/backend/internal/gitlab/service_merge_search_test.go new file mode 100644 index 0000000000..1942b4f289 --- /dev/null +++ b/apps/backend/internal/gitlab/service_merge_search_test.go @@ -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) + } +} diff --git a/apps/backend/internal/gitlab/service_mr_automation_boundaries_test.go b/apps/backend/internal/gitlab/service_mr_automation_boundaries_test.go new file mode 100644 index 0000000000..29248f2ce3 --- /dev/null +++ b/apps/backend/internal/gitlab/service_mr_automation_boundaries_test.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/service_watch_lifecycle_test.go b/apps/backend/internal/gitlab/service_watch_lifecycle_test.go new file mode 100644 index 0000000000..5fb07db2d1 --- /dev/null +++ b/apps/backend/internal/gitlab/service_watch_lifecycle_test.go @@ -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) + } +}