diff --git a/cmd/server/interfaces.go b/cmd/server/interfaces.go new file mode 100644 index 0000000..7b28eae --- /dev/null +++ b/cmd/server/interfaces.go @@ -0,0 +1,21 @@ +package main + +import ( + "context" + + "github.com/codeGROOVE-dev/discordian/internal/discord" + "github.com/codeGROOVE-dev/discordian/internal/github" +) + +// GitHubManager defines GitHub operations needed by the server. +type GitHubManager interface { + RefreshInstallations(ctx context.Context) error + AllOrgs() []string + ClientForOrg(org string) (*github.OrgClient, bool) + AppClient() *github.AppClient +} + +// DiscordGuildManager defines Discord guild management operations. +type DiscordGuildManager interface { + RegisterClient(guildID string, client *discord.Client) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 980f311..983c775 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -174,9 +174,9 @@ func run(ctx context.Context, cancel context.CancelFunc) int { return 0 } -// configAdapter adapts config.Manager to usermapping.ReverseConfigLookup. +// configAdapter adapts bot.ConfigManager to usermapping.ReverseConfigLookup. type configAdapter struct { - mgr *config.Manager + mgr bot.ConfigManager } func (ca *configAdapter) Config(org string) (usermapping.OrgConfig, bool) { @@ -194,11 +194,11 @@ type coordinatorManager struct { notifyMgr *notify.Manager reverseMapper *usermapping.ReverseMapper active map[string]context.CancelFunc - guildManager *discord.GuildManager + guildManager DiscordGuildManager failed map[string]time.Time coordinators map[string]*bot.Coordinator - configManager *config.Manager - githubManager *github.Manager + configManager bot.ConfigManager + githubManager GitHubManager cfg config.ServerConfig dmsSent int64 dailyReports int64 diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index d99d5d3..26ee795 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "net/http" "os" "testing" @@ -21,6 +22,67 @@ type mockStateStore struct { dailyReportInfos map[string]state.DailyReportInfo } +type mockTurnClient struct { + response *bot.CheckResponse + checkError error +} + +func (m *mockTurnClient) Check(_ context.Context, _, _ string, _ time.Time) (*bot.CheckResponse, error) { + if m.checkError != nil { + return nil, m.checkError + } + return m.response, nil +} + +type mockConfigManager struct { + configs map[string]*config.DiscordConfig +} + +func (m *mockConfigManager) LoadConfig(_ context.Context, _ string) error { + return nil +} + +func (m *mockConfigManager) ReloadConfig(_ context.Context, _ string) error { + return nil +} + +func (m *mockConfigManager) Config(org string) (*config.DiscordConfig, bool) { + if m.configs == nil { + return nil, false + } + cfg, ok := m.configs[org] + return cfg, ok +} + +func (m *mockConfigManager) ChannelsForRepo(_, _ string) []string { + return []string{} +} + +func (m *mockConfigManager) ChannelType(_, _ string) string { + return "text" +} + +func (m *mockConfigManager) DiscordUserID(_, _ string) string { + return "" +} + +func (m *mockConfigManager) ReminderDMDelay(_, _ string) int { + return 0 +} + +func (m *mockConfigManager) When(_, _ string) string { + return "" +} + +func (m *mockConfigManager) GuildID(org string) string { + if cfg, ok := m.Config(org); ok { + return cfg.Global.GuildID + } + return "" +} + +func (m *mockConfigManager) SetGitHubClient(_ string, _ any) {} + func (m *mockStateStore) Thread(_ context.Context, _, _ string, _ int, _ string) (state.ThreadInfo, bool) { return state.ThreadInfo{}, false } @@ -190,6 +252,7 @@ func TestCoordinatorManager_Report_Errors(t *testing.T) { startTime: time.Now(), store: &mockStateStore{}, configManager: config.New(), + reverseMapper: usermapping.NewReverseMapper(), } _, err := cm.Report(context.Background(), "unknown-guild", "user123") @@ -202,7 +265,17 @@ func TestCoordinatorManager_Report_Errors(t *testing.T) { } }) - t.Run("no Discord client for guild", func(t *testing.T) { + t.Run("no GitHub username mapping", func(t *testing.T) { + mockCfg := &mockConfigManager{ + configs: map[string]*config.DiscordConfig{ + "test-org": { + Global: config.GlobalConfig{ + GuildID: "test-guild", + }, + }, + }, + } + cm := &coordinatorManager{ active: map[string]context.CancelFunc{ "test-org": func() {}, @@ -213,13 +286,17 @@ func TestCoordinatorManager_Report_Errors(t *testing.T) { lastEventTime: make(map[string]time.Time), startTime: time.Now(), store: &mockStateStore{}, - configManager: config.New(), + configManager: mockCfg, + reverseMapper: usermapping.NewReverseMapper(), } - _, err := cm.Report(context.Background(), "test-guild", "user123") + _, err := cm.Report(context.Background(), "test-guild", "user-without-mapping") if err == nil { - t.Error("expected error for missing Discord client") + t.Error("expected error for no GitHub username mapping") + } + if err != nil && err.Error() != "no GitHub username mapping found for Discord user" { + t.Errorf("unexpected error message: %v", err) } }) } @@ -373,6 +450,7 @@ func TestCoordinatorManager_UserMappings(t *testing.T) { active: make(map[string]context.CancelFunc), coordinators: make(map[string]*bot.Coordinator), configManager: config.New(), + reverseMapper: usermapping.NewReverseMapper(), } mappings, err := cm.UserMappings(context.Background(), "unknown-guild") @@ -383,6 +461,42 @@ func TestCoordinatorManager_UserMappings(t *testing.T) { t.Errorf("TotalUsers = %d, want 0", mappings.TotalUsers) } }) + + t.Run("with config mappings", func(t *testing.T) { + mockCfg := &mockConfigManager{ + configs: map[string]*config.DiscordConfig{ + "test-org": { + Global: config.GlobalConfig{ + GuildID: "test-guild", + }, + Users: map[string]string{ + "github-user1": "discord-id-1", + "github-user2": "discord-id-2", + }, + }, + }, + } + + cm := &coordinatorManager{ + active: map[string]context.CancelFunc{ + "test-org": func() {}, + }, + coordinators: make(map[string]*bot.Coordinator), + configManager: mockCfg, + reverseMapper: usermapping.NewReverseMapper(), + } + + mappings, err := cm.UserMappings(context.Background(), "test-guild") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(mappings.ConfigMappings) != 2 { + t.Errorf("ConfigMappings = %d, want 2", len(mappings.ConfigMappings)) + } + if mappings.TotalUsers != 2 { + t.Errorf("TotalUsers = %d, want 2", mappings.TotalUsers) + } + }) } func TestCoordinatorManager_ChannelMappings(t *testing.T) { @@ -401,6 +515,45 @@ func TestCoordinatorManager_ChannelMappings(t *testing.T) { t.Errorf("TotalRepos = %d, want 0", mappings.TotalRepos) } }) + + t.Run("with channel and repo mappings", func(t *testing.T) { + mockCfg := &mockConfigManager{ + configs: map[string]*config.DiscordConfig{ + "test-org": { + Global: config.GlobalConfig{ + GuildID: "test-guild", + }, + Channels: map[string]config.ChannelConfig{ + "channel-1": { + Repos: []string{"repo1", "repo2"}, + }, + "channel-2": { + Repos: []string{"repo3"}, + }, + }, + }, + }, + } + + cm := &coordinatorManager{ + active: map[string]context.CancelFunc{ + "test-org": func() {}, + }, + coordinators: make(map[string]*bot.Coordinator), + configManager: mockCfg, + } + + mappings, err := cm.ChannelMappings(context.Background(), "test-guild") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(mappings.RepoMappings) != 3 { + t.Errorf("RepoMappings = %d, want 3", len(mappings.RepoMappings)) + } + if mappings.TotalRepos != 3 { + t.Errorf("TotalRepos = %d, want 3", mappings.TotalRepos) + } + }) } func TestLoadConfig_MissingRequired(t *testing.T) { @@ -635,3 +788,177 @@ func TestCoordinatorManager_DailyReportGetter_Interface(t *testing.T) { // Test that coordinatorManager implements DailyReportGetter interface var _ discord.DailyReportGetter = (*coordinatorManager)(nil) } + +func TestCoordinatorManager_Status_WithMetrics(t *testing.T) { + mockStore := &mockStateStore{ + pendingDMs: []*state.PendingDM{ + {ID: "1", UserID: "user1"}, + {ID: "2", UserID: "user2"}, + }, + } + + cm := &coordinatorManager{ + active: map[string]context.CancelFunc{ + "org1": func() {}, + "org2": func() {}, + }, + discordClients: make(map[string]*discord.Client), + slashHandlers: make(map[string]*discord.SlashCommandHandler), + coordinators: make(map[string]*bot.Coordinator), + lastEventTime: map[string]time.Time{ + "org1": time.Now().Add(-5 * time.Minute), + }, + startTime: time.Now().Add(-2 * time.Hour), + store: mockStore, + configManager: config.New(), + dailyReports: 5, + dmsSent: 10, + channelMsgs: 50, + } + + status := cm.Status(context.Background(), "test-guild") + + if !status.Connected { + t.Error("Status() Connected = false, want true") + } + if len(status.ConnectedOrgs) != 2 { + t.Errorf("Status() ConnectedOrgs = %d, want 2", len(status.ConnectedOrgs)) + } + if status.PendingDMs != 2 { + t.Errorf("Status() PendingDMs = %d, want 2", status.PendingDMs) + } + if status.DailyReportsSent != 5 { + t.Errorf("Status() DailyReportsSent = %d, want 5", status.DailyReportsSent) + } + if status.DMsSent != 10 { + t.Errorf("Status() DMsSent = %d, want 10", status.DMsSent) + } + if status.ChannelMessagesSent != 50 { + t.Errorf("Status() ChannelMessagesSent = %d, want 50", status.ChannelMessagesSent) + } + if status.UptimeSeconds < 7100 || status.UptimeSeconds > 7300 { + t.Errorf("Status() UptimeSeconds = %d, want ~7200", status.UptimeSeconds) + } +} + +func TestAnalyzePRForReport(t *testing.T) { + t.Run("invalid PR URL", func(t *testing.T) { + pr := bot.PRSearchResult{ + URL: "not-a-valid-url", + UpdatedAt: time.Now(), + } + + result := analyzePRForReport(context.Background(), pr, "testuser", nil) + + if result != nil { + t.Error("expected nil result for invalid PR URL") + } + }) + + t.Run("Turn API error", func(t *testing.T) { + mockTurn := &mockTurnClient{ + checkError: errors.New("API error"), + } + + pr := bot.PRSearchResult{ + URL: "https://github.com/testorg/testrepo/pull/123", + UpdatedAt: time.Now(), + } + + result := analyzePRForReport(context.Background(), pr, "testuser", mockTurn) + + if result != nil { + t.Error("expected nil result when Turn API fails") + } + }) + + t.Run("successful PR analysis with action", func(t *testing.T) { + mockTurn := &mockTurnClient{ + response: &bot.CheckResponse{ + PullRequest: bot.PRInfo{ + Author: "author1", + Title: "Test PR", + Merged: false, + Closed: false, + Draft: false, + }, + Analysis: bot.Analysis{ + WorkflowState: "review", + Approved: false, + Checks: bot.Checks{ + Failing: 0, + Pending: 0, + Waiting: 0, + }, + NextAction: map[string]bot.Action{ + "testuser": { + Kind: "review", + }, + }, + }, + }, + } + + pr := bot.PRSearchResult{ + URL: "https://github.com/testorg/testrepo/pull/123", + UpdatedAt: time.Now(), + } + + result := analyzePRForReport(context.Background(), pr, "testuser", mockTurn) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Repo != "testrepo" { + t.Errorf("Repo = %q, want %q", result.Repo, "testrepo") + } + if result.Number != 123 { + t.Errorf("Number = %d, want 123", result.Number) + } + if result.Title != "Test PR" { + t.Errorf("Title = %q, want %q", result.Title, "Test PR") + } + if result.Author != "author1" { + t.Errorf("Author = %q, want %q", result.Author, "author1") + } + if result.Action == "" { + t.Error("expected non-empty action") + } + }) + + t.Run("blocked PR", func(t *testing.T) { + mockTurn := &mockTurnClient{ + response: &bot.CheckResponse{ + PullRequest: bot.PRInfo{ + Author: "author1", + Title: "Blocked PR", + Merged: false, + Closed: false, + Draft: false, + }, + Analysis: bot.Analysis{ + MergeConflict: true, + WorkflowState: "conflict", + Checks: bot.Checks{ + Failing: 0, + }, + NextAction: map[string]bot.Action{}, + }, + }, + } + + pr := bot.PRSearchResult{ + URL: "https://github.com/testorg/testrepo/pull/456", + UpdatedAt: time.Now(), + } + + result := analyzePRForReport(context.Background(), pr, "testuser", mockTurn) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.IsBlocked { + t.Error("expected IsBlocked = true for PR with merge conflict") + } + }) +} diff --git a/internal/bot/mocks_test.go b/internal/bot/mocks_test.go new file mode 100644 index 0000000..fce0ee5 --- /dev/null +++ b/internal/bot/mocks_test.go @@ -0,0 +1,165 @@ +package bot + +import ( + "context" + "fmt" + "time" +) + +// MockTurnClient is a programmable mock for TurnClient +type MockTurnClient struct { + // Programmable responses - map PR URL to response + Responses map[string]*CheckResponse + Errors map[string]error + + // Default response for unmapped PRs + DefaultResponse *CheckResponse + DefaultError error + + // Track calls + Calls []*TurnCall +} + +type TurnCall struct { + PRURL string + Username string + UpdatedAt time.Time +} + +func NewMockTurnClient() *MockTurnClient { + return &MockTurnClient{ + Responses: make(map[string]*CheckResponse), + Errors: make(map[string]error), + Calls: make([]*TurnCall, 0), + } +} + +func (m *MockTurnClient) Check(ctx context.Context, prURL, username string, updatedAt time.Time) (*CheckResponse, error) { + m.Calls = append(m.Calls, &TurnCall{ + PRURL: prURL, + Username: username, + UpdatedAt: updatedAt, + }) + + // Check for specific error + if err, ok := m.Errors[prURL]; ok { + return nil, err + } + + // Check for specific response + if resp, ok := m.Responses[prURL]; ok { + return resp, nil + } + + // Use default error if set + if m.DefaultError != nil { + return nil, m.DefaultError + } + + // Use default response if set + if m.DefaultResponse != nil { + return m.DefaultResponse, nil + } + + // Return empty response + return &CheckResponse{ + PullRequest: PRInfo{ + Title: "Default PR", + Author: username, + }, + Analysis: Analysis{}, + }, nil +} + +// SetResponse sets a specific response for a PR URL +func (m *MockTurnClient) SetResponse(prURL string, resp *CheckResponse) { + m.Responses[prURL] = resp +} + +// SetError sets a specific error for a PR URL +func (m *MockTurnClient) SetError(prURL string, err error) { + m.Errors[prURL] = err +} + +// NewMockCheckResponse creates a mock TURN API response +func NewMockCheckResponse(title, author string, merged, closed, draft bool) *CheckResponse { + return &CheckResponse{ + PullRequest: PRInfo{ + Title: title, + Author: author, + Merged: merged, + Closed: closed, + Draft: draft, + }, + Analysis: Analysis{ + WorkflowState: "active", + NextAction: make(map[string]Action), + }, + } +} + +// WithAction adds a next action to the response +func (r *CheckResponse) WithAction(username, kind string) *CheckResponse { + r.Analysis.NextAction[username] = Action{ + Kind: kind, + } + return r +} + +// WithState sets the workflow state +func (r *CheckResponse) WithState(state string) *CheckResponse { + r.Analysis.WorkflowState = state + return r +} + +// WithApproved sets the approved flag +func (r *CheckResponse) WithApproved(approved bool) *CheckResponse { + r.Analysis.Approved = approved + return r +} + +// WithChecks sets check status +func (r *CheckResponse) WithChecks(failing, pending, waiting int) *CheckResponse { + r.Analysis.Checks = Checks{ + Failing: failing, + Pending: pending, + Waiting: waiting, + } + return r +} + +// WithComments sets unresolved comments +func (r *CheckResponse) WithComments(count int) *CheckResponse { + r.Analysis.UnresolvedComments = count + return r +} + +// WithConflict sets merge conflict flag +func (r *CheckResponse) WithConflict(hasConflict bool) *CheckResponse { + r.Analysis.MergeConflict = hasConflict + return r +} + +// MockGitHubClient is a programmable mock for GitHub client operations +type MockGitHubClient struct { + PRSearchResults map[string][]PRSearchResult + SearchErrors map[string]error +} + +func NewMockGitHubClient() *MockGitHubClient { + return &MockGitHubClient{ + PRSearchResults: make(map[string][]PRSearchResult), + SearchErrors: make(map[string]error), + } +} + +// NewMockPRSearchResult creates a mock PR search result +func NewMockPRSearchResult(owner, repo string, number int, updatedAt time.Time) PRSearchResult { + return PRSearchResult{ + URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, number), + Owner: owner, + Repo: repo, + Number: number, + UpdatedAt: updatedAt, + } +} diff --git a/internal/discord/client_test.go b/internal/discord/client_test.go index 0eb9928..5693810 100644 --- a/internal/discord/client_test.go +++ b/internal/discord/client_test.go @@ -1,6 +1,7 @@ package discord import ( + "context" "strings" "testing" @@ -306,3 +307,180 @@ func TestIsAllDigits(t *testing.T) { }) } } + +// TestClient_ResolveChannelID_LooksLikeID tests ResolveChannelID when input looks like an ID. +func TestClient_ResolveChannelID_LooksLikeID(t *testing.T) { + client := &Client{ + guildID: "test-guild", + channelCache: make(map[string]string), + } + + // 20-character numeric string should be returned as-is (looks like a Discord ID) + channelID := "12345678901234567890" + got := client.ResolveChannelID(context.Background(), channelID) + + if got != channelID { + t.Errorf("ResolveChannelID(%q) = %q, want %q", channelID, got, channelID) + } +} + +// TestClient_ResolveChannelID_CacheHit tests ResolveChannelID with cached channel. +func TestClient_ResolveChannelID_CacheHit(t *testing.T) { + client := &Client{ + guildID: "test-guild", + channelCache: map[string]string{ + "general": "111222333444555666", + }, + } + + got := client.ResolveChannelID(context.Background(), "general") + want := "111222333444555666" + + if got != want { + t.Errorf("ResolveChannelID(\"general\") = %q, want %q", got, want) + } +} + +// TestClient_ResolveChannelID_NoGuildID tests ResolveChannelID when no guild ID is set. +func TestClient_ResolveChannelID_NoGuildID(t *testing.T) { + client := &Client{ + guildID: "", + channelCache: make(map[string]string), + } + + channelName := "general" + got := client.ResolveChannelID(context.Background(), channelName) + + // Should return the input unchanged when no guild ID is set + if got != channelName { + t.Errorf("ResolveChannelID(%q) = %q, want %q", channelName, got, channelName) + } +} + +// TestClient_ChannelType_CacheHit tests ChannelType with cached channel type. +func TestClient_ChannelType_CacheHit(t *testing.T) { + client := &Client{ + channelTypeCache: map[string]discordgo.ChannelType{ + "123456": discordgo.ChannelTypeGuildText, + }, + } + + got, err := client.ChannelType(context.Background(), "123456") + if err != nil { + t.Fatalf("ChannelType() error = %v, want nil", err) + } + + if got != discordgo.ChannelTypeGuildText { + t.Errorf("ChannelType() = %v, want %v", got, discordgo.ChannelTypeGuildText) + } +} + +// TestClient_IsBotInChannel_NilState tests IsBotInChannel when session state is nil. +func TestClient_IsBotInChannel_NilState(t *testing.T) { + client := &Client{ + session: &discordgo.Session{ + State: nil, + }, + } + + got := client.IsBotInChannel(context.Background(), "some-channel-id") + if got { + t.Error("IsBotInChannel() = true, want false when session.State is nil") + } +} + +// TestClient_IsBotInChannel_NilUser tests IsBotInChannel when user is nil. +func TestClient_IsBotInChannel_NilUser(t *testing.T) { + state := discordgo.NewState() + state.User = nil + + client := &Client{ + session: &discordgo.Session{ + State: state, + }, + } + + got := client.IsBotInChannel(context.Background(), "some-channel-id") + if got { + t.Error("IsBotInChannel() = true, want false when session.State.User is nil") + } +} + +// TestClient_IsUserInGuild_NoGuildID tests IsUserInGuild when no guild ID is set. +func TestClient_IsUserInGuild_NoGuildID(t *testing.T) { + client := &Client{ + guildID: "", + session: &discordgo.Session{}, + } + + got := client.IsUserInGuild(context.Background(), "user-123") + if got { + t.Error("IsUserInGuild() = true, want false when no guild ID is set") + } +} + +// TestClient_IsUserActive_NoGuildID tests IsUserActive when no guild ID is set. +func TestClient_IsUserActive_NoGuildID(t *testing.T) { + client := &Client{ + guildID: "", + session: &discordgo.Session{}, + } + + got := client.IsUserActive(context.Background(), "user-123") + if got { + t.Error("IsUserActive() = true, want false when no guild ID is set") + } +} + +// TestClient_GuildInfo_NoGuildID tests GuildInfo when no guild ID is set. +func TestClient_GuildInfo_NoGuildID(t *testing.T) { + client := &Client{ + guildID: "", + session: &discordgo.Session{}, + } + + _, err := client.GuildInfo(context.Background()) + if err == nil { + t.Error("GuildInfo() error = nil, want error when no guild ID is set") + } + if err != nil && err.Error() != "no guild ID set" { + t.Errorf("GuildInfo() error = %q, want %q", err.Error(), "no guild ID set") + } +} + +// TestClient_BotInfo_NilState tests BotInfo when session state is nil. +func TestClient_BotInfo_NilState(t *testing.T) { + client := &Client{ + session: &discordgo.Session{ + State: nil, + }, + } + + _, err := client.BotInfo(context.Background()) + if err == nil { + t.Error("BotInfo() error = nil, want error when session.State is nil") + } + if err != nil && err.Error() != "bot user not available" { + t.Errorf("BotInfo() error = %q, want %q", err.Error(), "bot user not available") + } +} + +// TestClient_BotInfo_NilUser tests BotInfo when user is nil. +func TestClient_BotInfo_NilUser(t *testing.T) { + state := discordgo.NewState() + state.User = nil + + client := &Client{ + session: &discordgo.Session{ + State: state, + }, + } + + _, err := client.BotInfo(context.Background()) + if err == nil { + t.Error("BotInfo() error = nil, want error when session.State.User is nil") + } + if err != nil && err.Error() != "bot user not available" { + t.Errorf("BotInfo() error = %q, want %q", err.Error(), "bot user not available") + } +} diff --git a/internal/discord/mocks_test.go b/internal/discord/mocks_test.go new file mode 100644 index 0000000..a7d3013 --- /dev/null +++ b/internal/discord/mocks_test.go @@ -0,0 +1,317 @@ +package discord + +import ( + "fmt" + "sync" + + "github.com/bwmarrin/discordgo" +) + +// MockSession is a programmable mock for discordgo.Session +type MockSession struct { + // Programmable responses + OpenError error + CloseError error + MessageSendError error + MessageEditError error + UserChannelError error + GuildMembersError error + ChannelError error + GuildChannelsError error + MessagesError error + ThreadsActiveError error + ApplicationCommandsError error + InteractionResponseError error + + // Storage for tracking calls + SentMessages []*sentMessage + EditedMessages []*editedMessage + CreatedChannels []string + Interactions []*discordgo.InteractionResponse + + // Mock data + Channels map[string]*discordgo.Channel + Members map[string][]*discordgo.Member + Messages map[string][]*discordgo.Message + ActiveThreads []*discordgo.Channel + Commands []*discordgo.ApplicationCommand + + mu sync.Mutex +} + +type sentMessage struct { + ChannelID string + Content string + Embed *discordgo.MessageEmbed +} + +type editedMessage struct { + ChannelID string + MessageID string + Content string + Embed *discordgo.MessageEmbed +} + +func NewMockSession() *MockSession { + return &MockSession{ + SentMessages: make([]*sentMessage, 0), + EditedMessages: make([]*editedMessage, 0), + Channels: make(map[string]*discordgo.Channel), + Members: make(map[string][]*discordgo.Member), + Messages: make(map[string][]*discordgo.Message), + Commands: make([]*discordgo.ApplicationCommand, 0), + } +} + +func (m *MockSession) Open() error { + return m.OpenError +} + +func (m *MockSession) Close() error { + return m.CloseError +} + +func (m *MockSession) ChannelMessageSend(channelID string, content string, options ...discordgo.RequestOption) (*discordgo.Message, error) { + if m.MessageSendError != nil { + return nil, m.MessageSendError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.SentMessages = append(m.SentMessages, &sentMessage{ + ChannelID: channelID, + Content: content, + }) + + msgID := fmt.Sprintf("msg-%d", len(m.SentMessages)) + return &discordgo.Message{ + ID: msgID, + ChannelID: channelID, + Content: content, + }, nil +} + +func (m *MockSession) ChannelMessageSendEmbed(channelID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) { + if m.MessageSendError != nil { + return nil, m.MessageSendError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.SentMessages = append(m.SentMessages, &sentMessage{ + ChannelID: channelID, + Embed: embed, + }) + + msgID := fmt.Sprintf("msg-%d", len(m.SentMessages)) + return &discordgo.Message{ + ID: msgID, + ChannelID: channelID, + Embeds: []*discordgo.MessageEmbed{embed}, + }, nil +} + +func (m *MockSession) ChannelMessageEdit(channelID, messageID string, content string, options ...discordgo.RequestOption) (*discordgo.Message, error) { + if m.MessageEditError != nil { + return nil, m.MessageEditError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.EditedMessages = append(m.EditedMessages, &editedMessage{ + ChannelID: channelID, + MessageID: messageID, + Content: content, + }) + + return &discordgo.Message{ + ID: messageID, + ChannelID: channelID, + Content: content, + }, nil +} + +func (m *MockSession) ChannelMessageEditEmbed(channelID, messageID string, embed *discordgo.MessageEmbed, options ...discordgo.RequestOption) (*discordgo.Message, error) { + if m.MessageEditError != nil { + return nil, m.MessageEditError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.EditedMessages = append(m.EditedMessages, &editedMessage{ + ChannelID: channelID, + MessageID: messageID, + Embed: embed, + }) + + return &discordgo.Message{ + ID: messageID, + ChannelID: channelID, + Embeds: []*discordgo.MessageEmbed{embed}, + }, nil +} + +func (m *MockSession) UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (*discordgo.Channel, error) { + if m.UserChannelError != nil { + return nil, m.UserChannelError + } + + m.mu.Lock() + defer m.mu.Unlock() + + channelID := fmt.Sprintf("dm-%s", recipientID) + m.CreatedChannels = append(m.CreatedChannels, channelID) + + return &discordgo.Channel{ + ID: channelID, + Type: discordgo.ChannelTypeDM, + }, nil +} + +func (m *MockSession) GuildMembers(guildID string, after string, limit int, options ...discordgo.RequestOption) ([]*discordgo.Member, error) { + if m.GuildMembersError != nil { + return nil, m.GuildMembersError + } + + if members, ok := m.Members[guildID]; ok { + return members, nil + } + + return []*discordgo.Member{}, nil +} + +func (m *MockSession) Channel(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error) { + if m.ChannelError != nil { + return nil, m.ChannelError + } + + if channel, ok := m.Channels[channelID]; ok { + return channel, nil + } + + return nil, fmt.Errorf("channel not found") +} + +func (m *MockSession) GuildChannels(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Channel, error) { + if m.GuildChannelsError != nil { + return nil, m.GuildChannelsError + } + + channels := make([]*discordgo.Channel, 0) + for _, ch := range m.Channels { + if ch.GuildID == guildID { + channels = append(channels, ch) + } + } + + return channels, nil +} + +func (m *MockSession) ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...discordgo.RequestOption) ([]*discordgo.Message, error) { + if m.MessagesError != nil { + return nil, m.MessagesError + } + + if messages, ok := m.Messages[channelID]; ok { + return messages, nil + } + + return []*discordgo.Message{}, nil +} + +func (m *MockSession) ThreadsActive(guildID string, options ...discordgo.RequestOption) (*discordgo.ThreadsList, error) { + if m.ThreadsActiveError != nil { + return nil, m.ThreadsActiveError + } + + return &discordgo.ThreadsList{ + Threads: m.ActiveThreads, + }, nil +} + +func (m *MockSession) ApplicationCommandBulkOverwrite(appID, guildID string, commands []*discordgo.ApplicationCommand, options ...discordgo.RequestOption) ([]*discordgo.ApplicationCommand, error) { + if m.ApplicationCommandsError != nil { + return nil, m.ApplicationCommandsError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.Commands = commands + return commands, nil +} + +func (m *MockSession) InteractionRespond(interaction *discordgo.Interaction, resp *discordgo.InteractionResponse, options ...discordgo.RequestOption) error { + if m.InteractionResponseError != nil { + return m.InteractionResponseError + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.Interactions = append(m.Interactions, resp) + return nil +} + +// Helper functions to set up mock data + +func (m *MockSession) AddChannel(channel *discordgo.Channel) { + m.mu.Lock() + defer m.mu.Unlock() + m.Channels[channel.ID] = channel +} + +func (m *MockSession) AddMember(guildID string, member *discordgo.Member) { + m.mu.Lock() + defer m.mu.Unlock() + m.Members[guildID] = append(m.Members[guildID], member) +} + +func (m *MockSession) AddMessage(channelID string, message *discordgo.Message) { + m.mu.Lock() + defer m.mu.Unlock() + m.Messages[channelID] = append(m.Messages[channelID], message) +} + +func (m *MockSession) AddActiveThread(thread *discordgo.Channel) { + m.mu.Lock() + defer m.mu.Unlock() + m.ActiveThreads = append(m.ActiveThreads, thread) +} + +// NewMockChannel creates a mock Discord channel +func NewMockChannel(id, name, guildID string, channelType discordgo.ChannelType) *discordgo.Channel { + return &discordgo.Channel{ + ID: id, + Name: name, + GuildID: guildID, + Type: channelType, + } +} + +// NewMockMember creates a mock Discord guild member +func NewMockMember(userID, username, globalName string) *discordgo.Member { + return &discordgo.Member{ + User: &discordgo.User{ + ID: userID, + Username: username, + GlobalName: globalName, + }, + } +} + +// NewMockMessage creates a mock Discord message +func NewMockMessage(id, channelID, content, authorID string) *discordgo.Message { + return &discordgo.Message{ + ID: id, + ChannelID: channelID, + Content: content, + Author: &discordgo.User{ + ID: authorID, + }, + } +} diff --git a/internal/github/client_integration_test.go b/internal/github/client_integration_test.go new file mode 100644 index 0000000..7dd5100 --- /dev/null +++ b/internal/github/client_integration_test.go @@ -0,0 +1,265 @@ +package github + +import ( + "context" + "testing" + "time" +) + +// TestAppClient_ClientForOrg tests getting a client for an organization +func TestAppClient_ClientForOrg(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + t.Run("jwtClient", func(t *testing.T) { + client, err := NewAppClient("123", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + ctx := context.Background() + ghClient := client.jwtClient(ctx) + if ghClient == nil { + t.Error("jwtClient() returned nil") + } + }) +} + +// TestAppClient_InstallationToken tests getting installation tokens +func TestAppClient_InstallationToken(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + t.Run("token cached and not expired", func(t *testing.T) { + client, err := NewAppClient("123", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + // Pre-populate cache with a valid token + installationID := int64(456) + client.tokens[installationID] = &tokenEntry{ + token: "cached-token", + expiresAt: time.Now().Add(1 * time.Hour), + } + + ctx := context.Background() + token, err := client.installationToken(ctx, installationID) + if err != nil { + t.Errorf("installationToken() error = %v, want nil", err) + } + if token != "cached-token" { + t.Errorf("installationToken() = %v, want cached-token", token) + } + }) + + t.Run("org cached", func(t *testing.T) { + client, err := NewAppClient("123", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + // Pre-populate installations cache + client.installations["test-org"] = int64(789) + + ctx := context.Background() + id, err := client.installationID(ctx, "test-org") + if err != nil { + t.Errorf("installationID() error = %v, want nil", err) + } + if id != 789 { + t.Errorf("installationID() = %v, want 789", id) + } + }) +} + +// TestAppClient_GenerateJWT tests JWT generation +func TestAppClient_GenerateJWT(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + client, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + jwt := client.generateJWT() + if jwt == "" { + t.Error("generateJWT() returned empty string") + } +} + +// TestAppClient_HTTPClient tests getting an HTTP client +func TestAppClient_HTTPClient(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + client, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + // Pre-populate caches to avoid network calls + client.installations["test-org"] = int64(123) + client.tokens[123] = &tokenEntry{ + token: "test-token", + expiresAt: time.Now().Add(1 * time.Hour), + } + + ctx := context.Background() + httpClient, err := client.HTTPClient(ctx, "test-org") + if err != nil { + t.Errorf("HTTPClient() error = %v, want nil", err) + } + if httpClient == nil { + t.Error("HTTPClient() returned nil client") + } +} + +// TestSearcher_NewSearcher tests searcher creation +func TestSearcher_NewSearcher(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + appClient, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + t.Run("with logger", func(t *testing.T) { + searcher := NewSearcher(appClient, nil) + if searcher == nil { + t.Error("NewSearcher() returned nil") + } + }) +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 8f0d859..3c1aede 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -19,4 +19,56 @@ func TestNewAppClient(t *testing.T) { t.Error("NewAppClient() error = nil, want error for empty key") } }) + + t.Run("valid RSA key", func(t *testing.T) { + // Valid 2048-bit RSA private key in PKCS8 format for testing + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + client, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Errorf("NewAppClient() error = %v, want nil", err) + } + if client == nil { + t.Fatal("NewAppClient() returned nil client") + } + if client.appID != "12345" { + t.Errorf("NewAppClient() appID = %s, want 12345", client.appID) + } + if client.logger == nil { + t.Error("NewAppClient() logger should default to slog.Default()") + } + if client.tokens == nil { + t.Error("NewAppClient() tokens map should be initialized") + } + if client.installations == nil { + t.Error("NewAppClient() installations map should be initialized") + } + }) } diff --git a/internal/github/mocks_test.go b/internal/github/mocks_test.go new file mode 100644 index 0000000..d9246f2 --- /dev/null +++ b/internal/github/mocks_test.go @@ -0,0 +1,95 @@ +package github + +import ( + "context" + "fmt" + "time" + + "github.com/google/go-github/v50/github" +) + +// MockGitHubAppsService mocks the GitHub Apps API +type MockGitHubAppsService struct { + Installations []*github.Installation + InstallationTokens map[int64]string + ListInstallsError error + CreateTokenError error +} + +func (m *MockGitHubAppsService) ListInstallations(ctx context.Context, opts *github.ListOptions) ([]*github.Installation, *github.Response, error) { + if m.ListInstallsError != nil { + return nil, nil, m.ListInstallsError + } + return m.Installations, &github.Response{}, nil +} + +func (m *MockGitHubAppsService) CreateInstallationToken(ctx context.Context, id int64, opts *github.InstallationTokenOptions) (*github.InstallationToken, *github.Response, error) { + if m.CreateTokenError != nil { + return nil, nil, m.CreateTokenError + } + + token := "ghs_mock_token" + if m.InstallationTokens != nil { + if t, ok := m.InstallationTokens[id]; ok { + token = t + } + } + + expiresAt := github.Timestamp{Time: time.Now().Add(1 * time.Hour)} + return &github.InstallationToken{ + Token: &token, + ExpiresAt: &expiresAt, + }, &github.Response{}, nil +} + +// MockSearchService mocks the GitHub Search API +type MockSearchService struct { + IssueResults map[string]*github.IssuesSearchResult + SearchError error +} + +func (m *MockSearchService) Issues(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, *github.Response, error) { + if m.SearchError != nil { + return nil, nil, m.SearchError + } + + if m.IssueResults != nil { + if result, ok := m.IssueResults[query]; ok { + return result, &github.Response{}, nil + } + } + + // Default empty result + return &github.IssuesSearchResult{ + Total: github.Int(0), + Issues: []*github.Issue{}, + }, &github.Response{}, nil +} + +// NewMockInstallation creates a mock GitHub installation +func NewMockInstallation(id int64, login, accountType string) *github.Installation { + return &github.Installation{ + ID: &id, + Account: &github.User{ + Login: &login, + Type: &accountType, + }, + } +} + +// NewMockPRIssue creates a mock GitHub issue representing a PR +func NewMockPRIssue(owner, repo string, number int, title string) *github.Issue { + repoURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) + htmlURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, number) + updatedAt := github.Timestamp{Time: time.Now()} + + return &github.Issue{ + Number: &number, + Title: &title, + RepositoryURL: &repoURL, + UpdatedAt: &updatedAt, + PullRequestLinks: &github.PullRequestLinks{ + HTMLURL: &htmlURL, + }, + } +} diff --git a/internal/github/simple_unit_test.go b/internal/github/simple_unit_test.go new file mode 100644 index 0000000..c6438a2 --- /dev/null +++ b/internal/github/simple_unit_test.go @@ -0,0 +1,161 @@ +package github + +import ( + "testing" + "time" +) + +// TestTokenEntry_Expiration tests token expiration logic +func TestTokenEntry_Expiration(t *testing.T) { + tests := []struct { + name string + expiresAt time.Time + wantExpired bool + }{ + { + name: "token expired", + expiresAt: time.Now().Add(-1 * time.Hour), + wantExpired: true, + }, + { + name: "token valid for 1 hour", + expiresAt: time.Now().Add(1 * time.Hour), + wantExpired: false, + }, + { + name: "token expiring soon (within buffer)", + expiresAt: time.Now().Add(3 * time.Minute), + wantExpired: true, // within tokenRefreshBuffer + }, + { + name: "token valid beyond buffer", + expiresAt: time.Now().Add(10 * time.Minute), + wantExpired: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entry := &tokenEntry{ + token: "test-token", + expiresAt: tt.expiresAt, + } + + // Check if token is considered expired (needs refresh) + needsRefresh := time.Until(entry.expiresAt) <= tokenRefreshBuffer + + if needsRefresh != tt.wantExpired { + t.Errorf("token expiration check = %v, want %v", needsRefresh, tt.wantExpired) + } + }) + } +} + +// TestAppClient_CacheInitialization tests that caches are properly initialized +func TestAppClient_CacheInitialization(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + client, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + if client.tokens == nil { + t.Error("NewAppClient() tokens map is nil, want initialized map") + } + + if client.installations == nil { + t.Error("NewAppClient() installations map is nil, want initialized map") + } + + if client.appID != "12345" { + t.Errorf("NewAppClient() appID = %v, want 12345", client.appID) + } + + if client.logger == nil { + t.Error("NewAppClient() logger is nil, want default logger") + } +} + +// TestAppClient_ManualCacheOperations tests manual cache manipulation +func TestAppClient_ManualCacheOperations(t *testing.T) { + validKey := `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmVYzm/VqCi5Vt +rposZRASD5hzAxJnku3XVmxnHUhfO6UjsVph2NIh3/3XkMxM0C2c185d/P4iGtTZ +SAmw0c9E1cGd1sT3G4wH50Bw9+cSNMSnIKFU98KMdMlN2D/HaJnZOKtSnl6yT22/ +cx/AzkYBD0NBWeCLQfAmK7Unyg7/vH8U62ZBzJ1pTpEarLQ2WtEUUseRg498/EsX +fEgrL/vydtRJCvIHj3IQhtbSRrd2Ii3QcUhQhtxH4ea2CO3+vVOfAZKQIOL/xF7L +fc0A/osEoEvB9jZogTfHU9xGK7VToTb3nBxR4Sc/ZX9gqCQ5jb5au0i0K31jZ/Tp +I5Hf6KoFAgMBAAECggEAFe7+D4+lGcXSRI5bojMJdXg9AB2Nlb7YQicRUF+aJYS1 ++AjxBCoVO4ZP8NcVOaPR//atLdOop1Kmcqh/LqPcExWk3G1vt64YPwqNgtgNzmbK +78brv0qUivTzfqJfdqoib3R7kv9zOUwkCrThoQkSTh13Huz9IR/mzQHCd6a7Z5l6 +wgo9JU3B4JXviBjV2CcpYspgsMkUzAbjMIdUBaECg7OfNeBAd0yZVt2HI9+jyn44 +gakARkzA8kwQUrPYY5L/BrPDqzS1UShLgFAUaxY5P4wceSWSZcnUT0HvW+yNaJ6C +AUu722Ux5Wjz7TlD31VWbql9KzZd+rLiSUNPdrp0WQKBgQDqjMo4cXC1tAZmngZS +vHAT++BSeFOt16j7QcQV3fm6EALOFVNruNCemLb1IYgmiaIJW0JVGy4dC7tYFDfD +SLumICK1DOiYIepQJGmIHF+E54v4KTMfut82j/5uHpflEtWcaEE9Hl5rCgh1/VDt +jzah/oMB4Xw3ey9iZ+p8shn7lwKBgQC1i7bMTy1Nma3T7Z7MkBqulO3Sb1Y5xTV5 +rfNOpuEO2mRMEUB9fkm86U0CDmN01mbQoPr+XgSU1+CR3i7rkolkN8CjmdcsaxrL +CRVur5PRCU9z936OE7TIXhKzmDSvVk3OlVi0c6R3hmLcxVtUCJBofaL7np8ffANX +MhU3t8rqwwKBgEz15WSf1FvKtk71ix2atyvXecOVt99S5B+NdMm4DDkBB+qXFMhD +3DAt69qDJil+/6wSRbGnOXpOXyqHd8ScGPZplPnTQn6oojmpuPbwWGdDkqna2uuO +Za+Bj/qSD0Ua6PxpOP7U+CYnJJ+Sfvt0AnklCdeUJS4PPX0Mm+ROjDgBAoGBAI4e +WXOHaAefjpyhH/czuC+DFsntrqp632np6tZffT+LZ4jE2J9lBYSFfmtlqCYG0WXx +H4uRPjTm6j5GmKSBilyR6JQqEnALSGY5LjX/7M9vYmt+C+xdMODKBAnj1RqNjUtz +ToW1IcMPyMTbGqumKKYj9DrV6etTwam44zNDBe7RAoGBANDbD7/IqqSe91Ip2Ya3 +O+mpNiewSI6q/KY4pp6IARwpQPzDWpHlm1/aEncnVpASdekW35VEnuZCW3hbetgo +bxOazQxSjsZ+wfQNMfqsn4uD9qjcGI2oyC/U+FLw7f07X/CrBldF5F6rV3u7OLgP +XPSfScsFYQhv99Qo4yLJceaK +-----END PRIVATE KEY-----` + + client, err := NewAppClient("12345", validKey, nil) + if err != nil { + t.Fatalf("NewAppClient() error = %v", err) + } + + t.Run("add installation to cache", func(t *testing.T) { + client.installations["cached-org"] = int64(999) + + if id, ok := client.installations["cached-org"]; !ok || id != 999 { + t.Errorf("installations cache[cached-org] = %v, want 999", id) + } + }) + + t.Run("add token to cache", func(t *testing.T) { + client.tokens[999] = &tokenEntry{ + token: "cached-token", + expiresAt: time.Now().Add(1 * time.Hour), + } + + if entry, ok := client.tokens[999]; !ok || entry.token != "cached-token" { + t.Errorf("tokens cache[999] = %v, want cached-token", entry) + } + }) +}