11f1b20134
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// --- Task MR listing routes ---
|
|
|
|
func TestHTTPListWorkspaceTaskMRsGroupsByTask(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-a", "ws-1")
|
|
seedTask(t, store, "task-b", "ws-1")
|
|
seedTask(t, store, "task-foreign", "ws-2")
|
|
for _, mr := range []*TaskMR{
|
|
newTestMR("task-a", "repo-1", "group/a", 11),
|
|
newTestMR("task-b", "repo-1", "group/b", 22),
|
|
newTestMR("task-foreign", "repo-1", "group/c", 33),
|
|
} {
|
|
if err := store.UpsertTaskMR(ctx, mr); err != nil {
|
|
t.Fatalf("seed %s MR: %v", mr.TaskID, err)
|
|
}
|
|
}
|
|
|
|
var body TaskMRsResponse
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/workspaces/ws-1/task-mrs", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.TaskMRs) != 2 {
|
|
t.Fatalf("grouped tasks = %d, want 2 (%v)", len(body.TaskMRs), body.TaskMRs)
|
|
}
|
|
if _, leaked := body.TaskMRs["task-foreign"]; leaked {
|
|
t.Error("the ws-2 task leaked into the ws-1 listing")
|
|
}
|
|
if got := body.TaskMRs["task-a"]; len(got) != 1 || got[0].MRIID != 11 {
|
|
t.Errorf("task-a group = %+v, want the single !11 row", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPListTaskMRsReturnsRowsForTask(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-a", "ws-1")
|
|
if err := store.UpsertTaskMR(ctx, newTestMR("task-a", "repo-1", "group/a", 11)); err != nil {
|
|
t.Fatalf("seed MR: %v", err)
|
|
}
|
|
|
|
var body struct {
|
|
TaskMRs []*TaskMR `json:"task_mrs"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet, "/api/v1/gitlab/tasks/task-a/mrs", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.TaskMRs) != 1 || body.TaskMRs[0].ProjectPath != "group/a" {
|
|
t.Fatalf("task_mrs = %+v, want the single group/a row", body.TaskMRs)
|
|
}
|
|
}
|
|
|
|
func TestHTTPListTaskMRsReturnsEmptyListForUnknownTask(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
var body struct {
|
|
TaskMRs []*TaskMR `json:"task_mrs"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet, "/api/v1/gitlab/tasks/nope/mrs", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.TaskMRs) != 0 {
|
|
t.Errorf("task_mrs = %+v, want an empty list", body.TaskMRs)
|
|
}
|
|
}
|
|
|
|
// --- Watch reset routes ---
|
|
|
|
func TestHTTPPreviewResetReviewWatchCountsTasks(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
watch := validReviewWatchRow()
|
|
if err := store.CreateReviewWatch(ctx, watch); err != nil {
|
|
t.Fatalf("seed review watch: %v", err)
|
|
}
|
|
if _, err := store.ReserveReviewMRTask(ctx, watch.ID, "group/p", 1, "url"); err != nil {
|
|
t.Fatalf("reserve review MR task: %v", err)
|
|
}
|
|
if err := store.AssignReviewMRTaskID(ctx, watch.ID, "group/p", 1, "task-1"); err != nil {
|
|
t.Fatalf("assign review MR task: %v", err)
|
|
}
|
|
|
|
var body struct {
|
|
TaskCount int `json:"taskCount"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/watches/review/"+watch.ID+"/reset/preview?workspace_id=ws-1", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if body.TaskCount != 1 {
|
|
t.Errorf("taskCount = %d, want 1", body.TaskCount)
|
|
}
|
|
}
|
|
|
|
func TestHTTPResetReviewWatchDeletesTaskTrees(t *testing.T) {
|
|
svc, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
deleter := &recordingCascadeDeleter{}
|
|
svc.SetCascadeTaskDeleter(deleter)
|
|
watch := validReviewWatchRow()
|
|
if err := store.CreateReviewWatch(ctx, watch); err != nil {
|
|
t.Fatalf("seed review watch: %v", err)
|
|
}
|
|
if _, err := store.ReserveReviewMRTask(ctx, watch.ID, "group/p", 1, "url"); err != nil {
|
|
t.Fatalf("reserve review MR task: %v", err)
|
|
}
|
|
if err := store.AssignReviewMRTaskID(ctx, watch.ID, "group/p", 1, "task-1"); err != nil {
|
|
t.Fatalf("assign review MR task: %v", err)
|
|
}
|
|
|
|
var body struct {
|
|
TasksDeleted int `json:"tasksDeleted"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodPost,
|
|
"/api/v1/gitlab/watches/review/"+watch.ID+"/reset?workspace_id=ws-1", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if body.TasksDeleted != 1 {
|
|
t.Errorf("tasksDeleted = %d, want 1", body.TasksDeleted)
|
|
}
|
|
if len(deleter.ids) != 1 || deleter.ids[0] != "task-1" {
|
|
t.Errorf("deleted task trees = %v, want [task-1]", deleter.ids)
|
|
}
|
|
}
|
|
|
|
// TestHTTPResetReviewWatchWithoutCascadeDeleterFails covers writeWatchResetError:
|
|
// an unwired cascade deleter is a server-side misconfiguration, and the reply
|
|
// must be a 500 whose message names the operation, not the internal cause.
|
|
func TestHTTPResetReviewWatchWithoutCascadeDeleterFails(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
watch := validReviewWatchRow()
|
|
if err := store.CreateReviewWatch(t.Context(), watch); err != nil {
|
|
t.Fatalf("seed review watch: %v", err)
|
|
}
|
|
|
|
recorder := watchScopeRequest(t, router, http.MethodPost,
|
|
"/api/v1/gitlab/watches/review/"+watch.ID+"/reset?workspace_id=ws-1", nil)
|
|
|
|
if recorder.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := httpErrorMessage(t, recorder); got != "reset review watch failed" {
|
|
t.Errorf("error = %q, want \"reset review watch failed\"", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPResetReviewWatchRejectsForeignWatch(t *testing.T) {
|
|
svc, store, router := newWatchHTTPFixture(t)
|
|
deleter := &recordingCascadeDeleter{}
|
|
svc.SetCascadeTaskDeleter(deleter)
|
|
foreign := validReviewWatchRow()
|
|
foreign.WorkspaceID = "ws-2"
|
|
if err := store.CreateReviewWatch(t.Context(), foreign); err != nil {
|
|
t.Fatalf("seed review watch: %v", err)
|
|
}
|
|
|
|
recorder := watchScopeRequest(t, router, http.MethodPost,
|
|
"/api/v1/gitlab/watches/review/"+foreign.ID+"/reset?workspace_id=ws-1", nil)
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(deleter.ids) != 0 {
|
|
t.Errorf("a cross-workspace reset deleted task trees %v", deleter.ids)
|
|
}
|
|
}
|
|
|
|
func TestHTTPPreviewResetIssueWatchCountsTasks(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
watch := validIssueWatchRow()
|
|
if err := store.CreateIssueWatch(ctx, watch); err != nil {
|
|
t.Fatalf("seed issue watch: %v", err)
|
|
}
|
|
if _, err := store.ReserveIssueWatchTask(ctx, watch.ID, "group/p", 1, "url"); err != nil {
|
|
t.Fatalf("reserve issue task: %v", err)
|
|
}
|
|
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "group/p", 1, "task-1"); err != nil {
|
|
t.Fatalf("assign issue task: %v", err)
|
|
}
|
|
|
|
var body struct {
|
|
TaskCount int `json:"taskCount"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/watches/issue/"+watch.ID+"/reset/preview?workspace_id=ws-1", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if body.TaskCount != 1 {
|
|
t.Errorf("taskCount = %d, want 1", body.TaskCount)
|
|
}
|
|
}
|
|
|
|
func TestHTTPResetIssueWatchDeletesTaskTrees(t *testing.T) {
|
|
svc, store, router := newWatchHTTPFixture(t)
|
|
ctx := context.Background()
|
|
deleter := &recordingCascadeDeleter{}
|
|
svc.SetCascadeTaskDeleter(deleter)
|
|
watch := validIssueWatchRow()
|
|
if err := store.CreateIssueWatch(ctx, watch); err != nil {
|
|
t.Fatalf("seed issue watch: %v", err)
|
|
}
|
|
if _, err := store.ReserveIssueWatchTask(ctx, watch.ID, "group/p", 1, "url"); err != nil {
|
|
t.Fatalf("reserve issue task: %v", err)
|
|
}
|
|
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "group/p", 1, "task-1"); err != nil {
|
|
t.Fatalf("assign issue task: %v", err)
|
|
}
|
|
|
|
var body struct {
|
|
TasksDeleted int `json:"tasksDeleted"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodPost,
|
|
"/api/v1/gitlab/watches/issue/"+watch.ID+"/reset?workspace_id=ws-1", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if body.TasksDeleted != 1 {
|
|
t.Errorf("tasksDeleted = %d, want 1", body.TasksDeleted)
|
|
}
|
|
if len(deleter.ids) != 1 || deleter.ids[0] != "task-1" {
|
|
t.Errorf("deleted task trees = %v, want [task-1]", deleter.ids)
|
|
}
|
|
}
|
|
|
|
func TestHTTPPreviewResetIssueWatchRejectsForeignWatch(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
foreign := validIssueWatchRow()
|
|
foreign.WorkspaceID = "ws-2"
|
|
if err := store.CreateIssueWatch(t.Context(), foreign); err != nil {
|
|
t.Fatalf("seed issue watch: %v", err)
|
|
}
|
|
|
|
recorder := watchScopeRequest(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/watches/issue/"+foreign.ID+"/reset/preview?workspace_id=ws-1", nil)
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := httpErrorMessage(t, recorder); got != "issue watch not found" {
|
|
t.Errorf("error = %q, want \"issue watch not found\"", got)
|
|
}
|
|
}
|
|
|
|
// --- Action presets over HTTP ---
|
|
|
|
func TestHTTPActionPresetsRoundTrip(t *testing.T) {
|
|
_, store, router := newWatchHTTPFixture(t)
|
|
|
|
var defaults ActionPresets
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/action-presets?workspace_id=ws-1", nil, &defaults)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(defaults.MR) == 0 {
|
|
t.Fatal("mr presets are empty; the built-in defaults must be substituted")
|
|
}
|
|
|
|
var updated ActionPresets
|
|
recorder = httpJSON(t, router, http.MethodPut,
|
|
"/api/v1/gitlab/action-presets?workspace_id=ws-1",
|
|
UpdateActionPresetsRequest{
|
|
MR: &[]ActionPreset{{ID: "ship", Label: "Ship it", PromptTemplate: "ship {{mr}}"}},
|
|
}, &updated)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("put status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(updated.MR) != 1 || updated.MR[0].ID != "ship" {
|
|
t.Fatalf("mr presets = %+v, want only the custom ship preset", updated.MR)
|
|
}
|
|
if updated.WorkspaceID != "ws-1" {
|
|
t.Errorf("workspace = %q, want ws-1 from the query parameter", updated.WorkspaceID)
|
|
}
|
|
|
|
var reset ActionPresets
|
|
recorder = httpJSON(t, router, http.MethodPost,
|
|
"/api/v1/gitlab/action-presets/reset?workspace_id=ws-1", nil, &reset)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("reset status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
for _, preset := range reset.MR {
|
|
if preset.ID == "ship" {
|
|
t.Fatalf("mr presets = %+v, want the custom preset gone after reset", reset.MR)
|
|
}
|
|
}
|
|
stored, err := store.GetActionPresets(t.Context(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("reload presets: %v", err)
|
|
}
|
|
if len(stored.MR) != 0 {
|
|
t.Errorf("stored mr presets = %+v, want the row deleted", stored.MR)
|
|
}
|
|
}
|
|
|
|
func TestHTTPUpdateActionPresetsRejectsInvalidPayload(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
recorder := watchScopeRequest(t, router, http.MethodPut,
|
|
"/api/v1/gitlab/action-presets?workspace_id=ws-1", []string{"not", "an", "object"})
|
|
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := httpErrorMessage(t, recorder); got != "invalid payload" {
|
|
t.Errorf("error = %q, want \"invalid payload\"", got)
|
|
}
|
|
}
|
|
|
|
// --- Project routes ---
|
|
|
|
func TestHTTPListUserProjectsReturnsProjects(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
var body struct {
|
|
Projects []Project `json:"projects"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/projects?workspace_id=ws-1", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.Projects) != 1 || body.Projects[0].PathWithNamespace != "kandev/sample" {
|
|
t.Fatalf("projects = %+v, want the single kandev/sample project", body.Projects)
|
|
}
|
|
}
|
|
|
|
func TestHTTPSearchProjectsFiltersByQuery(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
want int
|
|
}{
|
|
{"matching", "sample", 1},
|
|
{"non-matching", "no-such-project", 0},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var body struct {
|
|
Projects []Project `json:"projects"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/projects/search?workspace_id=ws-1&query="+test.query, nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.Projects) != test.want {
|
|
t.Errorf("projects = %d, want %d (%+v)", len(body.Projects), test.want, body.Projects)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPProjectRoutesRequireProjectQuery(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
for _, path := range []string{"/projects/branches", "/projects/merge-methods"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
recorder := watchScopeRequest(t, router, http.MethodGet,
|
|
"/api/v1/gitlab"+path+"?workspace_id=ws-1", nil)
|
|
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := httpErrorMessage(t, recorder); got != "project required" {
|
|
t.Errorf("error = %q, want \"project required\"", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPGetProjectMergeMethodsReturnsAllowedMethods(t *testing.T) {
|
|
_, _, router := newWatchHTTPFixture(t)
|
|
|
|
var methods ProjectMergeMethods
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/projects/merge-methods?workspace_id=ws-1&project=group%2Fp", nil, &methods)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !methods.Merge || !methods.AllowSquash {
|
|
t.Errorf("methods = %+v, want the mock's merge+squash defaults", methods)
|
|
}
|
|
}
|
|
|
|
func TestHTTPListProjectBranchesReturnsBranches(t *testing.T) {
|
|
svc, _, router := newWatchHTTPFixture(t)
|
|
svc.workspaceClients["ws-1"].(*MockClient).SeedBranches("group/p",
|
|
[]RepoBranch{{Name: "main"}, {Name: "dev"}})
|
|
|
|
var body struct {
|
|
Branches []RepoBranch `json:"branches"`
|
|
}
|
|
recorder := httpJSON(t, router, http.MethodGet,
|
|
"/api/v1/gitlab/projects/branches?workspace_id=ws-1&project=group%2Fp", nil, &body)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(body.Branches) != 2 || body.Branches[0].Name != "main" {
|
|
t.Fatalf("branches = %+v, want [main dev]", body.Branches)
|
|
}
|
|
}
|