chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gogithub "github.com/google/go-github/v89/github"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
testifymock "github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GitHub API endpoint patterns for testing
|
||||
// These constants define the URL patterns used in HTTP mocking for tests
|
||||
const (
|
||||
// User endpoints
|
||||
GetUser = "GET /user"
|
||||
GetUsersByUsername = "GET /users/{username}"
|
||||
GetUserStarred = "GET /user/starred"
|
||||
GetUsersGistsByUsername = "GET /users/{username}/gists"
|
||||
GetUsersStarredByUsername = "GET /users/{username}/starred"
|
||||
PutUserStarredByOwnerByRepo = "PUT /user/starred/{owner}/{repo}"
|
||||
DeleteUserStarredByOwnerByRepo = "DELETE /user/starred/{owner}/{repo}"
|
||||
|
||||
// Repository endpoints
|
||||
GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}"
|
||||
GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches"
|
||||
GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags"
|
||||
GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits"
|
||||
GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}"
|
||||
GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}"
|
||||
PutReposContentsByOwnerByRepoByPath = "PUT /repos/{owner}/{repo}/contents/{path}"
|
||||
PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks"
|
||||
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
|
||||
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
|
||||
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
|
||||
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"
|
||||
|
||||
// Git endpoints
|
||||
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"
|
||||
GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}"
|
||||
PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs"
|
||||
PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}"
|
||||
GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}"
|
||||
PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits"
|
||||
GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}"
|
||||
PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees"
|
||||
GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status"
|
||||
GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses"
|
||||
GetReposCommitsCheckRunsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/check-runs"
|
||||
|
||||
// Issues endpoints
|
||||
GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}"
|
||||
GetReposIssuesCommentByOwnerByRepoByCommentID = "GET /repos/{owner}/{repo}/issues/comments/{comment_id}"
|
||||
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
|
||||
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions"
|
||||
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
|
||||
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
|
||||
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
|
||||
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue"
|
||||
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
|
||||
PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"
|
||||
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values/{issue_field_id}"
|
||||
|
||||
// Pull request endpoints
|
||||
GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls"
|
||||
GetReposPullsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}"
|
||||
GetReposPullsCommitsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits"
|
||||
GetReposPullsFilesByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/files"
|
||||
GetReposPullsReviewsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews"
|
||||
PostReposPullsByOwnerByRepo = "POST /repos/{owner}/{repo}/pulls"
|
||||
PatchReposPullsByOwnerByRepoByPullNumber = "PATCH /repos/{owner}/{repo}/pulls/{pull_number}"
|
||||
PutReposPullsMergeByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge"
|
||||
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch"
|
||||
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"
|
||||
PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments"
|
||||
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID = "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions"
|
||||
|
||||
// Notifications endpoints
|
||||
GetNotifications = "GET /notifications"
|
||||
PutNotifications = "PUT /notifications"
|
||||
GetReposNotificationsByOwnerByRepo = "GET /repos/{owner}/{repo}/notifications"
|
||||
PutReposNotificationsByOwnerByRepo = "PUT /repos/{owner}/{repo}/notifications"
|
||||
GetNotificationsThreadsByThreadID = "GET /notifications/threads/{thread_id}"
|
||||
PatchNotificationsThreadsByThreadID = "PATCH /notifications/threads/{thread_id}"
|
||||
DeleteNotificationsThreadsByThreadID = "DELETE /notifications/threads/{thread_id}"
|
||||
PutNotificationsThreadsSubscriptionByThreadID = "PUT /notifications/threads/{thread_id}/subscription"
|
||||
DeleteNotificationsThreadsSubscriptionByThreadID = "DELETE /notifications/threads/{thread_id}/subscription"
|
||||
|
||||
// Gists endpoints
|
||||
GetGists = "GET /gists"
|
||||
GetGistsByGistID = "GET /gists/{gist_id}"
|
||||
PostGists = "POST /gists"
|
||||
PatchGistsByGistID = "PATCH /gists/{gist_id}"
|
||||
|
||||
// Releases endpoints
|
||||
GetReposReleasesByOwnerByRepo = "GET /repos/{owner}/{repo}/releases"
|
||||
GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest"
|
||||
GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}"
|
||||
|
||||
// Code quality endpoints
|
||||
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}"
|
||||
|
||||
// Code scanning endpoints
|
||||
GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts"
|
||||
GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}"
|
||||
|
||||
// Secret scanning endpoints
|
||||
GetReposSecretScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/secret-scanning/alerts" //nolint:gosec // False positive - this is an API endpoint pattern, not a credential
|
||||
GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}" //nolint:gosec // False positive - this is an API endpoint pattern, not a credential
|
||||
|
||||
// Dependabot endpoints
|
||||
GetReposDependabotAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/dependabot/alerts"
|
||||
GetReposDependabotAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/dependabot/alerts/{alert_number}"
|
||||
|
||||
// Security advisories endpoints
|
||||
GetAdvisories = "GET /advisories"
|
||||
GetAdvisoriesByGhsaID = "GET /advisories/{ghsa_id}"
|
||||
GetReposSecurityAdvisoriesByOwnerByRepo = "GET /repos/{owner}/{repo}/security-advisories"
|
||||
GetOrgsSecurityAdvisoriesByOrg = "GET /orgs/{org}/security-advisories"
|
||||
|
||||
// Actions endpoints
|
||||
GetReposActionsWorkflowsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/workflows"
|
||||
GetReposActionsWorkflowsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}"
|
||||
PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID = "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches"
|
||||
GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs"
|
||||
GetReposActionsRunsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/runs"
|
||||
GetReposActionsRunsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}"
|
||||
GetReposActionsRunsLogsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs"
|
||||
GetReposActionsRunsJobsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||
GetReposActionsRunsArtifactsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts"
|
||||
GetReposActionsRunsTimingByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/timing"
|
||||
PostReposActionsRunsRerunByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun"
|
||||
PostReposActionsRunsRerunFailedJobsByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs"
|
||||
PostReposActionsRunsCancelByOwnerByRepoByRunID = "POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel"
|
||||
GetReposActionsJobsLogsByOwnerByRepoByJobID = "GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs"
|
||||
DeleteReposActionsRunsLogsByOwnerByRepoByRunID = "DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs"
|
||||
|
||||
// Search endpoints
|
||||
GetSearchCode = "GET /search/code"
|
||||
GetSearchIssues = "GET /search/issues"
|
||||
GetSearchUsers = "GET /search/users"
|
||||
GetSearchRepositories = "GET /search/repositories"
|
||||
GetSearchCommits = "GET /search/commits"
|
||||
|
||||
// Raw content endpoints (used for GitHub raw content API, not standard API)
|
||||
// These are used with the raw content client that interacts with raw.githubusercontent.com
|
||||
GetRawReposContentsByOwnerByRepoByPath = "GET /{owner}/{repo}/HEAD/{path:.*}"
|
||||
GetRawReposContentsByOwnerByRepoByBranchByPath = "GET /{owner}/{repo}/refs/heads/{branch}/{path:.*}"
|
||||
GetRawReposContentsByOwnerByRepoByTagByPath = "GET /{owner}/{repo}/refs/tags/{tag}/{path:.*}"
|
||||
GetRawReposContentsByOwnerByRepoBySHAByPath = "GET /{owner}/{repo}/{sha}/{path:.*}"
|
||||
|
||||
// Projects (ProjectsV2) endpoints
|
||||
// Organization-scoped
|
||||
GetOrgsProjectsV2 = "GET /orgs/{org}/projectsV2"
|
||||
GetOrgsProjectsV2ByProject = "GET /orgs/{org}/projectsV2/{project}"
|
||||
GetOrgsProjectsV2FieldsByProject = "GET /orgs/{org}/projectsV2/{project}/fields"
|
||||
GetOrgsProjectsV2FieldsByProjectByFieldID = "GET /orgs/{org}/projectsV2/{project}/fields/{field_id}"
|
||||
GetOrgsProjectsV2ItemsByProject = "GET /orgs/{org}/projectsV2/{project}/items"
|
||||
GetOrgsProjectsV2ItemsByProjectByItemID = "GET /orgs/{org}/projectsV2/{project}/items/{item_id}"
|
||||
PostOrgsProjectsV2ItemsByProject = "POST /orgs/{org}/projectsV2/{project}/items"
|
||||
PatchOrgsProjectsV2ItemsByProjectByItemID = "PATCH /orgs/{org}/projectsV2/{project}/items/{item_id}"
|
||||
DeleteOrgsProjectsV2ItemsByProjectByItemID = "DELETE /orgs/{org}/projectsV2/{project}/items/{item_id}"
|
||||
// User-scoped
|
||||
GetUsersProjectsV2ByUsername = "GET /users/{username}/projectsV2"
|
||||
GetUsersProjectsV2ByUsernameByProject = "GET /users/{username}/projectsV2/{project}"
|
||||
GetUsersProjectsV2FieldsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/fields"
|
||||
GetUsersProjectsV2FieldsByUsernameByProjectByFieldID = "GET /users/{username}/projectsV2/{project}/fields/{field_id}"
|
||||
GetUsersProjectsV2ItemsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/items"
|
||||
GetUsersProjectsV2ItemsByUsernameByProjectByItemID = "GET /users/{username}/projectsV2/{project}/items/{item_id}"
|
||||
PostUsersProjectsV2ItemsByUsernameByProject = "POST /users/{username}/projectsV2/{project}/items"
|
||||
PatchUsersProjectsV2ItemsByUsernameByProjectByItemID = "PATCH /users/{username}/projectsV2/{project}/items/{item_id}"
|
||||
DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID = "DELETE /users/{username}/projectsV2/{project}/items/{item_id}"
|
||||
|
||||
// Organization issue types endpoints
|
||||
GetOrgsIssueTypesByOrg = "GET /orgs/{org}/issue-types"
|
||||
)
|
||||
|
||||
type expectations struct {
|
||||
path string
|
||||
queryParams map[string]string
|
||||
requestBody any
|
||||
}
|
||||
|
||||
// mustNewGHClient creates a new GitHub client for testing.
|
||||
// If httpClient is nil, a client with no options is created.
|
||||
// The test fails immediately if client creation fails.
|
||||
func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client {
|
||||
t.Helper()
|
||||
var client *gogithub.Client
|
||||
var err error
|
||||
if httpClient == nil {
|
||||
client, err = gogithub.NewClient()
|
||||
} else {
|
||||
client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient))
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
// expect is a helper function to create a partial mock that expects various
|
||||
// request behaviors, such as path, query parameters, and request body.
|
||||
func expect(t *testing.T, e expectations) *partialMock {
|
||||
return &partialMock{
|
||||
t: t,
|
||||
expectedPath: e.path,
|
||||
expectedQueryParams: e.queryParams,
|
||||
expectedRequestBody: e.requestBody,
|
||||
}
|
||||
}
|
||||
|
||||
// expectPath is a helper function to create a partial mock that expects a
|
||||
// request with the given path, with the ability to chain a response handler.
|
||||
func expectPath(t *testing.T, expectedPath string) *partialMock {
|
||||
return &partialMock{
|
||||
t: t,
|
||||
expectedPath: expectedPath,
|
||||
}
|
||||
}
|
||||
|
||||
// expectQueryParams is a helper function to create a partial mock that expects a
|
||||
// request with the given query parameters, with the ability to chain a response handler.
|
||||
func expectQueryParams(t *testing.T, expectedQueryParams map[string]string) *partialMock {
|
||||
return &partialMock{
|
||||
t: t,
|
||||
expectedQueryParams: expectedQueryParams,
|
||||
}
|
||||
}
|
||||
|
||||
// expectRequestBody is a helper function to create a partial mock that expects a
|
||||
// request with the given body, with the ability to chain a response handler.
|
||||
func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock {
|
||||
return &partialMock{
|
||||
t: t,
|
||||
expectedRequestBody: expectedRequestBody,
|
||||
}
|
||||
}
|
||||
|
||||
type partialMock struct {
|
||||
t *testing.T
|
||||
|
||||
expectedPath string
|
||||
expectedQueryParams map[string]string
|
||||
expectedRequestBody any
|
||||
expectedHeaderContains map[string]string
|
||||
}
|
||||
|
||||
func (p *partialMock) withHeaders(headers map[string]string) *partialMock {
|
||||
p.expectedHeaderContains = headers
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc {
|
||||
p.t.Helper()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if p.expectedPath != "" {
|
||||
require.Equal(p.t, p.expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
if p.expectedQueryParams != nil {
|
||||
require.Equal(p.t, len(p.expectedQueryParams), len(r.URL.Query()))
|
||||
for k, v := range p.expectedQueryParams {
|
||||
require.Equal(p.t, v, r.URL.Query().Get(k))
|
||||
}
|
||||
}
|
||||
|
||||
if p.expectedRequestBody != nil {
|
||||
var unmarshaledRequestBody any
|
||||
err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody)
|
||||
require.NoError(p.t, err)
|
||||
|
||||
require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody)
|
||||
}
|
||||
|
||||
if p.expectedHeaderContains != nil {
|
||||
for k, v := range p.expectedHeaderContains {
|
||||
require.Contains(p.t, r.Header.Get(k), v, "expected header %q to contain %q", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
responseHandler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// mockResponse is a helper function to create a mock HTTP response handler
|
||||
// that returns a specified status code and marshaled body.
|
||||
func mockResponse(t *testing.T, code int, body any) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
// Some tests do not expect to return a JSON object, such as fetching a raw pull request diff,
|
||||
// so allow strings to be returned directly.
|
||||
s, ok := body.(string)
|
||||
if ok {
|
||||
_, _ = w.Write([]byte(s))
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
// createMCPRequest is a helper function to create a MCP request with the given arguments.
|
||||
func createMCPRequest(args any) mcp.CallToolRequest {
|
||||
// convert args to map[string]interface{} and serialize to JSON
|
||||
argsMap, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
argsMap = make(map[string]any)
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(argsMap)
|
||||
if err != nil {
|
||||
return mcp.CallToolRequest{}
|
||||
}
|
||||
|
||||
jsonRawMessage := json.RawMessage(argsJSON)
|
||||
|
||||
return mcp.CallToolRequest{
|
||||
Params: &mcp.CallToolParamsRaw{
|
||||
Arguments: jsonRawMessage,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Well-known MCP client names used in tests.
|
||||
const (
|
||||
ClientNameVSCodeInsiders = "Visual Studio Code - Insiders"
|
||||
ClientNameVSCode = "Visual Studio Code"
|
||||
)
|
||||
|
||||
// createMCPRequestWithSession creates a CallToolRequest with a ServerSession
|
||||
// that has the given client name in its InitializeParams. When withUI is true
|
||||
// the session advertises MCP Apps UI support via the capability extension.
|
||||
func createMCPRequestWithSession(t *testing.T, clientName string, withUI bool, args any) mcp.CallToolRequest {
|
||||
t.Helper()
|
||||
|
||||
argsMap, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
argsMap = make(map[string]any)
|
||||
}
|
||||
argsJSON, err := json.Marshal(argsMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil)
|
||||
|
||||
caps := &mcp.ClientCapabilities{}
|
||||
if withUI {
|
||||
caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{
|
||||
"mimeTypes": []string{"text/html;profile=mcp-app"},
|
||||
})
|
||||
}
|
||||
|
||||
st, _ := mcp.NewInMemoryTransports()
|
||||
session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{
|
||||
State: &mcp.ServerSessionState{
|
||||
InitializeParams: &mcp.InitializeParams{
|
||||
ClientInfo: &mcp.Implementation{Name: clientName},
|
||||
Capabilities: caps,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Close the unused client-side transport and session
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
return mcp.CallToolRequest{
|
||||
Session: session,
|
||||
Params: &mcp.CallToolParamsRaw{
|
||||
Arguments: json.RawMessage(argsJSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getTextResult is a helper function that returns a text result from a tool call.
|
||||
func getTextResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent {
|
||||
t.Helper()
|
||||
assert.NotNil(t, result)
|
||||
require.Len(t, result.Content, 1)
|
||||
textContent, ok := result.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok, "expected content to be of type TextContent")
|
||||
return textContent
|
||||
}
|
||||
|
||||
func getErrorResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent {
|
||||
res := getTextResult(t, result)
|
||||
require.True(t, result.IsError, "expected tool call result to be an error")
|
||||
return res
|
||||
}
|
||||
|
||||
// getTextResourceResult is a helper function that returns a text result from a tool call.
|
||||
|
||||
// getBlobResourceResult is a helper function that returns a blob result from a tool call.
|
||||
|
||||
func TestOptionalParamOK(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
paramName string
|
||||
expectedVal any
|
||||
expectedOk bool
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "present and correct type (string)",
|
||||
args: map[string]any{"myParam": "hello"},
|
||||
paramName: "myParam",
|
||||
expectedVal: "hello",
|
||||
expectedOk: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "present and correct type (bool)",
|
||||
args: map[string]any{"myParam": true},
|
||||
paramName: "myParam",
|
||||
expectedVal: true,
|
||||
expectedOk: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "present and correct type (number)",
|
||||
args: map[string]any{"myParam": float64(123)},
|
||||
paramName: "myParam",
|
||||
expectedVal: float64(123),
|
||||
expectedOk: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "present but wrong type (string expected, got bool)",
|
||||
args: map[string]any{"myParam": true},
|
||||
paramName: "myParam",
|
||||
expectedVal: "", // Zero value for string
|
||||
expectedOk: true, // ok is true because param exists
|
||||
expectError: true,
|
||||
errorMsg: "parameter myParam is not of type string, is bool",
|
||||
},
|
||||
{
|
||||
name: "present but wrong type (bool expected, got string)",
|
||||
args: map[string]any{"myParam": "true"},
|
||||
paramName: "myParam",
|
||||
expectedVal: false, // Zero value for bool
|
||||
expectedOk: true, // ok is true because param exists
|
||||
expectError: true,
|
||||
errorMsg: "parameter myParam is not of type bool, is string",
|
||||
},
|
||||
{
|
||||
name: "parameter not present",
|
||||
args: map[string]any{"anotherParam": "value"},
|
||||
paramName: "myParam",
|
||||
expectedVal: "", // Zero value for string
|
||||
expectedOk: false,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test with string type assertion
|
||||
if _, isString := tc.expectedVal.(string); isString || tc.errorMsg == "parameter myParam is not of type string, is bool" {
|
||||
val, ok, err := OptionalParamOK[string](tc.args, tc.paramName)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||
assert.Equal(t, tc.expectedOk, ok) // Check ok even on error
|
||||
assert.Equal(t, tc.expectedVal, val) // Check zero value on error
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedOk, ok)
|
||||
assert.Equal(t, tc.expectedVal, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Test with bool type assertion
|
||||
if _, isBool := tc.expectedVal.(bool); isBool || tc.errorMsg == "parameter myParam is not of type bool, is string" {
|
||||
val, ok, err := OptionalParamOK[bool](tc.args, tc.paramName)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||
assert.Equal(t, tc.expectedOk, ok) // Check ok even on error
|
||||
assert.Equal(t, tc.expectedVal, val) // Check zero value on error
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedOk, ok)
|
||||
assert.Equal(t, tc.expectedVal, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Test with float64 type assertion (for number case)
|
||||
if _, isFloat := tc.expectedVal.(float64); isFloat {
|
||||
val, ok, err := OptionalParamOK[float64](tc.args, tc.paramName)
|
||||
if tc.expectError {
|
||||
// This case shouldn't happen for float64 in the defined tests
|
||||
require.Fail(t, "Unexpected error case for float64")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedOk, ok)
|
||||
assert.Equal(t, tc.expectedVal, val)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceContents {
|
||||
t.Helper()
|
||||
assert.NotNil(t, result)
|
||||
require.Len(t, result.Content, 2)
|
||||
content := result.Content[1]
|
||||
require.IsType(t, &mcp.EmbeddedResource{}, content)
|
||||
resource, ok := content.(*mcp.EmbeddedResource)
|
||||
require.True(t, ok, "expected content to be of type EmbeddedResource")
|
||||
|
||||
require.IsType(t, &mcp.ResourceContents{}, resource.Resource)
|
||||
return resource.Resource
|
||||
}
|
||||
|
||||
// MockRoundTripper is a mock HTTP transport using testify/mock
|
||||
type MockRoundTripper struct {
|
||||
testifymock.Mock
|
||||
handlers map[string]http.HandlerFunc
|
||||
}
|
||||
|
||||
// NewMockRoundTripper creates a new mock round tripper
|
||||
func NewMockRoundTripper() *MockRoundTripper {
|
||||
return &MockRoundTripper{
|
||||
handlers: make(map[string]http.HandlerFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Normalize the request path and method for matching
|
||||
key := req.Method + " " + req.URL.Path
|
||||
|
||||
// Check if we have a specific handler for this request
|
||||
if handler, ok := m.handlers[key]; ok {
|
||||
// Use httptest.ResponseRecorder to capture the handler's response
|
||||
recorder := &responseRecorder{
|
||||
header: make(http.Header),
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
handler(recorder, req)
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: recorder.statusCode,
|
||||
Header: recorder.header,
|
||||
Body: io.NopCloser(bytes.NewReader(recorder.body.Bytes())),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to mock.Mock assertions if defined
|
||||
args := m.Called(req)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*http.Response), args.Error(1)
|
||||
}
|
||||
|
||||
// On registers an expectation using testify/mock
|
||||
func (m *MockRoundTripper) OnRequest(method, path string, handler http.HandlerFunc) *MockRoundTripper {
|
||||
key := method + " " + path
|
||||
m.handlers[key] = handler
|
||||
return m
|
||||
}
|
||||
|
||||
// NewMockHTTPClient creates an HTTP client with a mock transport
|
||||
func NewMockHTTPClient() (*http.Client, *MockRoundTripper) {
|
||||
transport := NewMockRoundTripper()
|
||||
client := &http.Client{Transport: transport}
|
||||
return client, transport
|
||||
}
|
||||
|
||||
// responseRecorder is a simple response recorder for the mock transport
|
||||
type responseRecorder struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Header() http.Header {
|
||||
return r.header
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(data []byte) (int, error) {
|
||||
if r.statusCode == 0 {
|
||||
r.statusCode = http.StatusOK
|
||||
}
|
||||
return r.body.Write(data)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(statusCode int) {
|
||||
r.statusCode = statusCode
|
||||
}
|
||||
|
||||
// matchPath checks if a request path matches a pattern (supports simple wildcards)
|
||||
func matchPath(pattern, path string) bool {
|
||||
// Simple exact match for now
|
||||
if pattern == path {
|
||||
return true
|
||||
}
|
||||
|
||||
// Support for path parameters like /repos/{owner}/{repo}/issues/{issue_number}
|
||||
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
// Handle patterns with wildcard path like {path:.*}
|
||||
if len(patternParts) > 0 {
|
||||
lastPart := patternParts[len(patternParts)-1]
|
||||
if strings.HasPrefix(lastPart, "{") && strings.Contains(lastPart, ":") && strings.HasSuffix(lastPart, "}") {
|
||||
// This is a wildcard pattern like {path:.*}
|
||||
// Check if all parts before the wildcard match
|
||||
if len(pathParts) < len(patternParts)-1 {
|
||||
return false
|
||||
}
|
||||
for i := range len(patternParts) - 1 {
|
||||
if strings.HasPrefix(patternParts[i], "{") && strings.HasSuffix(patternParts[i], "}") {
|
||||
continue // Path parameter matches anything
|
||||
}
|
||||
if patternParts[i] != pathParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if len(patternParts) != len(pathParts) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range patternParts {
|
||||
// Check if this is a path parameter (enclosed in {})
|
||||
if strings.HasPrefix(patternParts[i], "{") && strings.HasSuffix(patternParts[i], "}") {
|
||||
continue // Path parameters match anything
|
||||
}
|
||||
if patternParts[i] != pathParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// executeHandler executes an HTTP handler and returns the response
|
||||
func executeHandler(handler http.HandlerFunc, req *http.Request) *http.Response {
|
||||
recorder := &responseRecorder{
|
||||
header: make(http.Header),
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
handler(recorder, req)
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: recorder.statusCode,
|
||||
Header: recorder.header,
|
||||
Body: io.NopCloser(bytes.NewReader(recorder.body.Bytes())),
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
// MockHTTPClientWithHandler creates an HTTP client with a single handler function
|
||||
func MockHTTPClientWithHandler(handler http.HandlerFunc) *http.Client {
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"": handler, // Empty key acts as catch-all
|
||||
}
|
||||
return MockHTTPClientWithHandlers(handlers)
|
||||
}
|
||||
|
||||
// MockHTTPClientWithHandlers creates an HTTP client with multiple handlers for different paths
|
||||
func MockHTTPClientWithHandlers(handlers map[string]http.HandlerFunc) *http.Client {
|
||||
transport := &multiHandlerTransport{handlers: handlers}
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// Compatibility helpers to replace github.com/migueleliasweb/go-github-mock in tests
|
||||
type EndpointPattern string
|
||||
|
||||
type MockBackendOption func(map[string]http.HandlerFunc)
|
||||
|
||||
func parseEndpointPattern(p EndpointPattern) (string, string) {
|
||||
parts := strings.SplitN(string(p), " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return http.MethodGet, string(p)
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
func WithRequestMatch(pattern EndpointPattern, response any) MockBackendOption {
|
||||
return func(handlers map[string]http.HandlerFunc) {
|
||||
method, path := parseEndpointPattern(pattern)
|
||||
handlers[method+" "+path] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
switch v := response.(type) {
|
||||
case string:
|
||||
_, _ = w.Write([]byte(v))
|
||||
case []byte:
|
||||
_, _ = w.Write(v)
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithRequestMatchHandler(pattern EndpointPattern, handler http.HandlerFunc) MockBackendOption {
|
||||
return func(handlers map[string]http.HandlerFunc) {
|
||||
method, path := parseEndpointPattern(pattern)
|
||||
handlers[method+" "+path] = handler
|
||||
}
|
||||
}
|
||||
|
||||
func NewMockedHTTPClient(options ...MockBackendOption) *http.Client {
|
||||
handlers := map[string]http.HandlerFunc{}
|
||||
for _, opt := range options {
|
||||
if opt != nil {
|
||||
opt(handlers)
|
||||
}
|
||||
}
|
||||
return MockHTTPClientWithHandlers(handlers)
|
||||
}
|
||||
|
||||
func MustMarshal(v any) []byte {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type multiHandlerTransport struct {
|
||||
handlers map[string]http.HandlerFunc
|
||||
}
|
||||
|
||||
func (m *multiHandlerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Check for catch-all handler
|
||||
if handler, ok := m.handlers[""]; ok {
|
||||
return executeHandler(handler, req), nil
|
||||
}
|
||||
|
||||
// Try to find a handler for this request
|
||||
key := req.Method + " " + req.URL.Path
|
||||
|
||||
// First try exact match
|
||||
if handler, ok := m.handlers[key]; ok {
|
||||
return executeHandler(handler, req), nil
|
||||
}
|
||||
|
||||
// Then try pattern matching, prioritizing patterns without wildcards
|
||||
// This is important because wildcard patterns like /{owner}/{repo}/{sha}/{path:.*}
|
||||
// can incorrectly match API paths like /repos/owner/repo/pulls/42
|
||||
var wildcardPattern string
|
||||
var wildcardHandler http.HandlerFunc
|
||||
|
||||
for pattern, handler := range m.handlers {
|
||||
if pattern == "" {
|
||||
continue // Skip catch-all
|
||||
}
|
||||
parts := strings.SplitN(pattern, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
method, pathPattern := parts[0], parts[1]
|
||||
if req.Method != method {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this pattern contains a wildcard like {path:.*}
|
||||
isWildcard := strings.Contains(pathPattern, ":.*}")
|
||||
|
||||
if matchPath(pathPattern, req.URL.Path) {
|
||||
if isWildcard {
|
||||
// Save wildcard match for later, prefer non-wildcard patterns
|
||||
wildcardPattern = pattern
|
||||
wildcardHandler = handler
|
||||
} else {
|
||||
// Non-wildcard pattern takes priority
|
||||
return executeHandler(handler, req), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a wildcard match but no specific match, use it
|
||||
if wildcardPattern != "" && wildcardHandler != nil {
|
||||
return executeHandler(wildcardHandler, req), nil
|
||||
}
|
||||
|
||||
// No handler found
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte("not found"))),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractPathParams extracts path parameters from a URL path given a pattern
|
||||
func extractPathParams(pattern, path string) map[string]string {
|
||||
params := make(map[string]string)
|
||||
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
if len(patternParts) != len(pathParts) {
|
||||
return params
|
||||
}
|
||||
|
||||
for i := range patternParts {
|
||||
if strings.HasPrefix(patternParts[i], "{") && strings.HasSuffix(patternParts[i], "}") {
|
||||
paramName := strings.Trim(patternParts[i], "{}")
|
||||
params[paramName] = pathParts[i]
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ParseRequestPath is a helper to extract path parameters
|
||||
func ParseRequestPath(t *testing.T, req *http.Request, pattern string) url.Values {
|
||||
t.Helper()
|
||||
params := extractPathParams(pattern, req.URL.Path)
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
values.Set(k, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user