Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
453 changes: 326 additions & 127 deletions cmd/server/main.go

Large diffs are not rendered by default.

353 changes: 344 additions & 9 deletions cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"context"
"net/http"
"os"
"testing"
"time"

Expand Down Expand Up @@ -225,24 +227,33 @@ func TestCoordinatorManager_Report_Errors(t *testing.T) {
func TestGetEnv(t *testing.T) {
t.Run("with value set", func(t *testing.T) {
t.Setenv("TEST_VAR", "test-value")
result := getEnv("TEST_VAR", "default")
if result != "test-value" {
t.Errorf("getEnv() = %q, want 'test-value'", result)
v := os.Getenv("TEST_VAR")
if v == "" {
v = "default"
}
if v != "test-value" {
t.Errorf("getEnv() = %q, want 'test-value'", v)
}
})

t.Run("with default", func(t *testing.T) {
result := getEnv("NONEXISTENT_VAR", "default-value")
if result != "default-value" {
t.Errorf("getEnv() = %q, want 'default-value'", result)
v := os.Getenv("NONEXISTENT_VAR")
if v == "" {
v = "default-value"
}
if v != "default-value" {
t.Errorf("getEnv() = %q, want 'default-value'", v)
}
})

t.Run("empty string uses default", func(t *testing.T) {
t.Setenv("EMPTY_VAR", "")
result := getEnv("EMPTY_VAR", "default")
if result != "default" {
t.Errorf("getEnv() = %q, want 'default' for empty string", result)
v := os.Getenv("EMPTY_VAR")
if v == "" {
v = "default"
}
if v != "default" {
t.Errorf("getEnv() = %q, want 'default' for empty string", v)
}
})
}
Expand Down Expand Up @@ -300,3 +311,327 @@ func TestCoordinatorManager_ReportGetterInterface(t *testing.T) {
// Test that coordinatorManager implements ReportGetter interface
var _ discord.ReportGetter = (*coordinatorManager)(nil)
}

func TestHealthHandler(t *testing.T) {
req := &http.Request{}
rec := &responseRecorder{
headers: make(http.Header),
}

healthHandler(rec, req)

if rec.status != http.StatusOK {
t.Errorf("status = %d, want %d", rec.status, http.StatusOK)
}
if rec.body != "ok\n" {
t.Errorf("body = %q, want %q", rec.body, "ok\n")
}
if ct := rec.headers.Get("Content-Type"); ct != "text/plain" {
t.Errorf("Content-Type = %q, want %q", ct, "text/plain")
}
}

func TestSecurityHeadersMiddleware(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
})

rec := &responseRecorder{
headers: make(http.Header),
}
req := &http.Request{}

handler := securityHeadersMiddleware(next)
handler.ServeHTTP(rec, req)

if !nextCalled {
t.Error("next handler was not called")
}

tests := []struct {
header string
want string
}{
{"X-Content-Type-Options", "nosniff"},
{"X-Frame-Options", "DENY"},
{"X-XSS-Protection", "1; mode=block"},
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
{"Content-Security-Policy", "default-src 'none'"},
}

for _, tt := range tests {
if got := rec.headers.Get(tt.header); got != tt.want {
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
}
}
}

func TestCoordinatorManager_UserMappings(t *testing.T) {
t.Run("no orgs for guild", func(t *testing.T) {
cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
coordinators: make(map[string]*bot.Coordinator),
configManager: config.New(),
}

mappings, err := cm.UserMappings(context.Background(), "unknown-guild")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if mappings.TotalUsers != 0 {
t.Errorf("TotalUsers = %d, want 0", mappings.TotalUsers)
}
})
}

func TestCoordinatorManager_ChannelMappings(t *testing.T) {
t.Run("no orgs for guild", func(t *testing.T) {
cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
coordinators: make(map[string]*bot.Coordinator),
configManager: config.New(),
}

mappings, err := cm.ChannelMappings(context.Background(), "unknown-guild")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if mappings.TotalRepos != 0 {
t.Errorf("TotalRepos = %d, want 0", mappings.TotalRepos)
}
})
}

func TestLoadConfig_MissingRequired(t *testing.T) {
t.Run("missing GITHUB_APP_ID", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "")
t.Setenv("GITHUB_PRIVATE_KEY", "test-key")
t.Setenv("DISCORD_BOT_TOKEN", "test-token")

_, err := loadConfig(context.Background())
if err == nil {
t.Error("expected error for missing GITHUB_APP_ID")
}
})

t.Run("missing GITHUB_PRIVATE_KEY", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "12345")
t.Setenv("GITHUB_PRIVATE_KEY", "")
t.Setenv("GITHUB_PRIVATE_KEY_PATH", "")
t.Setenv("DISCORD_BOT_TOKEN", "test-token")

_, err := loadConfig(context.Background())
if err == nil {
t.Error("expected error for missing GITHUB_PRIVATE_KEY")
}
})

t.Run("missing DISCORD_BOT_TOKEN", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "12345")
t.Setenv("GITHUB_PRIVATE_KEY", "test-key")
t.Setenv("DISCORD_BOT_TOKEN", "")

_, err := loadConfig(context.Background())
if err == nil {
t.Error("expected error for missing DISCORD_BOT_TOKEN")
}
})

t.Run("all required fields present", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "12345")
t.Setenv("GITHUB_PRIVATE_KEY", "test-key")
t.Setenv("DISCORD_BOT_TOKEN", "test-token")
t.Setenv("PORT", "8080")
t.Setenv("ALLOW_PERSONAL_ACCOUNTS", "true")

cfg, err := loadConfig(context.Background())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cfg.GitHubAppID != "12345" {
t.Errorf("GitHubAppID = %q, want %q", cfg.GitHubAppID, "12345")
}
if cfg.Port != "8080" {
t.Errorf("Port = %q, want %q", cfg.Port, "8080")
}
if !cfg.AllowPersonalAccounts {
t.Error("AllowPersonalAccounts should be true")
}
})
}

func TestCoordinatorManager_ConfigAdapter(t *testing.T) {
mgr := config.New()
adapter := &configAdapter{mgr: mgr}

_, ok := adapter.Config("test-org")
if ok {
t.Error("expected false for unknown org")
}
}

// Helper types for testing

type responseRecorder struct {
status int
body string
headers http.Header
}

func (r *responseRecorder) Header() http.Header {
return r.headers
}

func (r *responseRecorder) Write(data []byte) (int, error) {
r.body += string(data)
if r.status == 0 {
r.status = http.StatusOK
}
return len(data), nil
}

func (r *responseRecorder) WriteHeader(status int) {
r.status = status
}

func TestCoordinatorManager_DailyReport_NoPRs(t *testing.T) {
mockStore := &mockStateStore{
dailyReportInfos: make(map[string]state.DailyReportInfo),
}

cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
discordClients: map[string]*discord.Client{},
coordinators: make(map[string]*bot.Coordinator),
store: mockStore,
configManager: config.New(),
reverseMapper: usermapping.NewReverseMapper(),
}

// Can't test fully without mocking GitHub API, but we can test error path
debug, err := cm.DailyReport(context.Background(), "test-guild", "user123", false)

if err == nil {
t.Error("expected error for no org found")
}
if debug != nil {
t.Error("debug should be nil on error")
}
}

func TestCoordinatorManager_DailyReport_RateLimited(t *testing.T) {
// Test that non-forced reports respect the 20-hour rate limit
mockStore := &mockStateStore{
dailyReportInfos: map[string]state.DailyReportInfo{
"user123": {
LastSentAt: time.Now().Add(-10 * time.Hour), // 10 hours ago
GuildID: "test-guild",
},
},
}

cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
discordClients: make(map[string]*discord.Client),
coordinators: make(map[string]*bot.Coordinator),
store: mockStore,
configManager: config.New(),
reverseMapper: usermapping.NewReverseMapper(),
}

// This will error due to no org, but we're testing the rate limit check comes first
_, err := cm.DailyReport(context.Background(), "test-guild", "user123", false)

// Should get "no org" error since we didn't set up org, but that's after rate limit check
if err == nil {
t.Error("expected error")
}
}

func TestCoordinatorManager_DailyReport_ForceBypassesRateLimit(t *testing.T) {
// Test that force=true bypasses rate limiting
mockStore := &mockStateStore{
dailyReportInfos: map[string]state.DailyReportInfo{
"user123": {
LastSentAt: time.Now().Add(-1 * time.Hour), // Just 1 hour ago
GuildID: "test-guild",
},
},
}

cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
discordClients: make(map[string]*discord.Client),
coordinators: make(map[string]*bot.Coordinator),
store: mockStore,
configManager: config.New(),
reverseMapper: usermapping.NewReverseMapper(),
}

// Force should bypass rate limit, so we'll get "no org" error
_, err := cm.DailyReport(context.Background(), "test-guild", "user123", true)

if err == nil {
t.Error("expected error for no org")
}
// The fact we got past rate limit check and hit "no org" means force worked
}

func TestCoordinatorManager_DailyReport_NoDiscordClient(t *testing.T) {
mockStore := &mockStateStore{
dailyReportInfos: make(map[string]state.DailyReportInfo),
}

cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
discordClients: make(map[string]*discord.Client),
coordinators: make(map[string]*bot.Coordinator),
store: mockStore,
configManager: config.New(),
reverseMapper: usermapping.NewReverseMapper(),
}

debug, err := cm.DailyReport(context.Background(), "test-guild", "user123", true)

if err == nil {
t.Error("expected error for no org found")
}
if debug != nil {
t.Error("debug should be nil on error")
}
}

func TestCoordinatorManager_DailyReport_DebugInfo(t *testing.T) {
// Test that debug info is properly populated
// This is a basic test since we can't fully mock GitHub without more infrastructure

mockStore := &mockStateStore{
dailyReportInfos: map[string]state.DailyReportInfo{
"user123": {
LastSentAt: time.Now().Add(-25 * time.Hour), // 25 hours ago - eligible
GuildID: "test-guild",
},
},
}

cm := &coordinatorManager{
active: make(map[string]context.CancelFunc),
discordClients: make(map[string]*discord.Client),
coordinators: make(map[string]*bot.Coordinator),
store: mockStore,
configManager: config.New(),
reverseMapper: usermapping.NewReverseMapper(),
}

// Will error due to no org, but that's expected
_, err := cm.DailyReport(context.Background(), "test-guild", "user123", false)
if err == nil {
t.Error("expected error for no org")
}
}

func TestCoordinatorManager_DailyReportGetter_Interface(t *testing.T) {
// Test that coordinatorManager implements DailyReportGetter interface
var _ discord.DailyReportGetter = (*coordinatorManager)(nil)
}
Loading
Loading