forgejo-mcp/operation/issue/issue_test.go

348 lines
11 KiB
Go

package issue
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
)
type recordedReq struct {
method string
path string
rawBody []byte
}
func newPatchBackend(t *testing.T, respBody string) (*httptest.Server, *[]recordedReq) {
t.Helper()
records := make([]recordedReq, 0, 2)
// Serve the SDK's startup version probe so NewClient succeeds.
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"11.0.0+gitea-1.22.0"}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
records = append(records, recordedReq{
method: r.Method,
path: r.URL.Path,
rawBody: body,
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(respBody))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
flag.URL = srv.URL
flag.Token = "tkn"
flag.UserAgent = "test"
c, err := forgejo_sdk.NewClient(srv.URL,
forgejo_sdk.SetToken("tkn"),
forgejo_sdk.SetUserAgent("test"),
)
if err != nil {
t.Fatalf("failed to build SDK client for test: %v", err)
}
forgejo.SetClientForTesting(c)
return srv, &records
}
func makeReq(args map[string]any) mcp.CallToolRequest {
return mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}}
}
func TestUpdateIssue_AssigneeSingular(t *testing.T) {
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
"owner": "goern",
"repo": "forgejo-mcp",
"index": float64(42),
"assignee": "goern",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
}
if len(*records) == 0 {
t.Fatal("expected at least one HTTP request to backend")
}
last := (*records)[len(*records)-1]
if last.method != http.MethodPatch {
t.Fatalf("expected PATCH, got %s", last.method)
}
if !strings.Contains(last.path, "/issues/42") {
t.Fatalf("unexpected path: %s", last.path)
}
var payload map[string]any
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
}
assignees, ok := payload["assignees"].([]any)
if !ok {
t.Fatalf("assignees field missing or wrong type: %T %v", payload["assignees"], payload["assignees"])
}
if len(assignees) != 1 || assignees[0] != "goern" {
t.Fatalf("expected assignees=[goern], got %v", assignees)
}
}
func TestUpdateIssue_AssigneesCSV(t *testing.T) {
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
"owner": "goern",
"repo": "forgejo-mcp",
"index": float64(42),
"assignees": "alice, bob ,carol",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
}
last := (*records)[len(*records)-1]
var payload map[string]any
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
}
assignees, _ := payload["assignees"].([]any)
want := []string{"alice", "bob", "carol"}
if len(assignees) != len(want) {
t.Fatalf("len mismatch: got %v want %v", assignees, want)
}
for i, v := range want {
if assignees[i] != v {
t.Fatalf("assignees[%d]: got %v want %s", i, assignees[i], v)
}
}
}
func TestUpdateIssue_AssigneesEmptyClears(t *testing.T) {
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
"owner": "goern",
"repo": "forgejo-mcp",
"index": float64(42),
"assignees": "",
"assignee": "ignored-because-assignees-wins",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
}
last := (*records)[len(*records)-1]
if !strings.Contains(string(last.rawBody), `"assignees":[]`) {
t.Fatalf("expected empty assignees array in body, got: %s", last.rawBody)
}
}
// newLabelsBackend serves /api/v1/repos/{owner}/{repo}/labels and
// /api/v1/orgs/{owner}/labels with caller-supplied status codes and bodies.
// Caller passes maps keyed by exact path. Status defaults to 200 if 0.
func newLabelsBackend(
t *testing.T,
repoLabelsBody string, repoStatus int,
orgLabelsBody string, orgStatus int,
) (*httptest.Server, *[]recordedReq) {
t.Helper()
records := make([]recordedReq, 0, 4)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"11.0.0+gitea-1.22.0"}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
records = append(records, recordedReq{method: r.Method, path: r.URL.Path, rawBody: body})
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/") && strings.HasSuffix(r.URL.Path, "/labels"):
if repoStatus == 0 {
repoStatus = http.StatusOK
}
w.WriteHeader(repoStatus)
_, _ = w.Write([]byte(repoLabelsBody))
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/") && strings.HasSuffix(r.URL.Path, "/labels"):
if orgStatus == 0 {
orgStatus = http.StatusOK
}
w.WriteHeader(orgStatus)
_, _ = w.Write([]byte(orgLabelsBody))
default:
w.WriteHeader(http.StatusNotFound)
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
flag.URL = srv.URL
flag.Token = "tkn"
flag.UserAgent = "test"
c, err := forgejo_sdk.NewClient(srv.URL,
forgejo_sdk.SetToken("tkn"),
forgejo_sdk.SetUserAgent("test"),
)
if err != nil {
t.Fatalf("failed to build SDK client for test: %v", err)
}
forgejo.SetClientForTesting(c)
return srv, &records
}
func TestListOrgLabels_Success(t *testing.T) {
_, records := newLabelsBackend(t,
"", 0,
`[{"id":1,"name":"bug","color":"ff0000","description":"a bug","url":""},{"id":2,"name":"enh","color":"00ff00","description":"","url":""}]`, http.StatusOK,
)
res, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "codeberg"}))
if err != nil || res == nil || res.IsError {
t.Fatalf("ListOrgLabelsFn err: %v res=%+v", err, res)
}
if len(*records) == 0 {
t.Fatal("expected request to backend")
}
last := (*records)[len(*records)-1]
if last.method != http.MethodGet {
t.Fatalf("expected GET, got %s", last.method)
}
if !strings.HasPrefix(last.path, "/api/v1/orgs/codeberg/labels") {
t.Fatalf("unexpected path: %s", last.path)
}
// Result body should serialize each entry with scope=org.
if !strings.Contains(textOf(res), `"scope":"org"`) {
t.Fatalf("expected scope=org in result, got %q", textOf(res))
}
}
func TestListOrgLabels_404IsEmpty(t *testing.T) {
_, _ = newLabelsBackend(t, "", 0, ``, http.StatusNotFound)
res, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "ghost"}))
if err != nil || res == nil || res.IsError {
t.Fatalf("expected success on 404, got err=%v res=%+v", err, res)
}
if !strings.Contains(textOf(res), "[]") && !strings.Contains(textOf(res), "null") {
t.Fatalf("expected empty result for 404, got %q", textOf(res))
}
}
func TestListOrgLabels_UnauthorizedSurfaces(t *testing.T) {
_, _ = newLabelsBackend(t, "", 0, ``, http.StatusUnauthorized)
_, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "codeberg"}))
if err == nil {
t.Fatal("expected error on 401, got nil")
}
}
func TestListRepoLabels_MergeWithOrgLabels(t *testing.T) {
_, _ = newLabelsBackend(t,
`[{"id":10,"name":"good-first-issue","color":"7057ff","description":"","url":""}]`, http.StatusOK,
`[{"id":20,"name":"security","color":"d73a4a","description":"","url":""}]`, http.StatusOK,
)
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
"owner": "codeberg-org", "repo": "project",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("ListRepoLabelsFn err: %v res=%+v", err, res)
}
out := textOf(res)
if !strings.Contains(out, `"scope":"repo"`) || !strings.Contains(out, `"scope":"org"`) {
t.Fatalf("expected both scopes in result, got %q", out)
}
if !strings.Contains(out, `"good-first-issue"`) || !strings.Contains(out, `"security"`) {
t.Fatalf("expected both label names in merged result, got %q", out)
}
}
func TestListRepoLabels_IncludeOrgFalseSkipsOrgCall(t *testing.T) {
_, records := newLabelsBackend(t,
`[{"id":10,"name":"good-first-issue","color":"7057ff","description":"","url":""}]`, http.StatusOK,
`[{"id":20,"name":"security","color":"d73a4a","description":"","url":""}]`, http.StatusOK,
)
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
"owner": "codeberg-org",
"repo": "project",
"include_org_labels": false,
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("ListRepoLabelsFn err: %v res=%+v", err, res)
}
for _, r := range *records {
if strings.HasPrefix(r.path, "/api/v1/orgs/") {
t.Fatalf("did not expect org-labels request, got %s %s", r.method, r.path)
}
}
out := textOf(res)
if strings.Contains(out, `"scope":"org"`) || strings.Contains(out, `"security"`) {
t.Fatalf("expected no org-scoped labels in result, got %q", out)
}
}
func TestListRepoLabels_UserOwnerOrgEndpoint404(t *testing.T) {
_, _ = newLabelsBackend(t,
`[{"id":10,"name":"bug","color":"ff0000","description":"","url":""}]`, http.StatusOK,
``, http.StatusNotFound,
)
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
"owner": "alice", "repo": "project",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("expected success when org endpoint 404s, got err=%v res=%+v", err, res)
}
out := textOf(res)
if !strings.Contains(out, `"scope":"repo"`) {
t.Fatalf("expected repo-scoped label in result, got %q", out)
}
if strings.Contains(out, `"scope":"org"`) {
t.Fatalf("expected no org-scoped labels when org endpoint 404s, got %q", out)
}
}
// textOf flattens a CallToolResult into a single string for substring assertions.
func textOf(res *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range res.Content {
if tc, ok := c.(mcp.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
func TestUpdateIssue_NoAssigneeFieldsOmitsAssignees(t *testing.T) {
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
"owner": "goern",
"repo": "forgejo-mcp",
"index": float64(42),
"title": "new title",
}))
if err != nil || res == nil || res.IsError {
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
}
last := (*records)[len(*records)-1]
var payload map[string]any
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
}
if v, ok := payload["assignees"]; ok && v != nil {
// Acceptable for SDK to emit "assignees":null since Assignees is a slice; reject only non-null arrays.
if arr, isArr := v.([]any); isArr && len(arr) > 0 {
t.Fatalf("expected no assignees set, got %v", v)
}
}
}