diff --git a/config/config.go b/config/config.go index 3d821fb..6d20fd2 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,6 @@ package config -// Environment +// Config holds all configuration settings for chatz providers. type Config struct { Provider string WebHookURL string diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..70adb4d --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,360 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestConfig_Struct(t *testing.T) { + // Test that Config struct can be created and fields can be set + cfg := &Config{ + Provider: "slack", + WebHookURL: "https://hooks.slack.com/test", + Token: "test-token", + ChannelId: "C1234567890", + ChatId: "123456789", + ConnectionURL: "redis://localhost:6379", + SMTPHost: "smtp.gmail.com", + SMTPPort: "587", + UseTLS: false, + UseSTARTTLS: true, + SMTPUser: "test@example.com", + SMTPPassword: "test-password", + SMTPSubject: "Test Subject", + SMTPFrom: "from@example.com", + SMTPTo: "to@example.com", + GotifyURL: "https://gotify.example.com", + GotifyToken: "gotify-token", + GotifyTitle: "Test Title", + GotifyPriority: 5, + } + + // Verify all fields are set correctly + if cfg.Provider != "slack" { + t.Errorf("expected Provider 'slack', got '%s'", cfg.Provider) + } + if cfg.WebHookURL != "https://hooks.slack.com/test" { + t.Errorf("expected WebHookURL 'https://hooks.slack.com/test', got '%s'", cfg.WebHookURL) + } + if cfg.Token != "test-token" { + t.Errorf("expected Token 'test-token', got '%s'", cfg.Token) + } + if cfg.ChannelId != "C1234567890" { + t.Errorf("expected ChannelId 'C1234567890', got '%s'", cfg.ChannelId) + } + if cfg.ChatId != "123456789" { + t.Errorf("expected ChatId '123456789', got '%s'", cfg.ChatId) + } + if cfg.ConnectionURL != "redis://localhost:6379" { + t.Errorf("expected ConnectionURL 'redis://localhost:6379', got '%s'", cfg.ConnectionURL) + } + if cfg.SMTPHost != "smtp.gmail.com" { + t.Errorf("expected SMTPHost 'smtp.gmail.com', got '%s'", cfg.SMTPHost) + } + if cfg.SMTPPort != "587" { + t.Errorf("expected SMTPPort '587', got '%s'", cfg.SMTPPort) + } + if cfg.UseTLS != false { + t.Errorf("expected UseTLS false, got %v", cfg.UseTLS) + } + if cfg.UseSTARTTLS != true { + t.Errorf("expected UseSTARTTLS true, got %v", cfg.UseSTARTTLS) + } + if cfg.SMTPUser != "test@example.com" { + t.Errorf("expected SMTPUser 'test@example.com', got '%s'", cfg.SMTPUser) + } + if cfg.SMTPPassword != "test-password" { + t.Errorf("expected SMTPPassword 'test-password', got '%s'", cfg.SMTPPassword) + } + if cfg.SMTPSubject != "Test Subject" { + t.Errorf("expected SMTPSubject 'Test Subject', got '%s'", cfg.SMTPSubject) + } + if cfg.SMTPFrom != "from@example.com" { + t.Errorf("expected SMTPFrom 'from@example.com', got '%s'", cfg.SMTPFrom) + } + if cfg.SMTPTo != "to@example.com" { + t.Errorf("expected SMTPTo 'to@example.com', got '%s'", cfg.SMTPTo) + } + if cfg.GotifyURL != "https://gotify.example.com" { + t.Errorf("expected GotifyURL 'https://gotify.example.com', got '%s'", cfg.GotifyURL) + } + if cfg.GotifyToken != "gotify-token" { + t.Errorf("expected GotifyToken 'gotify-token', got '%s'", cfg.GotifyToken) + } + if cfg.GotifyTitle != "Test Title" { + t.Errorf("expected GotifyTitle 'Test Title', got '%s'", cfg.GotifyTitle) + } + if cfg.GotifyPriority != 5 { + t.Errorf("expected GotifyPriority 5, got %d", cfg.GotifyPriority) + } +} + +func TestConfig_ProviderConfigurations(t *testing.T) { + tests := []struct { + name string + config Config + provider string + }{ + { + name: "slack configuration", + config: Config{ + Provider: "slack", + Token: "slack-token", + ChannelId: "C1234567890", + }, + provider: "slack", + }, + { + name: "discord configuration", + config: Config{ + Provider: "discord", + WebHookURL: "https://discord.com/api/webhooks/test", + }, + provider: "discord", + }, + { + name: "telegram configuration", + config: Config{ + Provider: "telegram", + Token: "telegram-token", + ChatId: "123456789", + }, + provider: "telegram", + }, + { + name: "smtp configuration", + config: Config{ + Provider: "smtp", + SMTPHost: "smtp.gmail.com", + SMTPPort: "587", + UseSTARTTLS: true, + SMTPUser: "user@example.com", + SMTPPassword: "password", + SMTPFrom: "from@example.com", + SMTPTo: "to@example.com", + }, + provider: "smtp", + }, + { + name: "redis configuration", + config: Config{ + Provider: "redis", + ConnectionURL: "redis://localhost:6379", + ChannelId: "test-channel", + }, + provider: "redis", + }, + { + name: "gotify configuration", + config: Config{ + Provider: "gotify", + GotifyURL: "https://gotify.example.com", + GotifyToken: "gotify-token", + GotifyPriority: 5, + }, + provider: "gotify", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.config.Provider != tt.provider { + t.Errorf("expected Provider '%s', got '%s'", tt.provider, tt.config.Provider) + } + + // Verify the config struct is properly initialized + configType := reflect.TypeOf(tt.config) + configValue := reflect.ValueOf(tt.config) + + for i := 0; i < configType.NumField(); i++ { + field := configType.Field(i) + value := configValue.Field(i) + + // Check that fields are accessible (not panicking) + _ = field.Name + _ = value.Interface() + } + }) + } +} + +func TestConfig_ZeroValues(t *testing.T) { + // Test zero values of Config struct + var cfg Config + + // Verify zero values + if cfg.Provider != "" { + t.Errorf("expected zero value for Provider, got '%s'", cfg.Provider) + } + if cfg.WebHookURL != "" { + t.Errorf("expected zero value for WebHookURL, got '%s'", cfg.WebHookURL) + } + if cfg.Token != "" { + t.Errorf("expected zero value for Token, got '%s'", cfg.Token) + } + if cfg.ChannelId != "" { + t.Errorf("expected zero value for ChannelId, got '%s'", cfg.ChannelId) + } + if cfg.UseTLS != false { + t.Errorf("expected zero value for UseTLS, got %v", cfg.UseTLS) + } + if cfg.UseSTARTTLS != false { + t.Errorf("expected zero value for UseSTARTTLS, got %v", cfg.UseSTARTTLS) + } + if cfg.GotifyPriority != 0 { + t.Errorf("expected zero value for GotifyPriority, got %d", cfg.GotifyPriority) + } +} + +func TestConfig_InvalidDataTypes(t *testing.T) { + tests := []struct { + name string + config Config + expectError bool + description string + }{ + { + name: "invalid port number", + config: Config{ + SMTPPort: "not-a-number", + }, + expectError: false, // Config struct doesn't validate data types + description: "Config struct accepts any string values", + }, + { + name: "negative priority", + config: Config{ + GotifyPriority: -1, + }, + expectError: false, // Config struct doesn't validate ranges + description: "Config struct accepts any int values", + }, + { + name: "very large priority", + config: Config{ + GotifyPriority: 999999, + }, + expectError: false, + description: "Config struct accepts any int values", + }, + { + name: "empty strings", + config: Config{ + Provider: "", + WebHookURL: "", + Token: "", + ChannelId: "", + ChatId: "", + ConnectionURL: "", + SMTPHost: "", + SMTPPort: "", + SMTPUser: "", + SMTPPassword: "", + SMTPSubject: "", + SMTPFrom: "", + SMTPTo: "", + GotifyURL: "", + GotifyToken: "", + GotifyTitle: "", + }, + expectError: false, + description: "All string fields can be empty", + }, + { + name: "very long strings", + config: Config{ + Provider: string(make([]byte, 10000)), + WebHookURL: string(make([]byte, 10000)), + Token: string(make([]byte, 10000)), + ChannelId: string(make([]byte, 10000)), + ChatId: string(make([]byte, 10000)), + ConnectionURL: string(make([]byte, 10000)), + SMTPHost: string(make([]byte, 10000)), + SMTPPort: string(make([]byte, 10000)), + SMTPUser: string(make([]byte, 10000)), + SMTPPassword: string(make([]byte, 10000)), + SMTPSubject: string(make([]byte, 10000)), + SMTPFrom: string(make([]byte, 10000)), + SMTPTo: string(make([]byte, 10000)), + GotifyURL: string(make([]byte, 10000)), + GotifyToken: string(make([]byte, 10000)), + GotifyTitle: string(make([]byte, 10000)), + }, + expectError: false, + description: "Config struct accepts very long strings", + }, + { + name: "strings with special characters", + config: Config{ + Provider: "provider_with_émojis_🎉_and_spëcial_chärs", + WebHookURL: "https://example.com/path with spaces?query=value&other=🎉", + Token: "token-with-special-chars!@#$%^&*()", + ChannelId: "channel_123_!@#", + ChatId: "chat-456_!@#", + ConnectionURL: "redis://user:pass@host:6379/db?param=value&other=🎉", + SMTPHost: "smtp.gmail.com", + SMTPPort: "587", + SMTPUser: "user+tag@example.com", + SMTPPassword: "pass!@#$%^&*()", + SMTPSubject: "Subject with émojis 🎉", + SMTPFrom: "from+tag@example.com", + SMTPTo: "to1@example.com,to2@example.com", + GotifyURL: "https://gotify.example.com", + GotifyToken: "token_!@#$%^&*()", + GotifyTitle: "Title with spëcial chärs 🎉", + }, + expectError: false, + description: "Config struct accepts strings with special characters", + }, + { + name: "malformed URLs", + config: Config{ + WebHookURL: "not-a-url", + ConnectionURL: "invalid-url-format", + GotifyURL: "also-not-a-url", + }, + expectError: false, + description: "Config struct doesn't validate URL formats", + }, + { + name: "invalid email formats", + config: Config{ + SMTPUser: "not-an-email", + SMTPFrom: "also-not-an-email", + SMTPTo: "invalid-email-1,invalid-email-2", + }, + expectError: false, + description: "Config struct doesn't validate email formats", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Config struct doesn't perform validation, so we just test that it can hold the values + if tt.expectError { + t.Errorf("unexpected: Config struct should not validate data, but test expects error: %s", tt.description) + } + + // Verify the config struct can hold these values without panicking + _ = tt.config.Provider + _ = tt.config.WebHookURL + _ = tt.config.Token + _ = tt.config.ChannelId + _ = tt.config.ChatId + _ = tt.config.ConnectionURL + _ = tt.config.SMTPHost + _ = tt.config.SMTPPort + _ = tt.config.UseTLS + _ = tt.config.UseSTARTTLS + _ = tt.config.SMTPUser + _ = tt.config.SMTPPassword + _ = tt.config.SMTPSubject + _ = tt.config.SMTPFrom + _ = tt.config.SMTPTo + _ = tt.config.GotifyURL + _ = tt.config.GotifyToken + _ = tt.config.GotifyTitle + _ = tt.config.GotifyPriority + }) + } +} diff --git a/constants/common.go b/constants/common.go index 0f68df5..2e16ca5 100644 --- a/constants/common.go +++ b/constants/common.go @@ -1,5 +1,6 @@ package constants +// ProviderType represents the type of messaging provider. type ProviderType string const ( diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..59b44bf --- /dev/null +++ b/main_test.go @@ -0,0 +1,237 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/urfave/cli/v2" +) + +// TestCLISetup tests that the CLI application is properly configured. +func TestCLISetup(t *testing.T) { + // We need to create a minimal version of the main function logic + // to test the CLI setup without calling app.Run() + + // Create the app as it would be in main() + app := cli.NewApp() + app.Name = "chatz" + app.Description = "chatz is a versatile messaging app designed to send notifications to Google Chat, Slack, Discord, Telegram and Redis." + + // Test basic app properties + if app.Name != "chatz" { + t.Errorf("expected app name 'chatz', got '%s'", app.Name) + } + + expectedDesc := "chatz is a versatile messaging app designed to send notifications to Google Chat, Slack, Discord, Telegram and Redis." + if app.Description != expectedDesc { + t.Errorf("app description mismatch") + } +} + +// TestCLIFlags tests that CLI flags are properly defined. +func TestCLIFlags(t *testing.T) { + // Create flags as they would be in main() + flags := []cli.Flag{ + &cli.BoolFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Print output to stdout", + Destination: &[]bool{false}[0], // dummy destination + }, + &cli.StringFlag{ + Name: "profile", + Aliases: []string{"p"}, + Value: "default", + Usage: "Profile from .chatz.ini", + Destination: &[]string{""}[0], // dummy destination + }, + &cli.StringFlag{ + Name: "thread-id", + Aliases: []string{"t"}, + Value: "", + Usage: "Thread ID for reply to a message", + Destination: &[]string{""}[0], // dummy destination + }, + &cli.BoolFlag{ + Name: "version", + Aliases: []string{"v"}, + Usage: "Print the version number", + Destination: &[]bool{false}[0], // dummy destination + }, + &cli.BoolFlag{ + Name: "from-env", + Aliases: []string{"e"}, + Usage: "To use config from environment variables", + Destination: &[]bool{false}[0], // dummy destination + }, + &cli.StringFlag{ + Name: "subject", + Aliases: []string{"s"}, + Usage: "Subject for provider which supports subject or title", + Destination: &[]string{""}[0], // dummy destination + }, + &cli.IntFlag{ + Name: "priority", + Aliases: []string{"pr"}, + Usage: "Priority for gotify notification", + Destination: &[]int{0}[0], // dummy destination + }, + } + + // Test that we have the expected number of flags + expectedFlags := 7 + if len(flags) != expectedFlags { + t.Errorf("expected %d flags, got %d", expectedFlags, len(flags)) + } + + // Test specific flags + flagNames := make(map[string]bool) + for _, flag := range flags { + switch f := flag.(type) { + case *cli.BoolFlag: + flagNames[f.Name] = true + if f.Name == "version" && len(f.Aliases) == 0 { + t.Error("version flag should have aliases") + } + case *cli.StringFlag: + flagNames[f.Name] = true + if f.Name == "profile" && f.Value != "default" { + t.Error("profile flag should default to 'default'") + } + case *cli.IntFlag: + flagNames[f.Name] = true + } + } + + // Verify we have all expected flags + expectedFlagNames := []string{"output", "profile", "thread-id", "version", "from-env", "subject", "priority"} + for _, name := range expectedFlagNames { + if !flagNames[name] { + t.Errorf("missing expected flag: %s", name) + } + } +} + +// TestAppCreation tests that the CLI app can be created without errors. +func TestAppCreation(t *testing.T) { + // This tests that the app setup doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("app creation panicked: %v", r) + } + }() + + // We can't fully test main() without it calling app.Run(), + // but we can test that the app struct can be created + app := &cli.App{ + Name: "chatz", + Description: "test description", + } + + if app.Name != "chatz" { + t.Error("app name not set correctly") + } +} + +// TestVersionOutput tests the version flag behavior by running the binary. +func TestVersionOutput(t *testing.T) { + // Skip this test if we're not running the integration test + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Build the binary first + buildCmd := exec.Command("go", "build", "-o", "chatz-test", ".") + if err := buildCmd.Run(); err != nil { + t.Fatalf("failed to build binary: %v", err) + } + defer os.Remove("chatz-test") // cleanup + + // Run the binary with --version flag + cmd := exec.Command("./chatz-test", "--version") + output, err := cmd.Output() + if err != nil { + t.Errorf("command failed: %v", err) + } + + outputStr := string(output) + if !strings.Contains(outputStr, "chatz version:") { + t.Errorf("expected version output, got: %s", outputStr) + } + if !strings.Contains(outputStr, "Commit Hash:") { + t.Errorf("expected commit hash in output, got: %s", outputStr) + } + if !strings.Contains(outputStr, "Build Date:") { + t.Errorf("expected build date in output, got: %s", outputStr) + } +} + +// TestNoArgsOutput tests the behavior when no arguments are provided. +func TestNoArgsOutput(t *testing.T) { + // Skip this test if we're not running the integration test + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Build the binary first + buildCmd := exec.Command("go", "build", "-o", "chatz-test-noargs", ".") + if err := buildCmd.Run(); err != nil { + t.Fatalf("failed to build binary: %v", err) + } + defer os.Remove("chatz-test-noargs") // cleanup + + // Run the binary with no arguments + cmd := exec.Command("./chatz-test-noargs") + output, err := cmd.Output() + if err != nil { + // This is expected to fail since no message is provided + // The CLI framework will exit with code 1 + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", exitErr.ExitCode()) + } + } else { + t.Errorf("unexpected error type: %v", err) + } + } + + outputStr := string(output) + if !strings.Contains(outputStr, "Please provide a message.") { + t.Errorf("expected 'Please provide a message.' in output, got: %s", outputStr) + } +} + +// TestHelpOutput tests the help flag behavior. +func TestHelpOutput(t *testing.T) { + // Skip this test if we're not running the integration test + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Build the binary first + buildCmd := exec.Command("go", "build", "-o", "chatz-test-help", ".") + if err := buildCmd.Run(); err != nil { + t.Fatalf("failed to build binary: %v", err) + } + defer os.Remove("chatz-test-help") // cleanup + + // Run the binary with --help flag + cmd := exec.Command("./chatz-test-help", "--help") + output, err := cmd.Output() + if err != nil { + t.Errorf("command failed: %v", err) + } + + outputStr := string(output) + if !strings.Contains(outputStr, "chatz") { + t.Errorf("expected 'chatz' in help output, got: %s", outputStr) + } + if !strings.Contains(outputStr, "USAGE") { + t.Errorf("expected 'USAGE' in help output, got: %s", outputStr) + } + if !strings.Contains(outputStr, "GLOBAL OPTIONS") { + t.Errorf("expected 'GLOBAL OPTIONS' in help output, got: %s", outputStr) + } +} diff --git a/models/base.go b/models/base.go index c3f7804..3a3c58a 100644 --- a/models/base.go +++ b/models/base.go @@ -1,5 +1,6 @@ package models +// Option holds optional parameters for messaging operations. type Option struct { Priority *int `json:"priority"` Title *string `json:"title"` diff --git a/models/models_test.go b/models/models_test.go new file mode 100644 index 0000000..a7eb732 --- /dev/null +++ b/models/models_test.go @@ -0,0 +1,393 @@ +package models + +import ( + "encoding/json" + "testing" +) + +func TestOption_Struct(t *testing.T) { + // Test Option struct with nil pointers (zero values) + var option Option + + if option.Priority != nil { + t.Errorf("expected Priority to be nil, got %v", option.Priority) + } + if option.Title != nil { + t.Errorf("expected Title to be nil, got %v", option.Title) + } + if option.Subject != nil { + t.Errorf("expected Subject to be nil, got %v", option.Subject) + } +} + +func TestOption_WithValues(t *testing.T) { + // Test Option struct with actual values + priority := 5 + title := "Test Title" + subject := "Test Subject" + + option := Option{ + Priority: &priority, + Title: &title, + Subject: &subject, + } + + if option.Priority == nil || *option.Priority != 5 { + t.Errorf("expected Priority to be 5, got %v", option.Priority) + } + if option.Title == nil || *option.Title != "Test Title" { + t.Errorf("expected Title to be 'Test Title', got %v", option.Title) + } + if option.Subject == nil || *option.Subject != "Test Subject" { + t.Errorf("expected Subject to be 'Test Subject', got %v", option.Subject) + } +} + +func TestOption_JSONMarshal(t *testing.T) { + tests := []struct { + name string + option Option + expected string + }{ + { + name: "empty option", + option: Option{}, + expected: `{"priority":null,"title":null,"subject":null}`, + }, + { + name: "option with values", + option: Option{ + Priority: func() *int { i := 10; return &i }(), + Title: func() *string { s := "Hello"; return &s }(), + Subject: func() *string { s := "World"; return &s }(), + }, + expected: `{"priority":10,"title":"Hello","subject":"World"}`, + }, + { + name: "option with some nil values", + option: Option{ + Priority: func() *int { i := 1; return &i }(), + Title: nil, + Subject: func() *string { s := "Test"; return &s }(), + }, + expected: `{"priority":1,"title":null,"subject":"Test"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.option) + if err != nil { + t.Errorf("failed to marshal option: %v", err) + return + } + + if string(data) != tt.expected { + t.Errorf("expected JSON '%s', got '%s'", tt.expected, string(data)) + } + }) + } +} + +func TestOption_JSONUnmarshal(t *testing.T) { + tests := []struct { + name string + jsonStr string + expected Option + }{ + { + name: "empty json", + jsonStr: `{"priority":null,"title":null,"subject":null}`, + expected: Option{ + Priority: nil, + Title: nil, + Subject: nil, + }, + }, + { + name: "json with values", + jsonStr: `{"priority":7,"title":"Test Title","subject":"Test Subject"}`, + expected: Option{ + Priority: func() *int { i := 7; return &i }(), + Title: func() *string { s := "Test Title"; return &s }(), + Subject: func() *string { s := "Test Subject"; return &s }(), + }, + }, + { + name: "json with partial values", + jsonStr: `{"priority":3,"title":null,"subject":"Only Subject"}`, + expected: Option{ + Priority: func() *int { i := 3; return &i }(), + Title: nil, + Subject: func() *string { s := "Only Subject"; return &s }(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var option Option + err := json.Unmarshal([]byte(tt.jsonStr), &option) + if err != nil { + t.Errorf("failed to unmarshal JSON: %v", err) + return + } + + // Compare Priority + if tt.expected.Priority == nil && option.Priority != nil { + t.Errorf("expected Priority to be nil, got %v", option.Priority) + } + if tt.expected.Priority != nil && (option.Priority == nil || *option.Priority != *tt.expected.Priority) { + t.Errorf("expected Priority %v, got %v", tt.expected.Priority, option.Priority) + } + + // Compare Title + if tt.expected.Title == nil && option.Title != nil { + t.Errorf("expected Title to be nil, got %v", option.Title) + } + if tt.expected.Title != nil && (option.Title == nil || *option.Title != *tt.expected.Title) { + t.Errorf("expected Title %v, got %v", tt.expected.Title, option.Title) + } + + // Compare Subject + if tt.expected.Subject == nil && option.Subject != nil { + t.Errorf("expected Subject to be nil, got %v", option.Subject) + } + if tt.expected.Subject != nil && (option.Subject == nil || *option.Subject != *tt.expected.Subject) { + t.Errorf("expected Subject %v, got %v", tt.expected.Subject, option.Subject) + } + }) + } +} + +func TestOption_PointerHandling(t *testing.T) { + // Test proper handling of pointer fields + var option Option + + // Initially all should be nil + if option.Priority != nil || option.Title != nil || option.Subject != nil { + t.Error("expected all fields to be initially nil") + } + + // Set values using pointers + priorityVal := 8 + titleVal := "Pointer Test" + subjectVal := "Pointer Subject" + + option.Priority = &priorityVal + option.Title = &titleVal + option.Subject = &subjectVal + + // Verify values + if option.Priority == nil || *option.Priority != 8 { + t.Errorf("expected Priority to be 8, got %v", option.Priority) + } + if option.Title == nil || *option.Title != "Pointer Test" { + t.Errorf("expected Title to be 'Pointer Test', got %v", option.Title) + } + if option.Subject == nil || *option.Subject != "Pointer Subject" { + t.Errorf("expected Subject to be 'Pointer Subject', got %v", option.Subject) + } + + // Test modifying through pointers + *option.Priority = 9 + *option.Title = "Modified Title" + + if *option.Priority != 9 { + t.Errorf("expected modified Priority to be 9, got %d", *option.Priority) + } + if *option.Title != "Modified Title" { + t.Errorf("expected modified Title to be 'Modified Title', got '%s'", *option.Title) + } +} + +func TestOption_JSONEdgeCases(t *testing.T) { + tests := []struct { + name string + jsonStr string + expectError bool + description string + }{ + { + name: "malformed json", + jsonStr: `{"priority": 5, "title": "test"`, + expectError: true, + description: "JSON unmarshaling should fail with malformed JSON", + }, + { + name: "invalid priority type", + jsonStr: `{"priority": "not-a-number", "title": "test"}`, + expectError: true, // JSON unmarshaling fails on type mismatch for int fields + description: "JSON unmarshaling fails when trying to unmarshal string into int field", + }, + { + name: "null values", + jsonStr: `{"priority": null, "title": null, "subject": null}`, + expectError: false, + description: "Null values should be handled correctly", + }, + { + name: "empty json object", + jsonStr: `{}`, + expectError: false, + description: "Empty JSON object should work", + }, + { + name: "json with extra fields", + jsonStr: `{"priority": 1, "title": "test", "subject": "subject", "extra_field": "ignored"}`, + expectError: false, + description: "Extra fields in JSON should be ignored", + }, + { + name: "unicode characters", + jsonStr: `{"title": "Test with émojis 🎉 and spëcial chärs"}`, + expectError: false, + description: "Unicode characters should be handled correctly", + }, + { + name: "escaped characters", + jsonStr: `{"title": "Test with \"quotes\" and \\backslashes\\"}`, + expectError: false, + description: "Escaped characters should be handled correctly", + }, + { + name: "very large numbers", + jsonStr: `{"priority": 999999999999}`, + expectError: false, + description: "Large numbers should be handled", + }, + { + name: "negative numbers", + jsonStr: `{"priority": -5}`, + expectError: false, + description: "Negative numbers should be accepted", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var option Option + err := json.Unmarshal([]byte(tt.jsonStr), &option) + + if tt.expectError { + if err == nil { + t.Errorf("expected error for %s, but got none", tt.description) + } + } else { + if err != nil { + t.Errorf("unexpected error for %s: %v", tt.description, err) + } + } + }) + } +} + +func TestOption_ExtremeValues(t *testing.T) { + tests := []struct { + name string + setup func() Option + validate func(t *testing.T, option Option) + }{ + { + name: "maximum int value", + setup: func() Option { + maxInt := 9223372036854775807 // Max int64 + return Option{Priority: &maxInt} + }, + validate: func(t *testing.T, option Option) { + if option.Priority == nil || *option.Priority != 9223372036854775807 { + t.Errorf("expected max int value, got %v", option.Priority) + } + }, + }, + { + name: "minimum int value", + setup: func() Option { + minInt := -9223372036854775808 // Min int64 + return Option{Priority: &minInt} + }, + validate: func(t *testing.T, option Option) { + if option.Priority == nil || *option.Priority != -9223372036854775808 { + t.Errorf("expected min int value, got %v", option.Priority) + } + }, + }, + { + name: "zero priority", + setup: func() Option { + zero := 0 + return Option{Priority: &zero} + }, + validate: func(t *testing.T, option Option) { + if option.Priority == nil || *option.Priority != 0 { + t.Errorf("expected zero priority, got %v", option.Priority) + } + }, + }, + { + name: "empty strings", + setup: func() Option { + empty := "" + return Option{ + Title: &empty, + Subject: &empty, + } + }, + validate: func(t *testing.T, option Option) { + if option.Title == nil || *option.Title != "" { + t.Errorf("expected empty title, got %v", option.Title) + } + if option.Subject == nil || *option.Subject != "" { + t.Errorf("expected empty subject, got %v", option.Subject) + } + }, + }, + { + name: "strings with only whitespace", + setup: func() Option { + whitespace := " \t\n " + return Option{ + Title: &whitespace, + Subject: &whitespace, + } + }, + validate: func(t *testing.T, option Option) { + expected := " \t\n " + if option.Title == nil || *option.Title != expected { + t.Errorf("expected whitespace title, got %v", option.Title) + } + if option.Subject == nil || *option.Subject != expected { + t.Errorf("expected whitespace subject, got %v", option.Subject) + } + }, + }, + { + name: "very long strings", + setup: func() Option { + longString := string(make([]byte, 100000)) // 100KB string + for i := range longString { + longString = longString[:i] + "a" + longString[i+1:] + } + return Option{ + Title: &longString, + Subject: &longString, + } + }, + validate: func(t *testing.T, option Option) { + if option.Title == nil || len(*option.Title) != 100000 { + t.Errorf("expected 100KB title, got length %d", len(*option.Title)) + } + if option.Subject == nil || len(*option.Subject) != 100000 { + t.Errorf("expected 100KB subject, got length %d", len(*option.Subject)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := tt.setup() + tt.validate(t, option) + }) + } +} diff --git a/providers/agent.go b/providers/agent.go index 889bc78..420f493 100644 --- a/providers/agent.go +++ b/providers/agent.go @@ -10,16 +10,19 @@ import ( var providerRegistry = make(map[constants.ProviderType]Provider) +// Provider defines the interface for messaging providers. type Provider interface { setup(env *config.Config) error Post(message string, option models.Option) (any, error) Reply(threadId string, message string, option models.Option) (any, error) } +// RegisterProvider registers a provider implementation for a given provider type. func RegisterProvider(providerType constants.ProviderType, provider Provider) { providerRegistry[providerType] = provider } +// NewProvider creates and initializes a provider based on the configuration. func NewProvider(config *config.Config) (Provider, error) { if provider, ok := providerRegistry[constants.ProviderType(config.Provider)]; ok { err := provider.setup(config) diff --git a/providers/provider_test.go b/providers/provider_test.go new file mode 100644 index 0000000..d948df1 --- /dev/null +++ b/providers/provider_test.go @@ -0,0 +1,436 @@ +package providers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/tech-thinker/chatz/config" + "github.com/tech-thinker/chatz/models" +) + +// MockTransport allows us to intercept HTTP calls for testing +type MockTransport struct { + RoundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return m.RoundTripFunc(req) +} + +func TestSlackProvider_Post(t *testing.T) { + tests := []struct { + name string + message string + mockResponse interface{} // Changed to interface{} to allow strings for malformed JSON + mockStatusCode int + expectError bool + mockTransportError error // For network-level errors + }{ + { + name: "successful post", + message: "test message", + mockResponse: map[string]interface{}{ + "ok": true, + "channel": "C1234567890", + "ts": "1503435956.000247", + }, + mockStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "slack api error", + message: "test message", + mockResponse: map[string]interface{}{ + "ok": false, + "error": "invalid_auth", + }, + mockStatusCode: http.StatusOK, + expectError: false, // HTTP call succeeds, but API returns error + }, + { + name: "http server error", + message: "test message", + mockResponse: map[string]interface{}{ + "ok": false, + "error": "server_error", + }, + mockStatusCode: http.StatusInternalServerError, + expectError: false, // HTTP client doesn't fail, just returns error response + }, + { + name: "malformed json response", + message: "test message", + mockResponse: "{invalid json", + mockStatusCode: http.StatusOK, + expectError: false, // Slack provider just returns the response as string + }, + { + name: "network connection error", + message: "test message", + mockTransportError: &http.ProtocolError{ErrorString: "connection refused"}, + expectError: true, + }, + { + name: "empty message", + message: "", + mockResponse: map[string]interface{}{"ok": true}, + mockStatusCode: http.StatusOK, + expectError: false, // Empty message should still work + }, + { + name: "very long message", + message: string(make([]byte, 10000)), // 10KB message + mockResponse: map[string]interface{}{"ok": true}, + mockStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "message with special characters", + message: "test message with émojis 🎉 and spëcial chärs", + mockResponse: map[string]interface{}{"ok": true}, + mockStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "message with newlines and tabs", + message: "line1\nline2\tindented", + mockResponse: map[string]interface{}{"ok": true}, + mockStatusCode: http.StatusOK, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP client + mockTransport := &MockTransport{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + // Return network error if specified + if tt.mockTransportError != nil { + return nil, tt.mockTransportError + } + + // Verify request for non-error cases + if tt.name != "network connection error" { + if req.Method != "POST" { + t.Errorf("expected POST request, got %s", req.Method) + } + if req.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer test-token, got %s", req.Header.Get("Authorization")) + } + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json, got %s", req.Header.Get("Content-Type")) + } + } + + // Create mock response body + var body bytes.Buffer + if tt.mockResponse != nil { + switch resp := tt.mockResponse.(type) { + case map[string]interface{}: + json.NewEncoder(&body).Encode(resp) + case string: + body.WriteString(resp) // For malformed JSON + } + } + + return &http.Response{ + StatusCode: tt.mockStatusCode, + Body: io.NopCloser(&body), + Header: make(http.Header), + }, nil + }, + } + + // Temporarily replace the default HTTP client + originalClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: mockTransport} + defer func() { http.DefaultClient = originalClient }() + + // Create provider with test config + cfg := &config.Config{ + Token: "test-token", + ChannelId: "C1234567890", + } + provider := &SlackProvider{config: cfg} + + option := models.Option{} + result, err := provider.Post(tt.message, option) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == nil { + t.Error("expected result but got nil") + } + } + }) + } +} + +func TestSlackProvider_Reply(t *testing.T) { + // Create a mock HTTP client that returns an error + mockTransport := &MockTransport{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + // Verify request method and headers + if req.Method != "POST" { + t.Errorf("expected POST request, got %s", req.Method) + } + if req.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer test-token, got %s", req.Header.Get("Authorization")) + } + + // Return a mock error response + body := bytes.NewBufferString(`{"ok":false,"error":"invalid_auth"}`) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(body), + Header: make(http.Header), + }, nil + }, + } + + // Temporarily replace the default HTTP client + originalClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: mockTransport} + defer func() { http.DefaultClient = originalClient }() + + cfg := &config.Config{ + Token: "test-token", + ChannelId: "C1234567890", + } + provider := &SlackProvider{config: cfg} + + option := models.Option{} + result, err := provider.Reply("thread-123", "reply message", option) + + // Should not error on HTTP call, but API returns error response + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == nil { + t.Error("expected result, got nil") + } +} + +func TestNewProvider(t *testing.T) { + tests := []struct { + name string + provider string + expectError bool + }{ + { + name: "valid slack provider", + provider: "slack", + expectError: false, // Slack provider should be registered + }, + { + name: "valid discord provider", + provider: "discord", + expectError: false, + }, + { + name: "invalid provider", + provider: "invalid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{Provider: tt.provider} + provider, err := NewProvider(cfg) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if provider == nil { + t.Error("expected provider but got nil") + } + }) + } +} + +func TestSlackProvider_Post_WithOptions(t *testing.T) { + tests := []struct { + name string + message string + option models.Option + expected map[string]interface{} + }{ + { + name: "with title option", + message: "test message", + option: models.Option{ + Title: stringPtr("Custom Title"), + }, + expected: map[string]interface{}{ + "ok": true, + }, + }, + { + name: "with subject option", + message: "test message", + option: models.Option{ + Subject: stringPtr("Custom Subject"), + }, + expected: map[string]interface{}{ + "ok": true, + }, + }, + { + name: "with priority option", + message: "test message", + option: models.Option{ + Priority: intPtr(5), + }, + expected: map[string]interface{}{ + "ok": true, + }, + }, + { + name: "with all options", + message: "test message", + option: models.Option{ + Title: stringPtr("Title"), + Subject: stringPtr("Subject"), + Priority: intPtr(8), + }, + expected: map[string]interface{}{ + "ok": true, + }, + }, + { + name: "with nil options", + message: "test message", + option: models.Option{}, // All nil + expected: map[string]interface{}{ + "ok": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTransport := &MockTransport{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + // Verify request method and headers + if req.Method != "POST" { + t.Errorf("expected POST request, got %s", req.Method) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)), + Header: make(http.Header), + }, nil + }, + } + + originalClient := http.DefaultClient + http.DefaultClient = &http.Client{Transport: mockTransport} + defer func() { http.DefaultClient = originalClient }() + + cfg := &config.Config{ + Token: "test-token", + ChannelId: "C1234567890", + } + provider := &SlackProvider{config: cfg} + + result, err := provider.Post(tt.message, tt.option) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == nil { + t.Error("expected result but got nil") + } + }) + } +} + +func TestSlackProvider_Setup(t *testing.T) { + tests := []struct { + name string + config *config.Config + expectError bool + }{ + { + name: "valid slack config", + config: &config.Config{ + Token: "test-token", + ChannelId: "C1234567890", + }, + expectError: false, + }, + { + name: "missing token", + config: &config.Config{ + ChannelId: "C1234567890", + }, + expectError: false, // Slack provider doesn't validate config in setup + }, + { + name: "missing channel", + config: &config.Config{ + Token: "test-token", + }, + expectError: false, // Slack provider doesn't validate config in setup + }, + { + name: "nil config", + config: nil, + expectError: false, // Slack provider doesn't validate config in setup + }, + { + name: "empty config", + config: &config.Config{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &SlackProvider{} + err := provider.setup(tt.config) + + if tt.expectError && err == nil { + t.Error("expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Verify config was set (unless nil config) + if !tt.expectError && tt.config != nil && provider.config != tt.config { + t.Error("config was not set properly") + } + }) + } +} + +// Helper functions for creating pointers +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/utils/common.go b/utils/common.go index e15c5ae..f2a5686 100644 --- a/utils/common.go +++ b/utils/common.go @@ -9,6 +9,7 @@ import ( "github.com/tech-thinker/chatz/config" ) +// LoadEnv loads configuration from environment variables or config file. func LoadEnv(profile string, fromEnv bool) (*config.Config, error) { if fromEnv { return loadEnvFromSystemEnv() @@ -17,6 +18,7 @@ func LoadEnv(profile string, fromEnv bool) (*config.Config, error) { } } +// loadEnvFromSystemEnv loads configuration from system environment variables. func loadEnvFromSystemEnv() (*config.Config, error) { v := viper.New() v.AutomaticEnv() @@ -67,6 +69,7 @@ func loadEnvFromSystemEnv() (*config.Config, error) { return &env, nil } +// loadEnvFromFile loads configuration from the .chatz.ini file in the user's home directory. func loadEnvFromFile(profile string) (*config.Config, error) { // Get the home directory of the user homeDir, err := os.UserHomeDir() diff --git a/utils/pointers.go b/utils/pointers.go index 3eb0748..d07c608 100644 --- a/utils/pointers.go +++ b/utils/pointers.go @@ -1,9 +1,11 @@ package utils +// NewInt creates a pointer to an int value. func NewInt(value int) *int { return &value } +// NewString creates a pointer to a string value. func NewString(value string) *string { return &value } diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..8fcc9f0 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,368 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/tech-thinker/chatz/config" +) + +func TestLoadEnv(t *testing.T) { + tests := []struct { + name string + profile string + fromEnv bool + setupFunc func() func() // returns cleanup function + expectError bool + }{ + { + name: "load from environment variables", + profile: "test", + fromEnv: true, + setupFunc: func() func() { + originalEnv := map[string]string{} + envVars := map[string]string{ + "PROVIDER": "slack", + "TOKEN": "test-token", + "CHANNEL_ID": "C1234567890", + "WEB_HOOK_URL": "https://hooks.slack.com/test", + } + + // Set test environment variables + for key, value := range envVars { + if original, exists := os.LookupEnv(key); exists { + originalEnv[key] = original + } + os.Setenv(key, value) + } + + // Return cleanup function + return func() { + for key := range envVars { + if original, exists := originalEnv[key]; exists { + os.Setenv(key, original) + } else { + os.Unsetenv(key) + } + } + } + }, + expectError: false, + }, + { + name: "load from config file", + profile: "testprofile", + fromEnv: false, + setupFunc: func() func() { + // Create temporary directory and config file + tempDir, err := os.MkdirTemp("", "chatz_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + configContent := `[testprofile] +PROVIDER=telegram +TOKEN=test-bot-token +CHAT_ID=123456789 +WEB_HOOK_URL=https://hooks.telegram.com/test` + + configPath := filepath.Join(tempDir, ".chatz.ini") + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Mock home directory + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + + return func() { + os.Setenv("HOME", originalHome) + os.RemoveAll(tempDir) + } + }, + expectError: false, + }, + { + name: "config file not found", + profile: "nonexistent", + fromEnv: false, + setupFunc: func() func() { + // Set home to non-existent directory + originalHome := os.Getenv("HOME") + os.Setenv("HOME", "/non/existent/path") + + return func() { + os.Setenv("HOME", originalHome) + } + }, + expectError: true, + }, + { + name: "corrupted config file", + profile: "test", + fromEnv: false, + setupFunc: func() func() { + tempDir, err := os.MkdirTemp("", "chatz_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + // Write corrupted INI content + configContent := `[test] +PROVIDER=slack +TOKEN=test-token +INVALID_LINE_WITHOUT_EQUALS +[invalid_section +CHANNEL_ID=C1234567890` + + configPath := filepath.Join(tempDir, ".chatz.ini") + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Mock home directory + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + + return func() { + os.Setenv("HOME", originalHome) + os.RemoveAll(tempDir) + } + }, + expectError: true, // viper validates INI format and fails on parsing errors + }, + { + name: "empty config file", + profile: "test", + fromEnv: false, + setupFunc: func() func() { + tempDir, err := os.MkdirTemp("", "chatz_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + configPath := filepath.Join(tempDir, ".chatz.ini") + err = os.WriteFile(configPath, []byte(""), 0644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + + return func() { + os.Setenv("HOME", originalHome) + os.RemoveAll(tempDir) + } + }, + expectError: false, // Empty file should not cause error + }, + { + name: "config file with permission denied", + profile: "test", + fromEnv: false, + setupFunc: func() func() { + tempDir, err := os.MkdirTemp("", "chatz_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + configContent := `[test] +PROVIDER=slack +TOKEN=test-token` + + configPath := filepath.Join(tempDir, ".chatz.ini") + err = os.WriteFile(configPath, []byte(configContent), 0200) // Write only for owner + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Try to make directory read-only (this might not work on all systems) + os.Chmod(tempDir, 0500) // Read and execute only + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + + return func() { + os.Setenv("HOME", originalHome) + os.RemoveAll(tempDir) + } + }, + expectError: true, // Should fail due to permission issues + }, + { + name: "missing HOME environment variable", + profile: "test", + fromEnv: false, + setupFunc: func() func() { + originalHome := os.Getenv("HOME") + os.Unsetenv("HOME") + + return func() { + if originalHome != "" { + os.Setenv("HOME", originalHome) + } + } + }, + expectError: true, // Should fail when HOME is not set + }, + { + name: "empty profile name", + profile: "", + fromEnv: false, + setupFunc: func() func() { + tempDir, err := os.MkdirTemp("", "chatz_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + configContent := `[] +PROVIDER=slack +TOKEN=test-token` + + configPath := filepath.Join(tempDir, ".chatz.ini") + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + + return func() { + os.Setenv("HOME", originalHome) + os.RemoveAll(tempDir) + } + }, + expectError: true, // Empty section name in INI format is invalid + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupFunc() + defer cleanup() + + result, err := LoadEnv(tt.profile, tt.fromEnv) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result == nil { + t.Error("expected config but got nil") + return + } + + // Verify the config was loaded correctly + if tt.fromEnv { + if result.Provider != "slack" { + t.Errorf("expected provider 'slack', got '%s'", result.Provider) + } + if result.Token != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", result.Token) + } + } else if tt.profile == "testprofile" { + if result.Provider != "telegram" { + t.Errorf("expected provider 'telegram', got '%s'", result.Provider) + } + if result.ChatId != "123456789" { + t.Errorf("expected chat_id '123456789', got '%s'", result.ChatId) + } + } + }) + } +} + +func TestLoadEnvFromSystemEnv(t *testing.T) { + // Test loading from environment variables + envVars := map[string]string{ + "PROVIDER": "discord", + "WEB_HOOK_URL": "https://discord.com/api/webhooks/test", + "TOKEN": "discord-token", + "CHANNEL_ID": "discord-channel", + "CHAT_ID": "telegram-chat", + "CONNECTION_URL": "redis://localhost:6379", + "SMTP_HOST": "smtp.gmail.com", + "SMTP_PORT": "587", + "SMTP_USE_TLS": "false", + "SMTP_USE_STARTTLS": "true", + "SMTP_USER": "test@example.com", + "SMTP_PASSWORD": "test-password", + "SMTP_SUBJECT": "Test Subject", + "SMTP_FROM": "from@example.com", + "SMTP_TO": "to@example.com", + "GOTIFY_URL": "https://gotify.example.com", + "GOTIFY_TOKEN": "gotify-token", + "GOTIFY_TITLE": "Test Title", + "GOTIFY_PRIORITY": "5", + } + + // Backup original environment + originalEnv := make(map[string]string) + for key := range envVars { + if original, exists := os.LookupEnv(key); exists { + originalEnv[key] = original + } + } + + // Set test environment + for key, value := range envVars { + os.Setenv(key, value) + } + + // Cleanup function + defer func() { + for key := range envVars { + if original, exists := originalEnv[key]; exists { + os.Setenv(key, original) + } else { + os.Unsetenv(key) + } + } + }() + + result, err := loadEnvFromSystemEnv() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify all fields are set correctly + expected := &config.Config{ + Provider: "discord", + WebHookURL: "https://discord.com/api/webhooks/test", + Token: "discord-token", + ChannelId: "discord-channel", + ChatId: "telegram-chat", + ConnectionURL: "redis://localhost:6379", + SMTPHost: "smtp.gmail.com", + SMTPPort: "587", + UseTLS: false, + UseSTARTTLS: true, + SMTPUser: "test@example.com", + SMTPPassword: "test-password", + SMTPSubject: "Test Subject", + SMTPFrom: "from@example.com", + SMTPTo: "to@example.com", + GotifyURL: "https://gotify.example.com", + GotifyToken: "gotify-token", + GotifyTitle: "Test Title", + GotifyPriority: 5, + } + + if *result != *expected { + t.Errorf("config mismatch.\nExpected: %+v\nGot: %+v", expected, result) + } +}