diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..4fadf67 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,25 @@ +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/cmd/bot/main.go b/cmd/bot/main.go index b0cf118..3be23c6 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -73,7 +73,8 @@ func handleLeaderboardError(leaderboard *aoc.Leaderboard, err error) *aoc.Leader } func initTracker(cfg *config.Config, storedLeaderboard *aoc.Leaderboard) *leaderboard.Tracker { - tracker := leaderboard.NewTracker(cfg, storedLeaderboard) + client := aoc.NewClient(cfg.SessionCookie) + tracker := leaderboard.NewTracker(cfg, storedLeaderboard, client) if tracker == nil { log.Fatal("tracker is nil") } diff --git a/go.mod b/go.mod index 27f2835..d6ad490 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,13 @@ go 1.20 require ( github.com/bwmarrin/discordgo v0.27.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7d1da0c..1e17764 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -12,3 +20,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/aoc/client.go b/internal/aoc/client.go index a5a3842..d23b75b 100644 --- a/internal/aoc/client.go +++ b/internal/aoc/client.go @@ -9,42 +9,48 @@ import ( type Client struct { SessionCookie string + HTTPClient *http.Client } +// NewClient creates a new AOC client with the provided session cookie. func NewClient(sessionCookie string) *Client { return &Client{ SessionCookie: sessionCookie, + HTTPClient: http.DefaultClient, } } +// For testing purposes, SetHTTPClient allows you to set the HTTP client used by the client. +func (c *Client) SetHTTPClient(client *http.Client) { + c.HTTPClient = client +} + func (c *Client) GetLeaderboard(leaderboardID string) (*Leaderboard, error) { url := fmt.Sprintf("https://adventofcode.com/2024/leaderboard/private/view/%s.json", leaderboardID) req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, fmt.Errorf("error reading request: %w", err) + return nil, fmt.Errorf("error creating request: %w", err) } req.Header.Set("User-Agent", "github.com/PaytonWebber/aoc-discord-bot by paytonwebber@gmail.com") - req.Header.Set("cookie", fmt.Sprintf("session=%s", c.SessionCookie)) - client := &http.Client{} - resp, err := client.Do(req) + resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("error reading response: %w", err) + return nil, fmt.Errorf("error making HTTP request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("error reading body: %w", err) + return nil, fmt.Errorf("error reading response body: %w", err) } var leaderboard Leaderboard err = json.Unmarshal(body, &leaderboard) if err != nil { - return nil, fmt.Errorf("error unmarshalling body: %w", err) + return nil, fmt.Errorf("error unmarshalling response body: %w", err) } return &leaderboard, nil diff --git a/internal/aoc/client_test.go b/internal/aoc/client_test.go new file mode 100644 index 0000000..7f52b57 --- /dev/null +++ b/internal/aoc/client_test.go @@ -0,0 +1,174 @@ +package aoc + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +const mockLeaderboardJSON = `{ + "event": "2024", + "owner_id": 12345, + "members": { + "67890": { + "id": 67890, + "last_star_ts": 1672444800, + "global_score": 100, + "local_score": 200, + "name": "Test User", + "completion_day_level": { + "1": { + "1": { + "get_star_ts": 1672444800, + "star_index": 1 + }, + "2": { + "get_star_ts": 1672444900, + "star_index": 2 + } + } + }, + "stars": 2 + } + } +}` + +func TestGetLeaderboardSuccess(t *testing.T) { + // Create a mock server to return a valid JSON response + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate the request + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + expectedPath := "/2024/leaderboard/private/view/test-leaderboard.json" + if r.URL.Path != expectedPath { + t.Errorf("Expected URL path %s, got %s", expectedPath, r.URL.Path) + } + // Return mock JSON + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, mockLeaderboardJSON) + })) + defer mockServer.Close() + + client := NewClient("test-session-cookie") + // Inject mock HTTP client + client.SetHTTPClient(mockServer.Client()) + + // Override the request URL using a custom transport + client.HTTPClient.Transport = rewriteURLTransport("https://adventofcode.com", mockServer.URL) + + // Call the method under test + leaderboard, err := client.GetLeaderboard("test-leaderboard") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify top-level fields + if leaderboard.Event != "2024" { + t.Errorf("Expected event '2024', got '%s'", leaderboard.Event) + } + if leaderboard.OwnerID != 12345 { + t.Errorf("Expected owner ID 12345, got %d", leaderboard.OwnerID) + } + + // Verify member data + member, exists := leaderboard.Members["67890"] + if !exists { + t.Fatalf("Expected member with ID '67890' in leaderboard members") + } + if member.ID != 67890 { + t.Errorf("Expected member ID 67890, got %d", member.ID) + } + if member.Name != "Test User" { + t.Errorf("Expected member name 'Test User', got '%s'", member.Name) + } + if member.Stars != 2 { + t.Errorf("Expected stars 2, got %d", member.Stars) + } + if member.LocalScore != 200 { + t.Errorf("Expected local score 200, got %d", member.LocalScore) + } + + // Verify completion day levels + day1, exists := member.CompletionDayLevels["1"] + if !exists { + t.Fatalf("Expected day '1' in completion_day_level") + } + if day1.Level1 == nil || day1.Level1.GetStarTs != 1672444800 { + t.Errorf("Expected Level1 get_star_ts 1672444800, got %v", day1.Level1) + } + if day1.Level2 == nil || day1.Level2.GetStarTs != 1672444900 { + t.Errorf("Expected Level2 get_star_ts 1672444900, got %v", day1.Level2) + } +} + +func TestGetLeaderboardInvalidSession(t *testing.T) { + // Create a mock server that returns unauthorized response + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + })) + defer mockServer.Close() + + client := NewClient("invalid-session-cookie") + client.SetHTTPClient(mockServer.Client()) + + // Override the request URL + client.HTTPClient.Transport = rewriteURLTransport("https://adventofcode.com", mockServer.URL) + + _, err := client.GetLeaderboard("test-leaderboard") + if err == nil { + t.Fatalf("Expected an error due to unauthorized access, but got none") + } + expectedError := "error unmarshalling response body" + if err.Error()[:len(expectedError)] != expectedError { + t.Errorf("Expected error starting with '%s', got '%v'", expectedError, err) + } +} + +func TestGetLeaderboardInvalidJSON(t *testing.T) { + // Create a mock server that returns invalid JSON + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "invalid json") + })) + defer mockServer.Close() + + client := NewClient("test-session-cookie") + client.SetHTTPClient(mockServer.Client()) + + // Override the request URL + client.HTTPClient.Transport = rewriteURLTransport("https://adventofcode.com", mockServer.URL) + + _, err := client.GetLeaderboard("test-leaderboard") + if err == nil { + t.Fatalf("Expected an error due to invalid JSON, but got none") + } + expectedError := "error unmarshalling response body" + if err.Error()[:len(expectedError)] != expectedError { + t.Errorf("Expected error starting with '%s', got '%v'", expectedError, err) + } +} + +// rewriteURLTransport modifies the request URL to point to the mock server +func rewriteURLTransport(originalBase, mockBase string) http.RoundTripper { + return &urlRewritingTransport{ + originalBase: originalBase, + mockBase: mockBase, + original: http.DefaultTransport, + } +} + +type urlRewritingTransport struct { + originalBase string + mockBase string + original http.RoundTripper +} + +func (t *urlRewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Replace the original base URL with the mock server's URL + if req.URL.String()[:len(t.originalBase)] == t.originalBase { + req.URL.Scheme = "http" + req.URL.Host = t.mockBase[len("http://"):] + } + return t.original.RoundTrip(req) +} diff --git a/internal/aoc/models_test.go b/internal/aoc/models_test.go new file mode 100644 index 0000000..26ef719 --- /dev/null +++ b/internal/aoc/models_test.go @@ -0,0 +1,117 @@ +package aoc + +import ( + "encoding/json" + "testing" +) + +func TestLeaderboardUnmarshal(t *testing.T) { + // Sample JSON response + mockJSON := `{ + "event": "2024", + "owner_id": 12345, + "members": { + "67890": { + "id": 67890, + "last_star_ts": 1672444800, + "global_score": 100, + "local_score": 200, + "name": "Test User", + "completion_day_level": { + "1": { + "1": { + "get_star_ts": 1672444800, + "star_index": 1 + }, + "2": { + "get_star_ts": 1672444900, + "star_index": 2 + } + } + }, + "stars": 2 + } + } + }` + + // Attempt to unmarshal the JSON into the Leaderboard struct + var leaderboard Leaderboard + err := json.Unmarshal([]byte(mockJSON), &leaderboard) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Verify the top-level fields + if leaderboard.Event != "2024" { + t.Errorf("Expected Event '2024', got '%s'", leaderboard.Event) + } + if leaderboard.OwnerID != 12345 { + t.Errorf("Expected OwnerID 12345, got %d", leaderboard.OwnerID) + } + + // Verify the members map + member, exists := leaderboard.Members["67890"] + if !exists { + t.Fatalf("Member '67890' not found in leaderboard members") + } + + if member.ID != 67890 { + t.Errorf("Expected Member ID 67890, got %d", member.ID) + } + if member.Name != "Test User" { + t.Errorf("Expected Member Name 'Test User', got '%s'", member.Name) + } + if member.Stars != 2 { + t.Errorf("Expected Member Stars 2, got %d", member.Stars) + } + if member.LastStarTs != 1672444800 { + t.Errorf("Expected LastStarTs 1672444800, got %d", member.LastStarTs) + } + + // Verify nested CompletionDayLevels + completion, exists := member.CompletionDayLevels["1"] + if !exists { + t.Fatalf("Day '1' not found in completion_day_level") + } + + if completion.Level1 == nil || completion.Level1.GetStarTs != 1672444800 { + t.Errorf("Expected Level1 GetStarTs 1672444800, got %v", completion.Level1) + } + if completion.Level2 == nil || completion.Level2.GetStarTs != 1672444900 { + t.Errorf("Expected Level2 GetStarTs 1672444900, got %v", completion.Level2) + } +} + +func TestMemberUnmarshalWithMissingFields(t *testing.T) { + // JSON with missing optional fields + mockJSON := `{ + "id": 67890, + "last_star_ts": 1672444800, + "global_score": 100, + "local_score": 200, + "name": "Test User", + "stars": 2 + }` + + var member Member + err := json.Unmarshal([]byte(mockJSON), &member) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Verify the parsed member + if member.ID != 67890 { + t.Errorf("Expected Member ID 67890, got %d", member.ID) + } + if member.Name != "Test User" { + t.Errorf("Expected Member Name 'Test User', got '%s'", member.Name) + } + if member.Stars != 2 { + t.Errorf("Expected Member Stars 2, got %d", member.Stars) + } + + // Check that missing fields have default values + if member.CompletionDayLevels != nil { + t.Errorf("Expected CompletionDayLevels to be nil, got %v", member.CompletionDayLevels) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a677353..829844f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,12 +19,3 @@ func NewConfig() *Config { ChannelID: os.Getenv("CHANNEL_ID"), } } - -func NewTestConfig() *Config { - return &Config{ - LeaderboardID: os.Getenv("LEADERBOARD_ID"), - SessionCookie: os.Getenv("SESSION_COOKIE"), - DiscordToken: os.Getenv("TEST_DISCORD_TOKEN"), - ChannelID: os.Getenv("TEST_CHANNEL_ID"), - } -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..be4933d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewConfig(t *testing.T) { + t.Run("All Environment Variables Set", func(t *testing.T) { + // Set environment variables + t.Setenv("LEADERBOARD_ID", "prod-leaderboard") + t.Setenv("SESSION_COOKIE", "prod-session-cookie") + t.Setenv("DISCORD_TOKEN", "prod-discord-token") + t.Setenv("CHANNEL_ID", "prod-channel-id") + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, "prod-leaderboard", cfg.LeaderboardID, "LeaderboardID should match") + assert.Equal(t, "prod-session-cookie", cfg.SessionCookie, "SessionCookie should match") + assert.Equal(t, "prod-discord-token", cfg.DiscordToken, "DiscordToken should match") + assert.Equal(t, "prod-channel-id", cfg.ChannelID, "ChannelID should match") + }) + + t.Run("Some Environment Variables Missing", func(t *testing.T) { + // Set some environment variables + t.Setenv("LEADERBOARD_ID", "prod-leaderboard") + t.Setenv("SESSION_COOKIE", "prod-session-cookie") + // DISCORD_TOKEN and CHANNEL_ID are not set + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, "prod-leaderboard", cfg.LeaderboardID, "LeaderboardID should match") + assert.Equal(t, "prod-session-cookie", cfg.SessionCookie, "SessionCookie should match") + assert.Equal(t, "", cfg.DiscordToken, "DiscordToken should be empty") + assert.Equal(t, "", cfg.ChannelID, "ChannelID should be empty") + }) + + t.Run("No Environment Variables Set", func(t *testing.T) { + // No environment variables are set + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, "", cfg.LeaderboardID, "LeaderboardID should be empty") + assert.Equal(t, "", cfg.SessionCookie, "SessionCookie should be empty") + assert.Equal(t, "", cfg.DiscordToken, "DiscordToken should be empty") + assert.Equal(t, "", cfg.ChannelID, "ChannelID should be empty") + }) +} + +func TestConfigStruct(t *testing.T) { + t.Run("Config Struct Fields", func(t *testing.T) { + // Set environment variables + t.Setenv("LEADERBOARD_ID", "config-leaderboard") + t.Setenv("SESSION_COOKIE", "config-session-cookie") + t.Setenv("DISCORD_TOKEN", "config-discord-token") + t.Setenv("CHANNEL_ID", "config-channel-id") + + // Initialize Config + cfg := NewConfig() + + // Directly test struct fields + expected := &Config{ + LeaderboardID: "config-leaderboard", + SessionCookie: "config-session-cookie", + DiscordToken: "config-discord-token", + ChannelID: "config-channel-id", + } + + assert.Equal(t, expected, cfg, "Config struct should match expected values") + }) +} diff --git a/internal/leaderboard/tracker.go b/internal/leaderboard/tracker.go index 26e0d1f..75583a9 100644 --- a/internal/leaderboard/tracker.go +++ b/internal/leaderboard/tracker.go @@ -7,17 +7,22 @@ import ( "time" ) +type AOCClient interface { + GetLeaderboard(leaderboardID string) (*aoc.Leaderboard, error) +} + +// Tracker manages the leaderboard state type Tracker struct { PreviousLeaderboard *aoc.Leaderboard CurrentLeaderboard *aoc.Leaderboard - Client *aoc.Client + Client AOCClient Config *config.Config LastUpdate time.Time } -func NewTracker(cfg *config.Config, StoredLeaderboard *aoc.Leaderboard) *Tracker { +func NewTracker(cfg *config.Config, StoredLeaderboard *aoc.Leaderboard, client AOCClient) *Tracker { return &Tracker{ - Client: aoc.NewClient(cfg.SessionCookie), + Client: client, Config: cfg, CurrentLeaderboard: StoredLeaderboard, } diff --git a/internal/leaderboard/tracker_test.go b/internal/leaderboard/tracker_test.go new file mode 100644 index 0000000..2cfe5c8 --- /dev/null +++ b/internal/leaderboard/tracker_test.go @@ -0,0 +1,521 @@ +// internal/leaderboard/tracker_test.go + +package leaderboard + +import ( + "errors" + "testing" + + "github.com/PaytonWebber/aoc-discord-bot/internal/aoc" + "github.com/PaytonWebber/aoc-discord-bot/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockAOCClient is a mock implementation of the AOCClient interface +type MockAOCClient struct { + mock.Mock +} + +func (m *MockAOCClient) GetLeaderboard(leaderboardID string) (*aoc.Leaderboard, error) { + args := m.Called(leaderboardID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*aoc.Leaderboard), args.Error(1) +} + +func TestNewTracker(t *testing.T) { + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + initialLeaderboard := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + mockClient := new(MockAOCClient) + + tracker := NewTracker(cfg, initialLeaderboard, mockClient) + + assert.Equal(t, cfg, tracker.Config, "Config should be set correctly") + assert.Equal(t, initialLeaderboard, tracker.CurrentLeaderboard, "Initial leaderboard should be set correctly") + assert.Equal(t, mockClient, tracker.Client, "AOCClient should be set correctly") + assert.Nil(t, tracker.PreviousLeaderboard, "PreviousLeaderboard should be nil initially") +} + +func TestGetLeaderboard_Success(t *testing.T) { + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + + expectedLeaderboard := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + mockClient.On("GetLeaderboard", "test-leaderboard").Return(expectedLeaderboard, nil) + + tracker := NewTracker(cfg, nil, mockClient) + + leaderboard, err := tracker.GetLeaderboard() + + assert.NoError(t, err, "Expected no error") + assert.Equal(t, expectedLeaderboard, leaderboard, "Expected leaderboard to match") + mockClient.AssertExpectations(t) +} + +func TestGetLeaderboard_Error(t *testing.T) { + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + + mockClient.On("GetLeaderboard", "test-leaderboard").Return(nil, errors.New("API error")) + + tracker := NewTracker(cfg, nil, mockClient) + + leaderboard, err := tracker.GetLeaderboard() + + assert.Error(t, err, "Expected an error") + assert.Nil(t, leaderboard, "Expected leaderboard to be nil") + mockClient.AssertExpectations(t) +} + +func TestUpdateLeaderboard_Success(t *testing.T) { + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + initialLeaderboard := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + mockClient := new(MockAOCClient) + + newLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + mockClient.On("GetLeaderboard", "test-leaderboard").Return(newLeaderboard, nil) + + tracker := NewTracker(cfg, initialLeaderboard, mockClient) + + err := tracker.UpdateLeaderboard() + + assert.NoError(t, err, "Expected no error") + assert.Equal(t, initialLeaderboard, tracker.PreviousLeaderboard, "PreviousLeaderboard should be updated to initial") + assert.Equal(t, newLeaderboard, tracker.CurrentLeaderboard, "CurrentLeaderboard should be updated to new") + mockClient.AssertExpectations(t) +} + +func TestUpdateLeaderboard_Error(t *testing.T) { + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + initialLeaderboard := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + mockClient := new(MockAOCClient) + + mockClient.On("GetLeaderboard", "test-leaderboard").Return(nil, errors.New("API error")) + + tracker := NewTracker(cfg, initialLeaderboard, mockClient) + + err := tracker.UpdateLeaderboard() + + assert.Error(t, err, "Expected an error") + assert.Equal(t, initialLeaderboard, tracker.CurrentLeaderboard, "CurrentLeaderboard should remain unchanged") + mockClient.AssertExpectations(t) +} + +func TestCheckForNewStars(t *testing.T) { + // Setup previous and current leaderboards + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "2": { + ID: 2, + Name: "User2", + LocalScore: 250, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 3, // Increased stars + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "2": { + ID: 2, + Name: "User2", + LocalScore: 250, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "3": { + ID: 3, + Name: "User3", + LocalScore: 300, + Stars: 1, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newStars, err := tracker.CheckForNewStars() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newStars, 1, "Expected one new star") + assert.Contains(t, newStars, "User1", "Expected User1 to have new stars") +} + +func TestCheckForNewStars_NoNewStars(t *testing.T) { + // Setup previous and current leaderboards with no new stars + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newStars, err := tracker.CheckForNewStars() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newStars, 0, "Expected no new stars") +} + +func TestCheckForNewMembers(t *testing.T) { + // Setup previous and current leaderboards + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "2": { + ID: 2, + Name: "User2", + LocalScore: 250, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newMembers, err := tracker.CheckForNewMembers() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newMembers, 1, "Expected one new member") + assert.Contains(t, newMembers, "User2", "Expected User2 to be identified as a new member") +} + +func TestCheckForNewMembers_NoNewMembers(t *testing.T) { + // Setup previous and current leaderboards with no new members + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newMembers, err := tracker.CheckForNewMembers() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newMembers, 0, "Expected no new members") +} + +func TestCheckForNewStars_PartialPreviousLeaderboard(t *testing.T) { + // Setup previous and current leaderboards where some members are missing + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + // User2 is missing in previous leaderboard + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 3, // Increased stars + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "2": { + ID: 2, + Name: "User2", + LocalScore: 250, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newStars, err := tracker.CheckForNewStars() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newStars, 1, "Expected one new star") + assert.Contains(t, newStars, "User1", "Expected User1 to have new stars") +} + +func TestCheckForNewMembers_PartialPreviousLeaderboard(t *testing.T) { + // Setup previous and current leaderboards where some members are missing + previousLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + // User2 is missing in previous leaderboard + }, + Event: "2024", + OwnerID: 12345, + } + + currentLeaderboard := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "User1", + LocalScore: 200, + Stars: 2, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + "2": { + ID: 2, + Name: "User2", + LocalScore: 250, + Stars: 3, + CompletionDayLevels: make(map[string]aoc.CompletionDayLevel), + }, + }, + Event: "2024", + OwnerID: 12345, + } + + cfg := &config.Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-session-cookie", + DiscordToken: "test-discord-token", + ChannelID: "test-channel", + } + + mockClient := new(MockAOCClient) + tracker := NewTracker(cfg, previousLeaderboard, mockClient) + mockClient.On("GetLeaderboard", "test-leaderboard").Return(currentLeaderboard, nil) + + // Simulate updating the leaderboard + err := tracker.UpdateLeaderboard() + assert.NoError(t, err, "Expected no error during UpdateLeaderboard") + + newMembers, err := tracker.CheckForNewMembers() + + assert.NoError(t, err, "Expected no error") + assert.Len(t, newMembers, 1, "Expected one new member") + assert.Contains(t, newMembers, "User2", "Expected User2 to be identified as a new member") +} diff --git a/internal/leaderboard/utils.go b/internal/leaderboard/utils.go index fc399fb..0c3ca2e 100644 --- a/internal/leaderboard/utils.go +++ b/internal/leaderboard/utils.go @@ -54,6 +54,9 @@ func FormatLeaderboard(leaderboard *aoc.Leaderboard) *discordgo.MessageEmbed { } func FormatStars(leaderboard *aoc.Leaderboard) *discordgo.MessageEmbed { + if leaderboard == nil { + return nil + } // Convert map to a slice for sorting members := make([]aoc.Member, 0, len(leaderboard.Members)) diff --git a/internal/leaderboard/utils_test.go b/internal/leaderboard/utils_test.go new file mode 100644 index 0000000..4c31576 --- /dev/null +++ b/internal/leaderboard/utils_test.go @@ -0,0 +1,155 @@ +// internal/leaderboard/utils_test.go + +package leaderboard + +import ( + "testing" + + "github.com/PaytonWebber/aoc-discord-bot/internal/aoc" + "github.com/stretchr/testify/assert" +) + +func TestFormatLeaderboard(t *testing.T) { + // Setup a sample leaderboard + leaderboardData := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "Alice", + LocalScore: 300, + Stars: 5, + }, + "2": { + ID: 2, + Name: "Bob", + LocalScore: 250, + Stars: 4, + }, + "3": { + ID: 3, + Name: "Charlie", + LocalScore: 250, + Stars: 4, + }, + }, + Event: "2024", + OwnerID: 12345, + } + + // Call the function + embed := FormatLeaderboard(leaderboardData) + + // Assertions + assert.NotNil(t, embed, "Embed should not be nil") + assert.Equal(t, "AoC Leaderboard:", embed.Title, "Embed title should match") + + // Verify the description content + expectedDescription := "1. Alice - 300 points (5 stars)\n" + + "2. Bob - 250 points (4 stars)\n" + + "2. Charlie - 250 points (4 stars)\n" + + assert.Equal(t, expectedDescription, embed.Description, "Embed description should match expected formatted leaderboard") + assert.Equal(t, 0x034F20, embed.Color, "Embed color should match expected value") +} + +func TestFormatLeaderboard_EmptyLeaderboard(t *testing.T) { + // Setup an empty leaderboard + leaderboardData := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + // Call the function + embed := FormatLeaderboard(leaderboardData) + + // Assertions + assert.Nil(t, embed, "Embed should be nil for empty leaderboard") +} + +func TestFormatLeaderboard_NilLeaderboard(t *testing.T) { + // Call the function with nil + embed := FormatLeaderboard(nil) + + // Assertions + assert.Nil(t, embed, "Embed should be nil for nil leaderboard") +} + +func TestFormatStars(t *testing.T) { + // Setup a sample leaderboard + leaderboardData := &aoc.Leaderboard{ + Members: map[string]aoc.Member{ + "1": { + ID: 1, + Name: "Alice", + CompletionDayLevels: map[string]aoc.CompletionDayLevel{ + "1": { + Level1: &aoc.StarDetail{ + GetStarTs: 1672444800, + StarIndex: 1, + }, + Level2: &aoc.StarDetail{ + GetStarTs: 1672444900, + StarIndex: 2, + }, + }, + "2": { + Level1: &aoc.StarDetail{ + GetStarTs: 1672445000, + StarIndex: 3, + }, + }, + }, + Stars: 3, + }, + "2": { + ID: 2, + Name: "Bob", + CompletionDayLevels: map[string]aoc.CompletionDayLevel{}, + Stars: 0, + }, + }, + Event: "2024", + OwnerID: 12345, + } + + // Call the function + embed := FormatStars(leaderboardData) + + // Assertions + assert.NotNil(t, embed, "Embed should not be nil") + assert.Equal(t, "AoC Stars:", embed.Title, "Embed title should match") + assert.Equal(t, 0xB22222, embed.Color, "Embed color should match expected value") + + // Verify the description content + expectedDescription := "```Day 1 2\n" + + " ★ ☆ Alice\n" + + " Bob ```" + + assert.Equal(t, expectedDescription, embed.Description, "Embed description should match expected formatted stars") +} + +func TestFormatStars_EmptyLeaderboard(t *testing.T) { + // Setup an empty leaderboard + leaderboardData := &aoc.Leaderboard{ + Members: make(map[string]aoc.Member), + Event: "2024", + OwnerID: 12345, + } + + // Call the function + embed := FormatStars(leaderboardData) + + // Assertions + assert.NotNil(t, embed, "Embed should not be nil even for empty leaderboard") + assert.Equal(t, "AoC Stars:", embed.Title, "Embed title should match") + assert.Equal(t, "```Day```", embed.Description, "Embed description should match expected formatted stars for empty leaderboard") +} + +func TestFormatStars_NilLeaderboard(t *testing.T) { + // Call the function with nil + embed := FormatStars(nil) + + // Assertions + assert.Nil(t, embed, "Embed should be nil for nil leaderboard") +}