package project import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "testing" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" "gitea.com/gitea/gitea-mcp/pkg/flag" "github.com/modelcontextprotocol/go-sdk/mcp" ) func TestGetProjectScope(t *testing.T) { tests := []struct { name string args map[string]any writable bool wantKind string wantErr bool }{ { name: "repo", args: map[string]any{"scope": "repo", "owner": "octo", "repo": "demo"}, wantKind: "repo", }, { name: "org", args: map[string]any{"scope": "org", "owner": "acme"}, wantKind: "org", }, { name: "user read", args: map[string]any{"scope": "user", "owner": "octo"}, wantKind: "user", }, { name: "user write", args: map[string]any{"scope": "user", "owner": "octo"}, writable: true, wantErr: true, }, { name: "current user", args: map[string]any{"scope": "current_user"}, wantKind: "current_user", }, { name: "missing scope", args: map[string]any{}, wantErr: true, }, { name: "repo missing owner", args: map[string]any{"scope": "repo", "repo": "demo"}, wantErr: true, }, { name: "repo missing repo", args: map[string]any{"scope": "repo", "owner": "octo"}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { info, err := getProjectScope(tt.args, tt.writable) if tt.wantErr { if err == nil { t.Fatal("expected an error, got nil") } return } if err != nil { t.Fatalf("getProjectScope() error = %v", err) } if info.kind != tt.wantKind { t.Fatalf("scope kind = %q, want %q", info.kind, tt.wantKind) } }) } } func TestProjectReadFn(t *testing.T) { const ( owner = "octo" repo = "demo" projectID = 10 columnID = 20 ) var ( mu sync.Mutex gotReq = struct { method string path string query string }{} ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") mu.Lock() gotReq.method = r.Method gotReq.path = r.URL.Path gotReq.query = r.URL.RawQuery mu.Unlock() switch r.URL.Path { case "/api/v1/version": _, _ = w.Write([]byte(`{"version":"1.28.0"}`)) case fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo): _, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Board","state":"open"}]`, projectID)) case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID): _, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Board","state":"open"}`, projectID)) case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID): _, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Todo","project_id":%d}]`, columnID, projectID)) case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID): _, _ = w.Write([]byte(`[{"id":5,"number":1,"title":"An issue","state":"open","html_url":"https://example.com/1","user":{"login":"octo"}}]`)) case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID): _, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Todo","project_id":%d}`, columnID, projectID)) default: http.NotFound(w, r) } })) defer server.Close() origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version flag.Host, flag.Token, flag.Version = server.URL, "", "test" defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-read-token") tests := []struct { name string args map[string]any wantPath string wantID float64 }{ { name: "list", args: map[string]any{ "method": "list", "scope": "repo", "owner": owner, "repo": repo, "state": "open", }, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo), wantID: projectID, }, { name: "get", args: map[string]any{ "method": "get", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), }, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), wantID: projectID, }, { name: "list columns", args: map[string]any{ "method": "list_columns", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), }, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID), wantID: columnID, }, { name: "get column", args: map[string]any{ "method": "get_column", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), }, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), wantID: columnID, }, { name: "list column issues", args: map[string]any{ "method": "list_column_issues", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), }, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID), wantID: 5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := projectReadFn(ctx, tt.args) if err != nil { t.Fatalf("projectReadFn() error = %v", err) } if result.IsError { t.Fatalf("projectReadFn() returned error result: %v", result) } mu.Lock() path := gotReq.path query := gotReq.query mu.Unlock() if path != tt.wantPath { t.Fatalf("request path = %q, want %q", path, tt.wantPath) } if tt.name == "list" && query == "" { t.Fatal("expected list query parameters, got none") } if len(result.Content) == 0 { t.Fatal("expected result content") } text, ok := result.Content[0].(*mcp.TextContent) if !ok { t.Fatalf("result content type = %T, want text content", result.Content[0]) } assertFirstID(t, []byte(text.Text), tt.wantID) }) } } func TestProjectWriteFn(t *testing.T) { const ( owner = "octo" repo = "demo" projectID = 10 columnID = 20 issueID = 5 ) type request struct { method string path string body map[string]any } var ( mu sync.Mutex requests []request ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api/v1/version" { _, _ = w.Write([]byte(`{"version":"1.28.0"}`)) return } var body map[string]any if r.Body != nil && r.ContentLength > 0 { _ = json.NewDecoder(r.Body).Decode(&body) } mu.Lock() requests = append(requests, request{method: r.Method, path: r.URL.Path, body: body}) mu.Unlock() switch r.Method { case http.MethodGet: _, _ = w.Write([]byte(`[]`)) case http.MethodPost, http.MethodPatch: _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNoContent) } })) defer server.Close() origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version flag.Host, flag.Token, flag.Version = server.URL, "", "test" defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-write-token") tests := []struct { name string args map[string]any wantMethod string wantPath string wantBody map[string]any }{ { name: "create", args: map[string]any{ "method": "create", "scope": "repo", "owner": owner, "repo": repo, "title": "Board", "description": "Project board", "template_type": "basic_kanban", "card_type": "images_and_text", }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo), wantBody: map[string]any{"title": "Board", "description": "Project board", "template_type": "basic_kanban", "card_type": "images_and_text"}, }, { name: "update", args: map[string]any{ "method": "update", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "title": "Updated Board", "state": "closed", }, wantMethod: http.MethodPatch, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), wantBody: map[string]any{"title": "Updated Board", "state": "closed"}, }, { name: "delete", args: map[string]any{ "method": "delete", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), }, wantMethod: http.MethodDelete, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), }, { name: "create column", args: map[string]any{ "method": "create_column", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "title": "Todo", "color": "#FF0000", }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID), wantBody: map[string]any{"title": "Todo", "color": "#FF0000"}, }, { name: "update column", args: map[string]any{ "method": "update_column", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), "title": "Doing", "sorting": float64(2), }, wantMethod: http.MethodPatch, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), wantBody: map[string]any{"title": "Doing", "sorting": float64(2)}, }, { name: "delete column", args: map[string]any{ "method": "delete_column", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), }, wantMethod: http.MethodDelete, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), }, { name: "set default column", args: map[string]any{ "method": "set_default_column", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/default", owner, repo, projectID, columnID), }, { name: "move columns", args: map[string]any{ "method": "move_columns", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_ids": []any{float64(20), float64(21)}, }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/move", owner, repo, projectID), wantBody: map[string]any{"column_ids": []any{float64(20), float64(21)}}, }, { name: "add issue", args: map[string]any{ "method": "add_issue", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), "issue_id": float64(issueID), }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID), }, { name: "remove issue", args: map[string]any{ "method": "remove_issue", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "column_id": float64(columnID), "issue_id": float64(issueID), }, wantMethod: http.MethodDelete, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID), }, { name: "move issue", args: map[string]any{ "method": "move_issue", "scope": "repo", "owner": owner, "repo": repo, "project_id": float64(projectID), "issue_id": float64(issueID), "column_id": float64(columnID), "sorting": float64(3), }, wantMethod: http.MethodPost, wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner, repo, projectID, issueID), wantBody: map[string]any{"column_id": float64(columnID), "sorting": float64(3)}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := projectWriteFn(ctx, tt.args) if err != nil { t.Fatalf("projectWriteFn() error = %v", err) } if result.IsError { t.Fatalf("projectWriteFn() returned error result: %v", result) } mu.Lock() request := requests[len(requests)-1] mu.Unlock() if request.method != tt.wantMethod { t.Fatalf("request method = %q, want %q", request.method, tt.wantMethod) } if request.path != tt.wantPath { t.Fatalf("request path = %q, want %q", request.path, tt.wantPath) } if tt.wantBody != nil { if len(request.body) != len(tt.wantBody) { t.Fatalf("request body = %v, want %v", request.body, tt.wantBody) } for key, want := range tt.wantBody { if got := request.body[key]; fmt.Sprint(got) != fmt.Sprint(want) { t.Fatalf("request body[%q] = %v, want %v", key, got, want) } } } }) } } func assertFirstID(t *testing.T, data []byte, want float64) { t.Helper() if len(data) > 0 && data[0] == '[' { var items []map[string]any if err := json.Unmarshal(data, &items); err != nil { t.Fatalf("unmarshal result text: %v", err) } if len(items) == 0 { t.Fatal("expected at least one result item") } if items[0]["id"] != want { t.Fatalf("first result id = %v, want %v", items[0]["id"], want) } return } var item map[string]any if err := json.Unmarshal(data, &item); err != nil { t.Fatalf("unmarshal result text: %v", err) } if item["id"] != want { t.Fatalf("result id = %v, want %v", item["id"], want) } }