From be4c2bd8b7eedae1ce6bd85434d607b01d78e198 Mon Sep 17 00:00:00 2001 From: Cyrus AI Date: Tue, 14 Oct 2025 17:58:17 +0000 Subject: [PATCH] feat: make Advent of Code year configurable with validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change adds support for configuring the Advent of Code year via the AOC_YEAR environment variable, making the bot more flexible and future-proof. Key improvements: - Added AOC_YEAR config field that defaults to the current year - Replaced hardcoded year (2024) in API URL with configurable year parameter - Added comprehensive config validation with descriptive error messages - Updated all test cases to work with the new year parameter - Enhanced documentation in README to explain the new optional setting The year validation ensures it's 2015 or later (when Advent of Code started) and defaults to the current year if not specified or invalid. This improvement resolves ENG-26 by making a valuable enhancement that improves maintainability and user experience. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 3 + cmd/bot/main.go | 8 ++- internal/aoc/client.go | 8 ++- internal/aoc/client_test.go | 6 +- internal/config/config.go | 33 +++++++++ internal/config/config_test.go | 121 +++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 8 deletions(-) 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") + }) +}