diff --git a/README.md b/README.md index ad7bf24..94ebe48 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,11 @@ Make sure to enable **MESSAGE CONTENT INTENT** for the bot: LEADERBOARD_ID="" DISCORD_TOKEN="" CHANNEL_ID="" + AOC_YEAR="" ``` + **Note:** The `AOC_YEAR` variable is optional and defaults to the current year. You can set it to any year from 2015 onwards to track a specific Advent of Code event. + 4. Build the project ```sh diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 3be23c6..3d0cc8b 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -37,6 +37,10 @@ func loadConfig() *config.Config { if cfg == nil { log.Fatal("cfg is nil") } + if err := cfg.Validate(); err != nil { + log.Fatalf("configuration validation failed: %v", err) + } + log.Printf("Using Advent of Code year: %d", cfg.AOCYear) return cfg } @@ -57,7 +61,7 @@ func getLeaderboard(cfg *config.Config) *aoc.Leaderboard { if err != nil || file == nil { log.Printf("error opening leaderboard file: %v", err) log.Printf("getting leaderboard from AoC") - client := aoc.NewClient(cfg.SessionCookie) + client := aoc.NewClient(cfg.SessionCookie, cfg.AOCYear) storedLeaderboard, err := client.GetLeaderboard(cfg.LeaderboardID) return handleLeaderboardError(storedLeaderboard, err) } @@ -73,7 +77,7 @@ func handleLeaderboardError(leaderboard *aoc.Leaderboard, err error) *aoc.Leader } func initTracker(cfg *config.Config, storedLeaderboard *aoc.Leaderboard) *leaderboard.Tracker { - client := aoc.NewClient(cfg.SessionCookie) + client := aoc.NewClient(cfg.SessionCookie, cfg.AOCYear) tracker := leaderboard.NewTracker(cfg, storedLeaderboard, client) if tracker == nil { log.Fatal("tracker is nil") diff --git a/internal/aoc/client.go b/internal/aoc/client.go index d23b75b..29b674e 100644 --- a/internal/aoc/client.go +++ b/internal/aoc/client.go @@ -10,13 +10,15 @@ import ( type Client struct { SessionCookie string HTTPClient *http.Client + Year int } -// NewClient creates a new AOC client with the provided session cookie. -func NewClient(sessionCookie string) *Client { +// NewClient creates a new AOC client with the provided session cookie and year. +func NewClient(sessionCookie string, year int) *Client { return &Client{ SessionCookie: sessionCookie, HTTPClient: http.DefaultClient, + Year: year, } } @@ -26,7 +28,7 @@ func (c *Client) SetHTTPClient(client *http.Client) { } func (c *Client) GetLeaderboard(leaderboardID string) (*Leaderboard, error) { - url := fmt.Sprintf("https://adventofcode.com/2024/leaderboard/private/view/%s.json", leaderboardID) + url := fmt.Sprintf("https://adventofcode.com/%d/leaderboard/private/view/%s.json", c.Year, leaderboardID) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/internal/aoc/client_test.go b/internal/aoc/client_test.go index 7f52b57..f1d906e 100644 --- a/internal/aoc/client_test.go +++ b/internal/aoc/client_test.go @@ -51,7 +51,7 @@ func TestGetLeaderboardSuccess(t *testing.T) { })) defer mockServer.Close() - client := NewClient("test-session-cookie") + client := NewClient("test-session-cookie", 2024) // Inject mock HTTP client client.SetHTTPClient(mockServer.Client()) @@ -110,7 +110,7 @@ func TestGetLeaderboardInvalidSession(t *testing.T) { })) defer mockServer.Close() - client := NewClient("invalid-session-cookie") + client := NewClient("invalid-session-cookie", 2024) client.SetHTTPClient(mockServer.Client()) // Override the request URL @@ -133,7 +133,7 @@ func TestGetLeaderboardInvalidJSON(t *testing.T) { })) defer mockServer.Close() - client := NewClient("test-session-cookie") + client := NewClient("test-session-cookie", 2024) client.SetHTTPClient(mockServer.Client()) // Override the request URL diff --git a/internal/config/config.go b/internal/config/config.go index 829844f..bd52dbc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,10 @@ package config import ( + "fmt" "os" + "strconv" + "time" ) type Config struct { @@ -9,13 +12,43 @@ type Config struct { SessionCookie string DiscordToken string ChannelID string + AOCYear int } func NewConfig() *Config { + // Default to current year if AOC_YEAR is not set + year := time.Now().Year() + if yearStr := os.Getenv("AOC_YEAR"); yearStr != "" { + if parsedYear, err := strconv.Atoi(yearStr); err == nil && parsedYear > 2015 { + year = parsedYear + } + } + return &Config{ LeaderboardID: os.Getenv("LEADERBOARD_ID"), SessionCookie: os.Getenv("SESSION_COOKIE"), DiscordToken: os.Getenv("DISCORD_TOKEN"), ChannelID: os.Getenv("CHANNEL_ID"), + AOCYear: year, + } +} + +// Validate checks that all required configuration values are present. +func (c *Config) Validate() error { + if c.LeaderboardID == "" { + return fmt.Errorf("LEADERBOARD_ID environment variable is required") + } + if c.SessionCookie == "" { + return fmt.Errorf("SESSION_COOKIE environment variable is required") + } + if c.DiscordToken == "" { + return fmt.Errorf("DISCORD_TOKEN environment variable is required") + } + if c.ChannelID == "" { + return fmt.Errorf("CHANNEL_ID environment variable is required") + } + if c.AOCYear < 2015 { + return fmt.Errorf("AOC_YEAR must be 2015 or later (Advent of Code started in 2015)") } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be4933d..cf81466 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -13,6 +14,7 @@ func TestNewConfig(t *testing.T) { t.Setenv("SESSION_COOKIE", "prod-session-cookie") t.Setenv("DISCORD_TOKEN", "prod-discord-token") t.Setenv("CHANNEL_ID", "prod-channel-id") + t.Setenv("AOC_YEAR", "2023") // Call NewConfig cfg := NewConfig() @@ -22,6 +24,7 @@ func TestNewConfig(t *testing.T) { 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") + assert.Equal(t, 2023, cfg.AOCYear, "AOCYear should match") }) t.Run("Some Environment Variables Missing", func(t *testing.T) { @@ -38,6 +41,7 @@ func TestNewConfig(t *testing.T) { 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") + assert.Equal(t, time.Now().Year(), cfg.AOCYear, "AOCYear should default to current year") }) t.Run("No Environment Variables Set", func(t *testing.T) { @@ -51,6 +55,40 @@ func TestNewConfig(t *testing.T) { 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") + assert.Equal(t, time.Now().Year(), cfg.AOCYear, "AOCYear should default to current year") + }) + + t.Run("Custom AOC Year Set", func(t *testing.T) { + // Set custom year + t.Setenv("AOC_YEAR", "2022") + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, 2022, cfg.AOCYear, "AOCYear should be 2022") + }) + + t.Run("Invalid AOC Year Defaults to Current Year", func(t *testing.T) { + // Set invalid year + t.Setenv("AOC_YEAR", "not-a-number") + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, time.Now().Year(), cfg.AOCYear, "AOCYear should default to current year when invalid") + }) + + t.Run("AOC Year Below 2015 Defaults to Current Year", func(t *testing.T) { + // Set year before Advent of Code existed + t.Setenv("AOC_YEAR", "2014") + + // Call NewConfig + cfg := NewConfig() + + // Assertions + assert.Equal(t, time.Now().Year(), cfg.AOCYear, "AOCYear should default to current year when below 2015") }) } @@ -61,6 +99,7 @@ func TestConfigStruct(t *testing.T) { t.Setenv("SESSION_COOKIE", "config-session-cookie") t.Setenv("DISCORD_TOKEN", "config-discord-token") t.Setenv("CHANNEL_ID", "config-channel-id") + t.Setenv("AOC_YEAR", "2024") // Initialize Config cfg := NewConfig() @@ -71,8 +110,90 @@ func TestConfigStruct(t *testing.T) { SessionCookie: "config-session-cookie", DiscordToken: "config-discord-token", ChannelID: "config-channel-id", + AOCYear: 2024, } assert.Equal(t, expected, cfg, "Config struct should match expected values") }) } + +func TestValidate(t *testing.T) { + t.Run("Valid Config", func(t *testing.T) { + cfg := &Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-cookie", + DiscordToken: "test-token", + ChannelID: "test-channel", + AOCYear: 2024, + } + + err := cfg.Validate() + assert.NoError(t, err, "Valid config should not return an error") + }) + + t.Run("Missing LeaderboardID", func(t *testing.T) { + cfg := &Config{ + SessionCookie: "test-cookie", + DiscordToken: "test-token", + ChannelID: "test-channel", + AOCYear: 2024, + } + + err := cfg.Validate() + assert.Error(t, err, "Should return error for missing LeaderboardID") + assert.Contains(t, err.Error(), "LEADERBOARD_ID", "Error should mention LEADERBOARD_ID") + }) + + t.Run("Missing SessionCookie", func(t *testing.T) { + cfg := &Config{ + LeaderboardID: "test-leaderboard", + DiscordToken: "test-token", + ChannelID: "test-channel", + AOCYear: 2024, + } + + err := cfg.Validate() + assert.Error(t, err, "Should return error for missing SessionCookie") + assert.Contains(t, err.Error(), "SESSION_COOKIE", "Error should mention SESSION_COOKIE") + }) + + t.Run("Missing DiscordToken", func(t *testing.T) { + cfg := &Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-cookie", + ChannelID: "test-channel", + AOCYear: 2024, + } + + err := cfg.Validate() + assert.Error(t, err, "Should return error for missing DiscordToken") + assert.Contains(t, err.Error(), "DISCORD_TOKEN", "Error should mention DISCORD_TOKEN") + }) + + t.Run("Missing ChannelID", func(t *testing.T) { + cfg := &Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-cookie", + DiscordToken: "test-token", + AOCYear: 2024, + } + + err := cfg.Validate() + assert.Error(t, err, "Should return error for missing ChannelID") + assert.Contains(t, err.Error(), "CHANNEL_ID", "Error should mention CHANNEL_ID") + }) + + t.Run("AOCYear Below 2015", func(t *testing.T) { + cfg := &Config{ + LeaderboardID: "test-leaderboard", + SessionCookie: "test-cookie", + DiscordToken: "test-token", + ChannelID: "test-channel", + AOCYear: 2014, + } + + err := cfg.Validate() + assert.Error(t, err, "Should return error for AOCYear below 2015") + assert.Contains(t, err.Error(), "AOC_YEAR", "Error should mention AOC_YEAR") + }) +}