Files
kandev/apps/backend/internal/gitlab/controller_http_surface_test.go
João Salavisa 11f1b20134 test(gitlab): cover WS handlers, watch HTTP routes, and provider identity (#2585)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:48:41 +01:00

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)
}
}