diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f2124de..dd498b4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,6 +14,7 @@ import ( "github.com/VATSIM-UK/ukcp-srd-tools/internal/airac" "github.com/VATSIM-UK/ukcp-srd-tools/internal/db" + "github.com/VATSIM-UK/ukcp-srd-tools/internal/discord" "github.com/VATSIM-UK/ukcp-srd-tools/internal/download" "github.com/VATSIM-UK/ukcp-srd-tools/internal/excel" "github.com/VATSIM-UK/ukcp-srd-tools/internal/file" @@ -222,6 +223,13 @@ func importProcess(ctx context.Context, filePath string, cycle string, envPath s // Get the filename from the command line path, _ := filepath.Abs(filePath) + // Load the .env file before sending Discord notifications so the webhook URL is available + err := godotenv.Overload(envPath) + if err != nil { + log.Error().Err(err).Msg("failed to load environment file") + return ErrCannotLoadDotenv + } + file, err := loadSrdFile(path) if err != nil { return err @@ -242,13 +250,6 @@ func importProcess(ctx context.Context, filePath string, cycle string, envPath s log.Info().Msgf("importing SRD file %v for cycle %v", path, airacCycle.Ident) - // Load the .env file - err = godotenv.Overload(envPath) - if err != nil { - log.Error().Err(err).Msg("failed to load environment file") - return ErrCannotLoadDotenv - } - // Get the database connection parameters dbParams, err := getDatabaseConnectionParams() if err != nil { @@ -292,6 +293,14 @@ func importProcess(ctx context.Context, filePath string, cycle string, envPath s // Print the stats printStats(file.Stats()) + err = discord.SendDiscordNotification(discord.DiscordNotificationData{ + WebhookURL: discord.LoadWebhookURL(), + Content: "SRD import complete for AIRAC cycle " + cycle, + }) + if err != nil { + log.Error().Err(err).Msg("failed to send Discord notification") + } + return nil } @@ -335,6 +344,14 @@ func doDownload(ctx context.Context, force bool, forceCycle string, envPath stri return err } + err = discord.SendDiscordNotification(discord.DiscordNotificationData{ + WebhookURL: discord.LoadWebhookURL(), + Content: "Starting SRD download for AIRAC cycle " + cycleToDownload.Ident, + }) + if err != nil { + log.Error().Err(err).Msg("failed to send Discord notification") + } + // Download the SRD file downloadUrl := download.DownloadUrl(cycleToDownload) if CLI.Download.Url != "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8170583..886678a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -157,8 +157,8 @@ func TestRun_ImportErrors(t *testing.T) { { "missing file", "missing.xlsx", - "", - nil, + "test.env", + map[string]string{}, fmt.Errorf("failed to open excel extended file: open %s: no such file or directory", testDataFile("missing.xlsx")), []string{ "failed to open excel extended file", @@ -167,8 +167,8 @@ func TestRun_ImportErrors(t *testing.T) { { "non excel file", "invalid.txt", - "", - nil, + "test.env", + map[string]string{}, cli.ErrUnknownFileExtension, []string{ "unknown file extension .txt", @@ -338,6 +338,10 @@ func TestRun_ImportSuccess(t *testing.T) { testDir := t.TempDir() envFilePath := fmt.Sprintf("%s/%s", testDir, "test.env") + // Setup Discord webhook mock + discordMock := getDiscordWebhookMock() + defer discordMock.server.Close() + // If there's a setup function, run it if tt.setupFunc != nil { test := getCliTestWithTempDir(tt.extraArgs, testDir) @@ -358,21 +362,26 @@ func TestRun_ImportSuccess(t *testing.T) { containerPort, err := mysqlContainer.container.MappedPort(ctx, "3306") require.NoError(err) - // Now write the env file with our database credentaisl - err = godotenv.Write( + // Now write the env file with our database credentials and Discord webhook + require.NoError(godotenv.Write( map[string]string{ - "DB_HOST": containerHost, - "DB_PORT": containerPort.Port(), - "DB_USERNAME": TestUsername, - "DB_DATABASE": TestDatabase, - "DB_PASSWORD": TestPassword, + "DB_HOST": containerHost, + "DB_PORT": containerPort.Port(), + "DB_USERNAME": TestUsername, + "DB_DATABASE": TestDatabase, + "DB_PASSWORD": TestPassword, + "DISCORD_WEBHOOK_URL": discordMock.server.URL, }, envFilePath, - ) + )) // Run the CLI test require.NoError(cli.Run(testDir)) + // Check Discord webhook was called (complete notification only) + require.Equal(1, discordMock.callCount, "expected Discord webhook to be called once (complete only)") + require.Contains(discordMock.messages[0], "SRD import complete for AIRAC cycle 2404") + // Check the logs for _, msg := range tt.expectedLogMessages { test.logRecorder.AssertHasString(require, msg) @@ -673,6 +682,10 @@ func TestDownloadSuccess(t *testing.T) { testDir := t.TempDir() envFilePath := fmt.Sprintf("%s/%s", testDir, "test.env") + // Setup Discord webhook mock + discordMock := getDiscordWebhookMock() + defer discordMock.server.Close() + // Download file path fileName := testDataFile(tt.fileName) @@ -718,21 +731,27 @@ func TestDownloadSuccess(t *testing.T) { containerPort, err := mysqlContainer.container.MappedPort(ctx, "3306") require.NoError(err) - // Now write the env file with our database credentaisl - err = godotenv.Write( + // Now write the env file with our database credentials and Discord webhook + require.NoError(godotenv.Write( map[string]string{ - "DB_HOST": containerHost, - "DB_PORT": containerPort.Port(), - "DB_USERNAME": TestUsername, - "DB_DATABASE": TestDatabase, - "DB_PASSWORD": TestPassword, + "DB_HOST": containerHost, + "DB_PORT": containerPort.Port(), + "DB_USERNAME": TestUsername, + "DB_DATABASE": TestDatabase, + "DB_PASSWORD": TestPassword, + "DISCORD_WEBHOOK_URL": discordMock.server.URL, }, envFilePath, - ) + )) // Run the CLI test require.NoError(cli.Run(testDir)) + // Check Discord webhook was called (download start and import complete) + require.Equal(2, discordMock.callCount, "expected Discord webhook to be called twice (download start and import complete)") + require.Contains(discordMock.messages[0], "Starting SRD download for AIRAC cycle") + require.Contains(discordMock.messages[1], "SRD import complete for AIRAC cycle") + // Check the logs for _, msg := range tt.expectedLogMessages { test.logRecorder.AssertHasString(require, msg) @@ -889,6 +908,12 @@ type testServer struct { wrapInZip bool } +type discordWebhookMock struct { + callCount int + messages []string + server *httptest.Server +} + func (t *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { t.callCount++ @@ -971,3 +996,18 @@ func getTestServer(statusCode int, pathToServe string) *testServer { return testServer } + +func getDiscordWebhookMock() *discordWebhookMock { + mock := &discordWebhookMock{ + messages: []string{}, + } + mock.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.callCount++ + body, err := io.ReadAll(r.Body) + if err == nil { + mock.messages = append(mock.messages, string(body)) + } + w.WriteHeader(http.StatusOK) + })) + return mock +} diff --git a/internal/discord/discord.go b/internal/discord/discord.go new file mode 100644 index 0000000..fc379a2 --- /dev/null +++ b/internal/discord/discord.go @@ -0,0 +1,48 @@ +package discord + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/rs/zerolog/log" +) + +type DiscordNotificationData struct { + WebhookURL string + Content string +} + +func SendDiscordNotification(data DiscordNotificationData) error { + payload := fmt.Sprintf(`{"content": "%s", "username": "UKCP SRD Tools"}`, data.Content) + + req, err := http.NewRequest("POST", data.WebhookURL, strings.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to send Discord notification, status code: %d", resp.StatusCode) + } + + log.Info().Msg("Discord notification sent successfully") + return nil +} + +func LoadWebhookURL() string { + webhookUrl := os.Getenv("DISCORD_WEBHOOK_URL") + + if webhookUrl == "" { + log.Error().Msg("DISCORD_WEBHOOK_URL environment variable is not set") + } + return webhookUrl +} diff --git a/internal/discord/discord_test.go b/internal/discord/discord_test.go new file mode 100644 index 0000000..939bb81 --- /dev/null +++ b/internal/discord/discord_test.go @@ -0,0 +1,129 @@ +package discord + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSendDiscordNotification_Success(t *testing.T) { + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal("POST", r.Method) + require.Equal("application/json", r.Header.Get("Content-Type")) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := SendDiscordNotification(DiscordNotificationData{ + WebhookURL: server.URL, + Content: "Test notification", + }) + require.NoError(err) +} + +func TestSendDiscordNotification_StatusCodes(t *testing.T) { + tests := []struct { + name string + statusCode int + wantError bool + }{ + {"200 OK", 200, false}, + {"204 No Content", 204, false}, + {"299 Max Success", 299, false}, + {"300 Redirect", 300, true}, + {"400 Bad Request", 400, true}, + {"500 Server Error", 500, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + })) + defer server.Close() + + err := SendDiscordNotification(DiscordNotificationData{ + WebhookURL: server.URL, + Content: "Test", + }) + + if tt.wantError { + require.Error(err) + } else { + require.NoError(err) + } + }) + } +} + +func TestSendDiscordNotification_InvalidURL(t *testing.T) { + require := require.New(t) + + err := SendDiscordNotification(DiscordNotificationData{ + WebhookURL: "invalid url", + Content: "Test", + }) + require.Error(err) +} + +func TestSendDiscordNotification_NetworkError(t *testing.T) { + require := require.New(t) + + err := SendDiscordNotification(DiscordNotificationData{ + WebhookURL: "http://nonexistent.invalid", + Content: "Test", + }) + require.Error(err) +} + +func TestLoadWebhookURL_Success(t *testing.T) { + require := require.New(t) + + webhookURL := "https://discord.com/api/webhooks/123/abc" + t.Setenv("DISCORD_WEBHOOK_URL", webhookURL) + + result := LoadWebhookURL() + require.Equal(webhookURL, result) +} + +func TestLoadWebhookURL_Missing(t *testing.T) { + require := require.New(t) + + t.Setenv("DISCORD_WEBHOOK_URL", "") + + result := LoadWebhookURL() + require.Equal("", result) +} + +func TestSendDiscordNotification_PayloadContent(t *testing.T) { + require := require.New(t) + + var receivedPayload map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(err) + + err = json.Unmarshal(body, &receivedPayload) + require.NoError(err) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + testContent := "Test import for cycle 2404" + err := SendDiscordNotification(DiscordNotificationData{ + WebhookURL: server.URL, + Content: testContent, + }) + require.NoError(err) + + require.Equal(testContent, receivedPayload["content"]) +}