|
| 1 | +package bot |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "strings" |
| 6 | + "testing" |
| 7 | + "time" |
| 8 | + |
| 9 | + ghmailto "github.com/codeGROOVE-dev/gh-mailto/pkg/gh-mailto" |
| 10 | + "github.com/codeGROOVE-dev/slacker/internal/notify" |
| 11 | + "github.com/codeGROOVE-dev/slacker/internal/slack" |
| 12 | + "github.com/codeGROOVE-dev/slacker/internal/slacktest" |
| 13 | + "github.com/codeGROOVE-dev/slacker/internal/usermapping" |
| 14 | + "github.com/codeGROOVE-dev/turnclient/pkg/turn" |
| 15 | + slackapi "github.com/slack-go/slack" |
| 16 | +) |
| 17 | + |
| 18 | +// TestUserMappingIntegration tests the complete flow of mapping GitHub users to Slack users. |
| 19 | +func TestUserMappingIntegration(t *testing.T) { |
| 20 | + ctx := context.Background() |
| 21 | + |
| 22 | + // Setup mock Slack server |
| 23 | + mockSlack := slacktest.New() |
| 24 | + defer mockSlack.Close() |
| 25 | + |
| 26 | + // Add test users |
| 27 | + mockSlack. AddUser( "[email protected]", "U001", "alice") |
| 28 | + mockSlack. AddUser( "[email protected]", "U002", "bob") |
| 29 | + |
| 30 | + // Create Slack client pointing to mock server |
| 31 | + slackClient := slackapi.New("test-token", slackapi.OptionAPIURL(mockSlack.URL+"/api/")) |
| 32 | + |
| 33 | + // Create mock GitHub email lookup |
| 34 | + mockGitHub := &mockGitHubLookup{ |
| 35 | + emails: map[string][]string{ |
| 36 | + |
| 37 | + |
| 38 | + "charlie": { "[email protected]"}, // Won't be found in Slack |
| 39 | + }, |
| 40 | + } |
| 41 | + |
| 42 | + // Create user mapper |
| 43 | + userMapper := usermapping.NewForTesting(slackClient, mockGitHub) |
| 44 | + |
| 45 | + tests := []struct { |
| 46 | + name string |
| 47 | + githubUsername string |
| 48 | + wantSlackID string |
| 49 | + wantErr bool |
| 50 | + }{ |
| 51 | + { |
| 52 | + name: "successful mapping with single email", |
| 53 | + githubUsername: "alice", |
| 54 | + wantSlackID: "U001", |
| 55 | + wantErr: false, |
| 56 | + }, |
| 57 | + { |
| 58 | + name: "successful mapping with multiple emails", |
| 59 | + githubUsername: "bob", |
| 60 | + wantSlackID: "U002", |
| 61 | + wantErr: false, |
| 62 | + }, |
| 63 | + { |
| 64 | + name: "no mapping - email not in Slack", |
| 65 | + githubUsername: "charlie", |
| 66 | + wantSlackID: "", |
| 67 | + wantErr: false, // Not an error, just no mapping |
| 68 | + }, |
| 69 | + { |
| 70 | + name: "no mapping - no GitHub emails found", |
| 71 | + githubUsername: "unknown", |
| 72 | + wantSlackID: "", |
| 73 | + wantErr: false, |
| 74 | + }, |
| 75 | + } |
| 76 | + |
| 77 | + for _, tt := range tests { |
| 78 | + t.Run(tt.name, func(t *testing.T) { |
| 79 | + slackID, err := userMapper.SlackHandle(ctx, tt.githubUsername, "test-org", "example.com") |
| 80 | + |
| 81 | + if (err != nil) != tt.wantErr { |
| 82 | + t.Errorf("SlackHandle() error = %v, wantErr %v", err, tt.wantErr) |
| 83 | + } |
| 84 | + |
| 85 | + if slackID != tt.wantSlackID { |
| 86 | + t.Errorf("SlackHandle() = %v, want %v", slackID, tt.wantSlackID) |
| 87 | + } |
| 88 | + }) |
| 89 | + } |
| 90 | + |
| 91 | + // Verify email lookups were performed |
| 92 | + emailLookups := mockSlack.GetEmailLookups() |
| 93 | + if len(emailLookups) < 3 { |
| 94 | + t.Errorf("Expected at least 3 email lookups, got %d", len(emailLookups)) |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +// TestTurnclientEmojiIntegration tests the complete emoji determination pipeline. |
| 99 | +func TestTurnclientEmojiIntegration(t *testing.T) { |
| 100 | + tests := []struct { |
| 101 | + name string |
| 102 | + workflowState string |
| 103 | + nextActions map[string]turn.Action |
| 104 | + expectedEmoji string |
| 105 | + expectedAction string |
| 106 | + description string |
| 107 | + }{ |
| 108 | + { |
| 109 | + name: "newly published takes precedence", |
| 110 | + workflowState: "newly_published", |
| 111 | + nextActions: map[string]turn.Action{ |
| 112 | + "author": {Kind: turn.ActionFixTests}, |
| 113 | + }, |
| 114 | + expectedEmoji: ":new:", |
| 115 | + expectedAction: "fix_tests", |
| 116 | + description: "newly_published state should always show :new: even with other actions", |
| 117 | + }, |
| 118 | + { |
| 119 | + name: "draft needs publishing", |
| 120 | + workflowState: "awaiting_action", |
| 121 | + nextActions: map[string]turn.Action{ |
| 122 | + "author": {Kind: turn.ActionPublishDraft}, |
| 123 | + }, |
| 124 | + expectedEmoji: ":construction:", |
| 125 | + expectedAction: "publish_draft", |
| 126 | + description: "unpublished draft should show construction emoji", |
| 127 | + }, |
| 128 | + { |
| 129 | + name: "tests broken has highest action priority", |
| 130 | + workflowState: "blocked", |
| 131 | + nextActions: map[string]turn.Action{ |
| 132 | + "author": {Kind: turn.ActionFixTests}, |
| 133 | + "reviewer": {Kind: turn.ActionReview}, |
| 134 | + }, |
| 135 | + expectedEmoji: ":cockroach:", |
| 136 | + expectedAction: "fix_tests", |
| 137 | + description: "fix_tests should take priority over review", |
| 138 | + }, |
| 139 | + { |
| 140 | + name: "awaiting review", |
| 141 | + workflowState: "awaiting_review", |
| 142 | + nextActions: map[string]turn.Action{ |
| 143 | + "reviewer": {Kind: turn.ActionReview}, |
| 144 | + }, |
| 145 | + expectedEmoji: ":hourglass:", |
| 146 | + expectedAction: "review", |
| 147 | + description: "review action should show hourglass", |
| 148 | + }, |
| 149 | + { |
| 150 | + name: "ready to merge", |
| 151 | + workflowState: "approved", |
| 152 | + nextActions: map[string]turn.Action{ |
| 153 | + "author": {Kind: turn.ActionMerge}, |
| 154 | + }, |
| 155 | + expectedEmoji: ":rocket:", |
| 156 | + expectedAction: "merge", |
| 157 | + description: "merge action should show rocket", |
| 158 | + }, |
| 159 | + { |
| 160 | + name: "merged with no actions shows fallback", |
| 161 | + workflowState: "merged", |
| 162 | + nextActions: map[string]turn.Action{}, |
| 163 | + expectedEmoji: ":postal_horn:", |
| 164 | + expectedAction: "", |
| 165 | + description: "This is a known bug - merged state with no actions falls back to postal_horn", |
| 166 | + }, |
| 167 | + { |
| 168 | + name: "multiple reviewers prioritized correctly", |
| 169 | + workflowState: "awaiting_review", |
| 170 | + nextActions: map[string]turn.Action{ |
| 171 | + "alice": {Kind: turn.ActionReview}, |
| 172 | + "bob": {Kind: turn.ActionApprove}, |
| 173 | + "carol": {Kind: turn.ActionReview}, |
| 174 | + }, |
| 175 | + expectedEmoji: ":hourglass:", |
| 176 | + expectedAction: "review", |
| 177 | + description: "review has lower priority number (6) than approve (7)", |
| 178 | + }, |
| 179 | + } |
| 180 | + |
| 181 | + for _, tt := range tests { |
| 182 | + t.Run(tt.name, func(t *testing.T) { |
| 183 | + // Test emoji determination |
| 184 | + emoji := notify.PrefixForAnalysis(tt.workflowState, tt.nextActions) |
| 185 | + if emoji != tt.expectedEmoji { |
| 186 | + t.Errorf("%s: PrefixForAnalysis() = %q, want %q", |
| 187 | + tt.description, emoji, tt.expectedEmoji) |
| 188 | + } |
| 189 | + |
| 190 | + // Test primary action extraction |
| 191 | + primaryAction := notify.PrimaryAction(tt.nextActions) |
| 192 | + if primaryAction != tt.expectedAction { |
| 193 | + t.Errorf("%s: PrimaryAction() = %q, want %q", |
| 194 | + tt.description, primaryAction, tt.expectedAction) |
| 195 | + } |
| 196 | + |
| 197 | + // Verify consistency: if there's a primary action, its emoji should match |
| 198 | + // (except for newly_published and merged edge cases) |
| 199 | + if tt.workflowState != "newly_published" && tt.workflowState != "merged" && tt.expectedAction != "" { |
| 200 | + actionEmoji := notify.PrefixForAction(tt.expectedAction) |
| 201 | + if emoji != actionEmoji { |
| 202 | + t.Errorf("%s: emoji mismatch - PrefixForAnalysis=%q but PrefixForAction(%q)=%q", |
| 203 | + tt.description, emoji, tt.expectedAction, actionEmoji) |
| 204 | + } |
| 205 | + } |
| 206 | + }) |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +// TestDMDelayLogicIntegration tests the DM delay decision logic. |
| 211 | +func TestDMDelayLogicIntegration(t *testing.T) { |
| 212 | + ctx := context.Background() |
| 213 | + |
| 214 | + // Setup mock Slack server |
| 215 | + mockSlack := slacktest.New() |
| 216 | + defer mockSlack.Close() |
| 217 | + |
| 218 | + mockSlack. AddUser( "[email protected]", "U001", "alice") |
| 219 | + mockSlack.AddChannel("C123", "dev", true) |
| 220 | + mockSlack.AddChannelMember("C123", "U001") // alice is in channel |
| 221 | + mockSlack.AddChannel("C456", "other", true) |
| 222 | + // alice is NOT in C456 |
| 223 | + |
| 224 | + slackClient := slackapi.New("test-token", slackapi.OptionAPIURL(mockSlack.URL+"/api/")) |
| 225 | + |
| 226 | + // Create notification manager |
| 227 | + slackManager := slack.NewManager("") |
| 228 | + slackManager.RegisterWorkspace(ctx, "test-workspace", slackClient) |
| 229 | + |
| 230 | + configMgr := &mockConfigManager{ |
| 231 | + dmDelay: 65, // 65 minute delay |
| 232 | + } |
| 233 | + |
| 234 | + notifier := notify.New(slackManager, configMgr) |
| 235 | + |
| 236 | + prInfo := notify.PRInfo{ |
| 237 | + Owner: "test", |
| 238 | + Repo: "repo", |
| 239 | + Number: 1, |
| 240 | + Title: "Test PR", |
| 241 | + Author: "author", |
| 242 | + State: "awaiting_review", |
| 243 | + HTMLURL: "https://github.com/test/repo/pull/1", |
| 244 | + WorkflowState: "awaiting_review", |
| 245 | + NextAction: map[string]turn.Action{ |
| 246 | + "alice": {Kind: turn.ActionReview}, |
| 247 | + }, |
| 248 | + } |
| 249 | + |
| 250 | + tests := []struct { |
| 251 | + name string |
| 252 | + setupFunc func() |
| 253 | + channelID string |
| 254 | + expectDM bool |
| 255 | + description string |
| 256 | + }{ |
| 257 | + { |
| 258 | + name: "user in channel - delay should apply", |
| 259 | + setupFunc: func() { |
| 260 | + notifier.Tracker.UpdateUserPRChannelTag("test-workspace", "U001", "C123", "test", "repo", 1) |
| 261 | + }, |
| 262 | + channelID: "C123", |
| 263 | + expectDM: false, |
| 264 | + description: "User is in channel where tagged - DM should be delayed", |
| 265 | + }, |
| 266 | + { |
| 267 | + name: "user not in channel - immediate DM", |
| 268 | + setupFunc: func() { |
| 269 | + notifier.Tracker.UpdateUserPRChannelTag("test-workspace", "U001", "C456", "test", "repo", 1) |
| 270 | + }, |
| 271 | + channelID: "C456", |
| 272 | + expectDM: true, |
| 273 | + description: "User is NOT in channel where tagged - DM should be immediate", |
| 274 | + }, |
| 275 | + { |
| 276 | + name: "no channel tag - immediate DM", |
| 277 | + setupFunc: func() { |
| 278 | + // Don't record any channel tag |
| 279 | + }, |
| 280 | + channelID: "", |
| 281 | + expectDM: true, |
| 282 | + description: "No channel tag recorded - DM should be immediate", |
| 283 | + }, |
| 284 | + } |
| 285 | + |
| 286 | + for _, tt := range tests { |
| 287 | + t.Run(tt.name, func(t *testing.T) { |
| 288 | + // Reset state |
| 289 | + mockSlack.Reset() |
| 290 | + |
| 291 | + // Create fresh tracker with initialized maps |
| 292 | + notifier.Tracker = ¬ify.NotificationTracker{} |
| 293 | + // Initialize the tracker by creating a new notifier |
| 294 | + notifier = notify.New(slackManager, configMgr) |
| 295 | + |
| 296 | + // Setup test scenario |
| 297 | + if tt.setupFunc != nil { |
| 298 | + tt.setupFunc() |
| 299 | + } |
| 300 | + |
| 301 | + // Execute |
| 302 | + err := notifier.NotifyUser(ctx, "test-workspace", "U001", tt.channelID, "dev", prInfo) |
| 303 | + if err != nil { |
| 304 | + t.Errorf("NotifyUser() error = %v", err) |
| 305 | + } |
| 306 | + |
| 307 | + // Give async operations time to complete |
| 308 | + time.Sleep(50 * time.Millisecond) |
| 309 | + |
| 310 | + // Verify |
| 311 | + postedMessages := mockSlack.GetPostedMessages() |
| 312 | + dmCount := 0 |
| 313 | + for _, msg := range postedMessages { |
| 314 | + if strings.HasPrefix(msg.Channel, "D") { |
| 315 | + dmCount++ |
| 316 | + } |
| 317 | + } |
| 318 | + |
| 319 | + if tt.expectDM && dmCount == 0 { |
| 320 | + t.Errorf("%s: expected DM to be sent, but none were sent", tt.description) |
| 321 | + } |
| 322 | + |
| 323 | + if !tt.expectDM && dmCount > 0 { |
| 324 | + t.Errorf("%s: expected no DM (delay should apply), but %d DMs were sent", tt.description, dmCount) |
| 325 | + } |
| 326 | + }) |
| 327 | + } |
| 328 | +} |
| 329 | + |
| 330 | +// Mock implementations for testing |
| 331 | + |
| 332 | +type mockGitHubLookup struct { |
| 333 | + emails map[string][]string |
| 334 | +} |
| 335 | + |
| 336 | +func (m *mockGitHubLookup) Lookup(ctx context.Context, username, organization string) (*ghmailto.Result, error) { |
| 337 | + emails, exists := m.emails[username] |
| 338 | + if !exists { |
| 339 | + return &ghmailto.Result{ |
| 340 | + Username: username, |
| 341 | + Addresses: []ghmailto.Address{}, |
| 342 | + }, nil |
| 343 | + } |
| 344 | + |
| 345 | + addresses := make([]ghmailto.Address, len(emails)) |
| 346 | + for i, email := range emails { |
| 347 | + addresses[i] = ghmailto.Address{ |
| 348 | + Email: email, |
| 349 | + Methods: []string{"Test Mock"}, |
| 350 | + } |
| 351 | + } |
| 352 | + |
| 353 | + return &ghmailto.Result{ |
| 354 | + Username: username, |
| 355 | + Addresses: addresses, |
| 356 | + }, nil |
| 357 | +} |
| 358 | + |
| 359 | +func (m *mockGitHubLookup) Guess(ctx context.Context, username, organization string, opts ghmailto.GuessOptions) (*ghmailto.GuessResult, error) { |
| 360 | + return &ghmailto.GuessResult{ |
| 361 | + Username: username, |
| 362 | + Guesses: []ghmailto.Address{}, |
| 363 | + }, nil |
| 364 | +} |
| 365 | + |
| 366 | +type mockConfigManager struct { |
| 367 | + dmDelay int |
| 368 | +} |
| 369 | + |
| 370 | +func (m *mockConfigManager) DailyRemindersEnabled(org string) bool { |
| 371 | + return false |
| 372 | +} |
| 373 | + |
| 374 | +func (m *mockConfigManager) ReminderDMDelay(org, channel string) int { |
| 375 | + return m.dmDelay |
| 376 | +} |
0 commit comments