package github import ( "context" "encoding/json" "net/http" "testing" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v74/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_ListNotifications(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_notifications", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "filter") assert.Contains(t, tool.InputSchema.Properties, "since") assert.Contains(t, tool.InputSchema.Properties, "before") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "page") assert.Contains(t, tool.InputSchema.Properties, "perPage") // All fields are optional, so Required should be empty assert.Empty(t, tool.InputSchema.Required) mockNotification := &github.Notification{ ID: github.Ptr("123"), Reason: github.Ptr("mention"), } tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectedResult []*github.Notification expectedErrMsg string }{ { name: "success default filter (no params)", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetNotifications, []*github.Notification{mockNotification}, ), ), requestArgs: map[string]interface{}{}, expectError: false, expectedResult: []*github.Notification{mockNotification}, }, { name: "success with filter=include_read_notifications", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetNotifications, []*github.Notification{mockNotification}, ), ), requestArgs: map[string]interface{}{ "filter": "include_read_notifications", }, expectError: false, expectedResult: []*github.Notification{mockNotification}, }, { name: "success with filter=only_participating", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetNotifications, []*github.Notification{mockNotification}, ), ), requestArgs: map[string]interface{}{ "filter": "only_participating", }, expectError: false, expectedResult: []*github.Notification{mockNotification}, }, { name: "success for repo notifications", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetReposNotificationsByOwnerByRepo, []*github.Notification{mockNotification}, ), ), requestArgs: map[string]interface{}{ "filter": "default", "since": "2024-01-01T00:00:00Z", "before": "2024-01-02T00:00:00Z", "owner": "octocat", "repo": "hello-world", "page": float64(2), "perPage": float64(10), }, expectError: false, expectedResult: []*github.Notification{mockNotification}, }, { name: "error", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetNotifications, mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), ), ), requestArgs: map[string]interface{}{}, expectError: true, expectedErrMsg: "error", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { require.NoError(t, err) require.True(t, result.IsError) errorContent := getErrorResult(t, result) if tc.expectedErrMsg != "" { assert.Contains(t, errorContent.Text, tc.expectedErrMsg) } return } require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) t.Logf("textContent: %s", textContent.Text) var returned []*github.Notification err = json.Unmarshal([]byte(textContent.Text), &returned) require.NoError(t, err) require.NotEmpty(t, returned) assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) }) } } func Test_ManageNotificationSubscription(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "manage_notification_subscription", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "notificationID") assert.Contains(t, tool.InputSchema.Properties, "action") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"}) mockSub := &github.Subscription{Ignored: github.Ptr(true)} mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectIgnored *bool expectDeleted bool expectInvalid bool expectedErrMsg string }{ { name: "ignore subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutNotificationsThreadsSubscriptionByThreadId, mockSub, ), ), requestArgs: map[string]interface{}{ "notificationID": "123", "action": "ignore", }, expectError: false, expectIgnored: github.Ptr(true), }, { name: "watch subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutNotificationsThreadsSubscriptionByThreadId, mockSubWatch, ), ), requestArgs: map[string]interface{}{ "notificationID": "123", "action": "watch", }, expectError: false, expectIgnored: github.Ptr(false), }, { name: "delete subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.DeleteNotificationsThreadsSubscriptionByThreadId, nil, ), ), requestArgs: map[string]interface{}{ "notificationID": "123", "action": "delete", }, expectError: false, expectDeleted: true, }, { name: "invalid action", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "notificationID": "123", "action": "invalid", }, expectError: false, expectInvalid: true, }, { name: "missing required notificationID", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "action": "ignore", }, expectError: true, }, { name: "missing required action", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "notificationID": "123", }, expectError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { require.NoError(t, err) require.NotNil(t, result) text := getTextResult(t, result).Text switch { case tc.requestArgs["notificationID"] == nil: assert.Contains(t, text, "missing required parameter: notificationID") case tc.requestArgs["action"] == nil: assert.Contains(t, text, "missing required parameter: action") default: assert.Contains(t, text, "error") } return } require.NoError(t, err) textContent := getTextResult(t, result) if tc.expectIgnored != nil { var returned github.Subscription err = json.Unmarshal([]byte(textContent.Text), &returned) require.NoError(t, err) assert.Equal(t, *tc.expectIgnored, *returned.Ignored) } if tc.expectDeleted { assert.Contains(t, textContent.Text, "deleted") } if tc.expectInvalid { assert.Contains(t, textContent.Text, "Invalid action") } }) } } func Test_ManageRepositoryNotificationSubscription(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "manage_repository_notification_subscription", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "action") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"}) mockSub := &github.Subscription{Ignored: github.Ptr(true)} mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectIgnored *bool expectSubscribed *bool expectDeleted bool expectInvalid bool expectedErrMsg string }{ { name: "ignore subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutReposSubscriptionByOwnerByRepo, mockSub, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "action": "ignore", }, expectError: false, expectIgnored: github.Ptr(true), }, { name: "watch subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutReposSubscriptionByOwnerByRepo, mockWatchSub, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "action": "watch", }, expectError: false, expectIgnored: github.Ptr(false), expectSubscribed: github.Ptr(true), }, { name: "delete subscription", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.DeleteReposSubscriptionByOwnerByRepo, nil, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "action": "delete", }, expectError: false, expectDeleted: true, }, { name: "invalid action", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "action": "invalid", }, expectError: false, expectInvalid: true, }, { name: "missing required owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "repo": "repo", "action": "ignore", }, expectError: true, }, { name: "missing required repo", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "action": "ignore", }, expectError: true, }, { name: "missing required action", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", }, expectError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { require.NoError(t, err) require.NotNil(t, result) text := getTextResult(t, result).Text switch { case tc.requestArgs["owner"] == nil: assert.Contains(t, text, "missing required parameter: owner") case tc.requestArgs["repo"] == nil: assert.Contains(t, text, "missing required parameter: repo") case tc.requestArgs["action"] == nil: assert.Contains(t, text, "missing required parameter: action") default: assert.Contains(t, text, "error") } return } require.NoError(t, err) textContent := getTextResult(t, result) if tc.expectIgnored != nil || tc.expectSubscribed != nil { var returned github.Subscription err = json.Unmarshal([]byte(textContent.Text), &returned) require.NoError(t, err) if tc.expectIgnored != nil { assert.Equal(t, *tc.expectIgnored, *returned.Ignored) } if tc.expectSubscribed != nil { assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed) } } if tc.expectDeleted { assert.Contains(t, textContent.Text, "deleted") } if tc.expectInvalid { assert.Contains(t, textContent.Text, "Invalid action") } }) } } func Test_DismissNotification(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "dismiss_notification", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "threadID") assert.Contains(t, tool.InputSchema.Properties, "state") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"}) tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectRead bool expectDone bool expectInvalid bool expectedErrMsg string }{ { name: "mark as read", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PatchNotificationsThreadsByThreadId, nil, ), ), requestArgs: map[string]interface{}{ "threadID": "123", "state": "read", }, expectError: false, expectRead: true, }, { name: "mark as done", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.DeleteNotificationsThreadsByThreadId, nil, ), ), requestArgs: map[string]interface{}{ "threadID": "123", "state": "done", }, expectError: false, expectDone: true, }, { name: "invalid threadID format", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "threadID": "notanumber", "state": "done", }, expectError: false, expectInvalid: true, }, { name: "missing required threadID", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "state": "read", }, expectError: true, }, { name: "missing required state", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "threadID": "123", }, expectError: true, }, { name: "invalid state value", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "threadID": "123", "state": "invalid", }, expectError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { // The tool returns a ToolResultError with a specific message require.NoError(t, err) require.NotNil(t, result) text := getTextResult(t, result).Text switch { case tc.requestArgs["threadID"] == nil: assert.Contains(t, text, "missing required parameter: threadID") case tc.requestArgs["state"] == nil: assert.Contains(t, text, "missing required parameter: state") case tc.name == "invalid threadID format": assert.Contains(t, text, "invalid threadID format") case tc.name == "invalid state value": assert.Contains(t, text, "Invalid state. Must be one of: read, done.") default: // fallback for other errors assert.Contains(t, text, "error") } return } require.NoError(t, err) textContent := getTextResult(t, result) if tc.expectRead { assert.Contains(t, textContent.Text, "Notification marked as read") } if tc.expectDone { assert.Contains(t, textContent.Text, "Notification marked as done") } if tc.expectInvalid { assert.Contains(t, textContent.Text, "invalid threadID format") } }) } } func Test_MarkAllNotificationsRead(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "mark_all_notifications_read", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "lastReadAt") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Empty(t, tool.InputSchema.Required) tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectMarked bool expectedErrMsg string }{ { name: "success (no params)", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutNotifications, nil, ), ), requestArgs: map[string]interface{}{}, expectError: false, expectMarked: true, }, { name: "success with lastReadAt param", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutNotifications, nil, ), ), requestArgs: map[string]interface{}{ "lastReadAt": "2024-01-01T00:00:00Z", }, expectError: false, expectMarked: true, }, { name: "success with owner and repo", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.PutReposNotificationsByOwnerByRepo, nil, ), ), requestArgs: map[string]interface{}{ "owner": "octocat", "repo": "hello-world", }, expectError: false, expectMarked: true, }, { name: "API error", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PutNotifications, mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), ), ), requestArgs: map[string]interface{}{}, expectError: true, expectedErrMsg: "error", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { require.NoError(t, err) require.True(t, result.IsError) errorContent := getErrorResult(t, result) if tc.expectedErrMsg != "" { assert.Contains(t, errorContent.Text, tc.expectedErrMsg) } return } require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) if tc.expectMarked { assert.Contains(t, textContent.Text, "All notifications marked as read") } }) } } func Test_GetNotificationDetails(t *testing.T) { // Verify tool definition and schema mockClient := github.NewClient(nil) tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_notification_details", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "notificationID") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"}) mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectResult *github.Notification expectedErrMsg string }{ { name: "success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetNotificationsThreadsByThreadId, mockThread, ), ), requestArgs: map[string]interface{}{ "notificationID": "123", }, expectError: false, expectResult: mockThread, }, { name: "not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetNotificationsThreadsByThreadId, mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), ), ), requestArgs: map[string]interface{}{ "notificationID": "123", }, expectError: true, expectedErrMsg: "not found", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) if tc.expectError { require.NoError(t, err) require.True(t, result.IsError) errorContent := getErrorResult(t, result) if tc.expectedErrMsg != "" { assert.Contains(t, errorContent.Text, tc.expectedErrMsg) } return } require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var returned github.Notification err = json.Unmarshal([]byte(textContent.Text), &returned) require.NoError(t, err) assert.Equal(t, *tc.expectResult.ID, *returned.ID) }) } }