From 1579f43a235e392ba9611fb48e1fb00e87423d00 Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 21 Nov 2025 08:57:11 +0200 Subject: [PATCH 1/3] Add comprehensive unit tests and CI workflow Added unit tests for config, JMAP, similarity, and server packages to ensure full test coverage. Updated documentation in CLAUDE.md and README.md to describe test and coverage commands. Introduced a GitHub Actions workflow for automated test execution and coverage reporting. --- .github/workflows/test.yml | 69 ++ CLAUDE.md | 31 +- README.md | 19 + internal/config/config_test.go | 346 ++++++++++ internal/jmap/jmap_test.go | 560 ++++++++++++++++ internal/jmap/mock_test.go | 389 +++++++++++ internal/server/server_test.go | 705 ++++++++++++++++++++ internal/similarity/similarity_test.go | 876 +++++++++++++++++++++++++ 8 files changed, 2993 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 internal/config/config_test.go create mode 100644 internal/jmap/jmap_test.go create mode 100644 internal/jmap/mock_test.go create mode 100644 internal/server/server_test.go create mode 100644 internal/similarity/similarity_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d85b268 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Test Suite + +on: + push: + pull_request: + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run tests with coverage + run: | + go test ./... -v -race -coverprofile=coverage.out -covermode=atomic -json > test-results.json || true + + - name: Generate coverage report + run: | + echo "## Test Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.out | tail -n 1 | awk '{print "**Total Coverage: " $3 "**"}' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage by Package" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Package | Coverage |" >> $GITHUB_STEP_SUMMARY + echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.out | grep -v "total:" | awk '{print "| " $1 " | " $3 " |"}' | sort -u >> $GITHUB_STEP_SUMMARY + + - name: Test Report + uses: dorny/test-reporter@v1 + if: always() + with: + name: Go Test Results + path: test-results.json + reporter: 'go-test' + fail-on-error: true + fail-on-empty: true + + - name: Upload coverage to GitHub + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.out + + - name: Check test results + run: | + if grep -q '"Action":"fail"' test-results.json; then + echo "Some tests failed" + exit 1 + else + echo "All tests passed" + fi diff --git a/CLAUDE.md b/CLAUDE.md index c951b84..dcff20e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,12 +249,25 @@ mock_mode: true # Enable mock mode # Quick start with mock data (no Fastmail account required) go run main.go -config config-mock.yaml.example -# Run all tests (when implemented) +# Run all tests go test ./... +# Run tests with verbose output +go test ./... -v + # Run with race detection go test -race ./... +# Run tests with coverage +go test ./... -cover + +# Generate coverage report +go test ./... -coverprofile=coverage.out +go tool cover -html=coverage.out + +# Run benchmarks +go test ./... -bench=. + # Lint the code (requires golangci-lint) golangci-lint run ``` @@ -363,7 +376,21 @@ log.SetFlags(log.LstdFlags | log.Lshortfile) 3. **Interface Design:** Clean separation of concerns 4. **Memory Management:** Efficient string operations and minimal allocations 5. **Concurrency Safety:** Thread-safe operations where needed -6. **Testing Ready:** Structured for unit test implementation +6. **Comprehensive Testing:** Full unit test coverage with table-driven tests + +### Test Coverage +The project includes comprehensive unit tests for all packages: +- **Config Package:** Configuration loading, validation, and error handling +- **JMAP Package:** Data parsing, mock client functionality, and helper functions +- **Similarity Package:** Fuzzy matching algorithms, Levenshtein distance, email similarity +- **Server Package:** HTTP handlers, API endpoints, and request/response handling + +Tests follow Go best practices: +- Table-driven test design for multiple scenarios +- Clear test naming and organization +- Use of test helpers and fixtures +- Mock clients for external dependencies +- Benchmark tests for performance-critical functions ### Code Style - **Naming:** Clear, descriptive variable and function names diff --git a/README.md b/README.md index e0bef69..8340f58 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,25 @@ mailboxzero/ └── static/ # CSS and JavaScript files ``` +### Running Tests + +The project includes comprehensive unit tests for all packages: + +```bash +# Run all tests +go test ./... + +# Run tests with verbose output +go test ./... -v + +# Run tests with coverage +go test ./... -cover + +# Generate coverage report +go test ./... -coverprofile=coverage.out +go tool cover -html=coverage.out +``` + ## License This project is provided as-is for educational and personal use. diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..78be9f2 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,346 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + configYAML string + wantErr bool + errContains string + }{ + { + name: "valid config", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session + api_token: test-token +dry_run: true +default_similarity: 75 +`, + wantErr: false, + }, + { + name: "valid config in mock mode", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + endpoint: "" + api_token: "" +dry_run: true +default_similarity: 75 +mock_mode: true +`, + wantErr: false, + }, + { + name: "missing jmap endpoint", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + api_token: test-token +dry_run: true +default_similarity: 75 +`, + wantErr: true, + errContains: "JMAP endpoint is required", + }, + { + name: "missing jmap api token", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session +dry_run: true +default_similarity: 75 +`, + wantErr: true, + errContains: "JMAP API token is required", + }, + { + name: "invalid port - negative", + configYAML: ` +server: + port: -1 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session + api_token: test-token +dry_run: true +default_similarity: 75 +`, + wantErr: true, + errContains: "invalid server port", + }, + { + name: "invalid port - too high", + configYAML: ` +server: + port: 99999 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session + api_token: test-token +dry_run: true +default_similarity: 75 +`, + wantErr: true, + errContains: "invalid server port", + }, + { + name: "invalid similarity - negative", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session + api_token: test-token +dry_run: true +default_similarity: -10 +`, + wantErr: true, + errContains: "default similarity must be between 0 and 100", + }, + { + name: "invalid similarity - over 100", + configYAML: ` +server: + port: 8080 + host: localhost +jmap: + endpoint: https://api.fastmail.com/jmap/session + api_token: test-token +dry_run: true +default_similarity: 150 +`, + wantErr: true, + errContains: "default similarity must be between 0 and 100", + }, + { + name: "invalid YAML", + configYAML: ` +server: + port: 8080 + host: localhost + invalid yaml here: [ +`, + wantErr: true, + errContains: "failed to parse config file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configPath, []byte(tt.configYAML), 0644) + if err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + // Test Load function + cfg, err := Load(configPath) + + if tt.wantErr { + if err == nil { + t.Errorf("Load() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("Load() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("Load() unexpected error = %v", err) + } + if cfg == nil { + t.Errorf("Load() returned nil config") + } + } + }) + } +} + +func TestLoadNonexistentFile(t *testing.T) { + _, err := Load("/nonexistent/path/config.yaml") + if err == nil { + t.Error("Load() expected error for nonexistent file but got none") + } + if !contains(err.Error(), "failed to read config file") { + t.Errorf("Load() error = %v, want error containing 'failed to read config file'", err) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + errContains string + }{ + { + name: "valid config", + config: Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: 8080, + Host: "localhost", + }, + JMAP: struct { + Endpoint string `yaml:"endpoint"` + APIToken string `yaml:"api_token"` + }{ + Endpoint: "https://api.fastmail.com/jmap/session", + APIToken: "test-token", + }, + DryRun: true, + DefaultSimilarity: 75, + MockMode: false, + }, + wantErr: false, + }, + { + name: "valid config with mock mode", + config: Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: 8080, + Host: "localhost", + }, + JMAP: struct { + Endpoint string `yaml:"endpoint"` + APIToken string `yaml:"api_token"` + }{ + Endpoint: "", + APIToken: "", + }, + DryRun: true, + DefaultSimilarity: 75, + MockMode: true, + }, + wantErr: false, + }, + { + name: "missing jmap endpoint without mock mode", + config: Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: 8080, + Host: "localhost", + }, + JMAP: struct { + Endpoint string `yaml:"endpoint"` + APIToken string `yaml:"api_token"` + }{ + Endpoint: "", + APIToken: "test-token", + }, + DryRun: true, + DefaultSimilarity: 75, + MockMode: false, + }, + wantErr: true, + errContains: "JMAP endpoint is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validate() + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} + +func TestGetServerAddr(t *testing.T) { + tests := []struct { + name string + host string + port int + want string + }{ + { + name: "localhost with port 8080", + host: "localhost", + port: 8080, + want: "localhost:8080", + }, + { + name: "0.0.0.0 with port 3000", + host: "0.0.0.0", + port: 3000, + want: "0.0.0.0:3000", + }, + { + name: "empty host with port 8080", + host: "", + port: 8080, + want: ":8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: tt.port, + Host: tt.host, + }, + } + + got := cfg.GetServerAddr() + if got != tt.want { + t.Errorf("GetServerAddr() = %v, want %v", got, tt.want) + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/jmap/jmap_test.go b/internal/jmap/jmap_test.go new file mode 100644 index 0000000..3fbe565 --- /dev/null +++ b/internal/jmap/jmap_test.go @@ -0,0 +1,560 @@ +package jmap + +import ( + "testing" + "time" +) + +func TestGetString(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + key string + want string + }{ + { + name: "string value exists", + data: map[string]interface{}{"key": "value"}, + key: "key", + want: "value", + }, + { + name: "key doesn't exist", + data: map[string]interface{}{"other": "value"}, + key: "key", + want: "", + }, + { + name: "value is not a string", + data: map[string]interface{}{"key": 123}, + key: "key", + want: "", + }, + { + name: "empty map", + data: map[string]interface{}{}, + key: "key", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getString(tt.data, tt.key) + if got != tt.want { + t.Errorf("getString() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetInt(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + key string + want int + }{ + { + name: "float64 value", + data: map[string]interface{}{"key": float64(123)}, + key: "key", + want: 123, + }, + { + name: "int value", + data: map[string]interface{}{"key": 123}, + key: "key", + want: 123, + }, + { + name: "key doesn't exist", + data: map[string]interface{}{"other": 123}, + key: "key", + want: 0, + }, + { + name: "value is not a number", + data: map[string]interface{}{"key": "string"}, + key: "key", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getInt(tt.data, tt.key) + if got != tt.want { + t.Errorf("getInt() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetBool(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + key string + want bool + }{ + { + name: "bool true value", + data: map[string]interface{}{"key": true}, + key: "key", + want: true, + }, + { + name: "bool false value", + data: map[string]interface{}{"key": false}, + key: "key", + want: false, + }, + { + name: "key doesn't exist", + data: map[string]interface{}{"other": true}, + key: "key", + want: false, + }, + { + name: "value is not a bool", + data: map[string]interface{}{"key": "true"}, + key: "key", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getBool(tt.data, tt.key) + if got != tt.want { + t.Errorf("getBool() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseEmail(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + want Email + }{ + { + name: "basic email data", + data: map[string]interface{}{ + "id": "test-id-123", + "subject": "Test Subject", + "preview": "Test preview text", + }, + want: Email{ + ID: "test-id-123", + Subject: "Test Subject", + Preview: "Test preview text", + }, + }, + { + name: "email with from address", + data: map[string]interface{}{ + "id": "test-id-456", + "subject": "Test Subject", + "from": []interface{}{ + map[string]interface{}{ + "name": "Test User", + "email": "test@example.com", + }, + }, + }, + want: Email{ + ID: "test-id-456", + Subject: "Test Subject", + From: []EmailAddress{ + {Name: "Test User", Email: "test@example.com"}, + }, + }, + }, + { + name: "email with receivedAt", + data: map[string]interface{}{ + "id": "test-id-789", + "receivedAt": "2023-01-01T12:00:00Z", + }, + want: Email{ + ID: "test-id-789", + ReceivedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + }, + { + name: "email with body values", + data: map[string]interface{}{ + "id": "test-id-body", + "bodyValues": map[string]interface{}{ + "text": map[string]interface{}{ + "value": "Body text content", + "isEncodingProblem": false, + "isTruncated": true, + }, + }, + }, + want: Email{ + ID: "test-id-body", + BodyValues: map[string]BodyValue{ + "text": { + Value: "Body text content", + IsEncodingProblem: false, + IsTruncated: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseEmail(tt.data) + + if got.ID != tt.want.ID { + t.Errorf("parseEmail().ID = %v, want %v", got.ID, tt.want.ID) + } + if got.Subject != tt.want.Subject { + t.Errorf("parseEmail().Subject = %v, want %v", got.Subject, tt.want.Subject) + } + if got.Preview != tt.want.Preview { + t.Errorf("parseEmail().Preview = %v, want %v", got.Preview, tt.want.Preview) + } + if len(tt.want.From) > 0 { + if len(got.From) != len(tt.want.From) { + t.Errorf("parseEmail().From length = %v, want %v", len(got.From), len(tt.want.From)) + } else { + if got.From[0].Email != tt.want.From[0].Email { + t.Errorf("parseEmail().From[0].Email = %v, want %v", got.From[0].Email, tt.want.From[0].Email) + } + } + } + }) + } +} + +func TestNewClient(t *testing.T) { + endpoint := "https://api.example.com/jmap/session" + apiToken := "test-token" + + client := NewClient(endpoint, apiToken) + + if client == nil { + t.Fatal("NewClient() returned nil") + } + if client.endpoint != endpoint { + t.Errorf("NewClient().endpoint = %v, want %v", client.endpoint, endpoint) + } + if client.apiToken != apiToken { + t.Errorf("NewClient().apiToken = %v, want %v", client.apiToken, apiToken) + } + if client.httpClient == nil { + t.Error("NewClient().httpClient is nil") + } + if client.session != nil { + t.Error("NewClient().session should be nil before authentication") + } +} + +func TestClient_GetPrimaryAccount(t *testing.T) { + tests := []struct { + name string + session *Session + want string + }{ + { + name: "valid session with mail account", + session: &Session{ + PrimaryAccounts: map[string]string{ + "urn:ietf:params:jmap:mail": "account-123", + }, + }, + want: "account-123", + }, + { + name: "session without mail account", + session: &Session{ + PrimaryAccounts: map[string]string{ + "urn:ietf:params:jmap:contacts": "account-456", + }, + }, + want: "", + }, + { + name: "nil session", + session: nil, + want: "", + }, + { + name: "empty primary accounts", + session: &Session{ + PrimaryAccounts: map[string]string{}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &Client{session: tt.session} + got := client.GetPrimaryAccount() + if got != tt.want { + t.Errorf("GetPrimaryAccount() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractNameFromEmail(t *testing.T) { + tests := []struct { + name string + email string + want string + }{ + { + name: "github email", + email: "notifications@github.com", + want: "GitHub", + }, + { + name: "stripe email", + email: "support@stripe.com", + want: "Stripe Support", + }, + { + name: "unknown email", + email: "unknown@example.com", + want: "unknown@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractNameFromEmail(tt.email) + if got != tt.want { + t.Errorf("extractNameFromEmail() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClient_MakeRequest_Unauthenticated(t *testing.T) { + client := NewClient("https://api.example.com/jmap/session", "test-token") + + // Try to make a request without authenticating first + methodCalls := []MethodCall{ + {"Email/get", map[string]interface{}{"accountId": "test"}, "0"}, + } + + _, err := client.makeRequest(methodCalls) + if err == nil { + t.Error("makeRequest() should fail when client not authenticated") + } + if err.Error() != "client not authenticated" { + t.Errorf("makeRequest() error = %v, want 'client not authenticated'", err) + } +} + +func TestClient_GetInboxEmails(t *testing.T) { + // Test with mock client + mockClient := NewMockClient() + emails, err := mockClient.GetInboxEmails(10) + + if err != nil { + t.Errorf("GetInboxEmails() unexpected error = %v", err) + } + + if len(emails) > 10 { + t.Errorf("GetInboxEmails() returned %d emails, want at most 10", len(emails)) + } +} + +func TestClient_GetInboxEmailsWithCount(t *testing.T) { + // Test with mock client + mockClient := NewMockClient() + info, err := mockClient.GetInboxEmailsWithCount(5) + + if err != nil { + t.Errorf("GetInboxEmailsWithCount() unexpected error = %v", err) + } + + if info == nil { + t.Fatal("GetInboxEmailsWithCount() returned nil") + } + + if len(info.Emails) > 5 { + t.Errorf("GetInboxEmailsWithCount() returned %d emails, want at most 5", len(info.Emails)) + } + + if info.TotalCount < len(info.Emails) { + t.Errorf("GetInboxEmailsWithCount() TotalCount = %d, but returned %d emails", + info.TotalCount, len(info.Emails)) + } +} + +func TestClient_ArchiveEmails_DryRun(t *testing.T) { + mockClient := NewMockClient() + + // Test dry run + err := mockClient.ArchiveEmails([]string{"email-0-0"}, true) + if err != nil { + t.Errorf("ArchiveEmails() dry run unexpected error = %v", err) + } + + // Verify email wasn't actually archived + emails, _ := mockClient.GetInboxEmails(100) + found := false + for _, email := range emails { + if email.ID == "email-0-0" { + found = true + break + } + } + if !found { + t.Error("ArchiveEmails() in dry run mode should not actually archive emails") + } +} + +func TestClient_ArchiveEmails_Real(t *testing.T) { + mockClient := NewMockClient() + + // Get initial count + initialInfo, _ := mockClient.GetInboxEmailsWithCount(100) + initialCount := initialInfo.TotalCount + + // Archive an email + err := mockClient.ArchiveEmails([]string{"email-0-0"}, false) + if err != nil { + t.Errorf("ArchiveEmails() unexpected error = %v", err) + } + + // Verify email was archived + afterInfo, _ := mockClient.GetInboxEmailsWithCount(100) + if afterInfo.TotalCount != initialCount-1 { + t.Errorf("ArchiveEmails() inbox count = %d, want %d", + afterInfo.TotalCount, initialCount-1) + } +} + +func TestParseEmail_ComplexStructures(t *testing.T) { + data := map[string]interface{}{ + "id": "complex-email", + "subject": "Test Subject", + "textBody": []interface{}{ + map[string]interface{}{ + "partId": "text-part-1", + "type": "text/plain", + }, + map[string]interface{}{ + "partId": "text-part-2", + "type": "text/plain", + }, + }, + "htmlBody": []interface{}{ + map[string]interface{}{ + "partId": "html-part-1", + "type": "text/html", + }, + }, + "bodyValues": map[string]interface{}{ + "text-part-1": map[string]interface{}{ + "value": "First text part", + "isEncodingProblem": false, + "isTruncated": false, + }, + "text-part-2": map[string]interface{}{ + "value": "Second text part", + "isEncodingProblem": true, + "isTruncated": true, + }, + }, + } + + email := parseEmail(data) + + if email.ID != "complex-email" { + t.Errorf("parseEmail().ID = %v, want 'complex-email'", email.ID) + } + + if len(email.TextBody) != 2 { + t.Errorf("parseEmail() TextBody length = %d, want 2", len(email.TextBody)) + } + + if len(email.HTMLBody) != 1 { + t.Errorf("parseEmail() HTMLBody length = %d, want 1", len(email.HTMLBody)) + } + + if len(email.BodyValues) != 2 { + t.Errorf("parseEmail() BodyValues length = %d, want 2", len(email.BodyValues)) + } + + // Check specific body value + if bodyVal, ok := email.BodyValues["text-part-2"]; ok { + if bodyVal.Value != "Second text part" { + t.Errorf("parseEmail() BodyValue.Value = %v, want 'Second text part'", bodyVal.Value) + } + if !bodyVal.IsEncodingProblem { + t.Error("parseEmail() BodyValue.IsEncodingProblem should be true") + } + if !bodyVal.IsTruncated { + t.Error("parseEmail() BodyValue.IsTruncated should be true") + } + } else { + t.Error("parseEmail() should have body value for 'text-part-2'") + } +} + +func TestParseEmail_MissingFields(t *testing.T) { + // Test with minimal data + data := map[string]interface{}{ + "id": "minimal-email", + } + + email := parseEmail(data) + + if email.ID != "minimal-email" { + t.Errorf("parseEmail().ID = %v, want 'minimal-email'", email.ID) + } + if email.Subject != "" { + t.Errorf("parseEmail().Subject = %v, want empty string", email.Subject) + } + if len(email.From) != 0 { + t.Errorf("parseEmail().From length = %d, want 0", len(email.From)) + } + if email.ReceivedAt.IsZero() { + // This is expected for missing receivedAt + } +} + +func TestParseEmail_InvalidReceivedAt(t *testing.T) { + data := map[string]interface{}{ + "id": "test-id", + "receivedAt": "invalid-date-format", + } + + email := parseEmail(data) + + // Should handle invalid date gracefully + if !email.ReceivedAt.IsZero() { + t.Error("parseEmail() should have zero time for invalid receivedAt") + } +} + +func TestInboxInfo(t *testing.T) { + info := &InboxInfo{ + Emails: []Email{ + {ID: "1", Subject: "Test 1"}, + {ID: "2", Subject: "Test 2"}, + }, + TotalCount: 10, + } + + if len(info.Emails) != 2 { + t.Errorf("InboxInfo.Emails length = %d, want 2", len(info.Emails)) + } + if info.TotalCount != 10 { + t.Errorf("InboxInfo.TotalCount = %d, want 10", info.TotalCount) + } +} diff --git a/internal/jmap/mock_test.go b/internal/jmap/mock_test.go new file mode 100644 index 0000000..9c8563e --- /dev/null +++ b/internal/jmap/mock_test.go @@ -0,0 +1,389 @@ +package jmap + +import ( + "testing" +) + +func TestNewMockClient(t *testing.T) { + client := NewMockClient() + + if client == nil { + t.Fatal("NewMockClient() returned nil") + } + + if client.sampleEmails == nil { + t.Error("NewMockClient() sampleEmails is nil") + } + + if len(client.sampleEmails) == 0 { + t.Error("NewMockClient() should generate sample emails") + } + + if client.archivedIDs == nil { + t.Error("NewMockClient() archivedIDs is nil") + } +} + +func TestMockClient_Authenticate(t *testing.T) { + client := NewMockClient() + err := client.Authenticate() + + if err != nil { + t.Errorf("MockClient.Authenticate() unexpected error = %v", err) + } +} + +func TestMockClient_GetPrimaryAccount(t *testing.T) { + client := NewMockClient() + accountID := client.GetPrimaryAccount() + + if accountID == "" { + t.Error("MockClient.GetPrimaryAccount() returned empty string") + } +} + +func TestMockClient_GetMailboxes(t *testing.T) { + client := NewMockClient() + mailboxes, err := client.GetMailboxes() + + if err != nil { + t.Errorf("MockClient.GetMailboxes() unexpected error = %v", err) + } + + if len(mailboxes) == 0 { + t.Error("MockClient.GetMailboxes() returned no mailboxes") + } + + // Check for inbox + foundInbox := false + foundArchive := false + for _, mb := range mailboxes { + if mb.Role == "inbox" { + foundInbox = true + } + if mb.Role == "archive" { + foundArchive = true + } + } + + if !foundInbox { + t.Error("MockClient.GetMailboxes() did not return inbox") + } + if !foundArchive { + t.Error("MockClient.GetMailboxes() did not return archive") + } +} + +func TestMockClient_GetInboxEmails(t *testing.T) { + client := NewMockClient() + totalEmails := len(client.sampleEmails) + + tests := []struct { + name string + limit int + wantCount int + }{ + { + name: "get all emails", + limit: 100, + wantCount: totalEmails, + }, + { + name: "get limited emails", + limit: 5, + wantCount: 5, + }, + { + name: "get zero emails", + limit: 0, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + emails, err := client.GetInboxEmails(tt.limit) + if err != nil { + t.Errorf("MockClient.GetInboxEmails() unexpected error = %v", err) + } + + if len(emails) > tt.wantCount { + t.Errorf("MockClient.GetInboxEmails() returned %d emails, want at most %d", + len(emails), tt.wantCount) + } + }) + } +} + +func TestMockClient_GetInboxEmailsPaginated(t *testing.T) { + client := NewMockClient() + + tests := []struct { + name string + limit int + offset int + wantErr bool + }{ + { + name: "first page", + limit: 10, + offset: 0, + wantErr: false, + }, + { + name: "second page", + limit: 10, + offset: 10, + wantErr: false, + }, + { + name: "offset beyond emails", + limit: 10, + offset: 1000, + wantErr: false, // Should return empty slice, not error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + emails, err := client.GetInboxEmailsPaginated(tt.limit, tt.offset) + + if tt.wantErr { + if err == nil { + t.Error("MockClient.GetInboxEmailsPaginated() expected error but got none") + } + } else { + if err != nil { + t.Errorf("MockClient.GetInboxEmailsPaginated() unexpected error = %v", err) + } + + if len(emails) > tt.limit { + t.Errorf("MockClient.GetInboxEmailsPaginated() returned %d emails, want at most %d", + len(emails), tt.limit) + } + } + }) + } +} + +func TestMockClient_GetInboxEmailsWithCount(t *testing.T) { + client := NewMockClient() + + info, err := client.GetInboxEmailsWithCount(10) + if err != nil { + t.Errorf("MockClient.GetInboxEmailsWithCount() unexpected error = %v", err) + } + + if info == nil { + t.Fatal("MockClient.GetInboxEmailsWithCount() returned nil") + } + + if len(info.Emails) > 10 { + t.Errorf("MockClient.GetInboxEmailsWithCount() returned %d emails, want at most 10", + len(info.Emails)) + } + + if info.TotalCount <= 0 { + t.Error("MockClient.GetInboxEmailsWithCount() TotalCount should be positive") + } + + if info.TotalCount < len(info.Emails) { + t.Errorf("MockClient.GetInboxEmailsWithCount() TotalCount = %d, but returned %d emails", + info.TotalCount, len(info.Emails)) + } +} + +func TestMockClient_GetInboxEmailsWithCountPaginated(t *testing.T) { + client := NewMockClient() + + // Get first page + info1, err := client.GetInboxEmailsWithCountPaginated(5, 0) + if err != nil { + t.Fatalf("MockClient.GetInboxEmailsWithCountPaginated() first page error = %v", err) + } + + // Get second page + info2, err := client.GetInboxEmailsWithCountPaginated(5, 5) + if err != nil { + t.Fatalf("MockClient.GetInboxEmailsWithCountPaginated() second page error = %v", err) + } + + // Total count should be the same on both pages + if info1.TotalCount != info2.TotalCount { + t.Errorf("MockClient.GetInboxEmailsWithCountPaginated() TotalCount inconsistent: %d vs %d", + info1.TotalCount, info2.TotalCount) + } + + // Email IDs should be different between pages + if len(info1.Emails) > 0 && len(info2.Emails) > 0 { + if info1.Emails[0].ID == info2.Emails[0].ID { + t.Error("MockClient.GetInboxEmailsWithCountPaginated() pages should return different emails") + } + } +} + +func TestMockClient_ArchiveEmails(t *testing.T) { + tests := []struct { + name string + emailIDs []string + dryRun bool + wantErr bool + }{ + { + name: "dry run archive", + emailIDs: []string{"email-0-0", "email-0-1"}, + dryRun: true, + wantErr: false, + }, + { + name: "real archive", + emailIDs: []string{"email-1-0", "email-1-1"}, + dryRun: false, + wantErr: false, + }, + { + name: "archive empty list", + emailIDs: []string{}, + dryRun: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewMockClient() + initialCount := len(client.sampleEmails) + + // Count non-archived emails before + nonArchivedBefore := 0 + for _, email := range client.sampleEmails { + if !client.archivedIDs[email.ID] { + nonArchivedBefore++ + } + } + + err := client.ArchiveEmails(tt.emailIDs, tt.dryRun) + + if tt.wantErr { + if err == nil { + t.Error("MockClient.ArchiveEmails() expected error but got none") + } + } else { + if err != nil { + t.Errorf("MockClient.ArchiveEmails() unexpected error = %v", err) + } + + // Check that total emails hasn't changed + if len(client.sampleEmails) != initialCount { + t.Errorf("MockClient.ArchiveEmails() changed total emails: %d -> %d", + initialCount, len(client.sampleEmails)) + } + + // In dry run mode, emails should not be archived + if tt.dryRun { + for _, id := range tt.emailIDs { + if client.archivedIDs[id] { + t.Errorf("MockClient.ArchiveEmails() in dry run mode but email %s was archived", id) + } + } + } else { + // In real mode, emails should be marked as archived + for _, id := range tt.emailIDs { + if !client.archivedIDs[id] { + t.Errorf("MockClient.ArchiveEmails() email %s should be archived", id) + } + } + } + } + }) + } +} + +func TestMockClient_ArchiveAndRetrieve(t *testing.T) { + client := NewMockClient() + + // Get initial inbox count + initialEmails, err := client.GetInboxEmails(100) + if err != nil { + t.Fatalf("Failed to get initial emails: %v", err) + } + initialCount := len(initialEmails) + + if initialCount < 2 { + t.Fatal("Need at least 2 sample emails for this test") + } + + // Archive some emails + emailsToArchive := []string{initialEmails[0].ID, initialEmails[1].ID} + err = client.ArchiveEmails(emailsToArchive, false) + if err != nil { + t.Fatalf("Failed to archive emails: %v", err) + } + + // Get inbox emails again + afterEmails, err := client.GetInboxEmails(100) + if err != nil { + t.Fatalf("Failed to get emails after archiving: %v", err) + } + + // Should have fewer emails in inbox + if len(afterEmails) != initialCount-2 { + t.Errorf("After archiving 2 emails, inbox has %d emails, want %d", + len(afterEmails), initialCount-2) + } + + // Archived emails should not be in inbox + for _, archivedID := range emailsToArchive { + for _, email := range afterEmails { + if email.ID == archivedID { + t.Errorf("Archived email %s is still in inbox", archivedID) + } + } + } +} + +func TestMockClient_GenerateSampleEmails(t *testing.T) { + client := NewMockClient() + + if len(client.sampleEmails) == 0 { + t.Fatal("generateSampleEmails() should create emails") + } + + // Check that emails have required fields + for i, email := range client.sampleEmails { + if email.ID == "" { + t.Errorf("Email %d has empty ID", i) + } + if email.Subject == "" { + t.Errorf("Email %d has empty Subject", i) + } + if len(email.From) == 0 { + t.Errorf("Email %d has no From address", i) + } + if email.ReceivedAt.IsZero() { + t.Errorf("Email %d has zero ReceivedAt time", i) + } + } + + // Check that we have emails from the same sender + // (which indicates similar email groups) + senderCount := make(map[string]int) + for _, email := range client.sampleEmails { + if len(email.From) > 0 { + senderCount[email.From[0].Email]++ + } + } + + // Should have at least one sender with multiple emails + foundGroup := false + for _, count := range senderCount { + if count > 1 { + foundGroup = true + break + } + } + + if !foundGroup { + t.Error("generateSampleEmails() should create groups of similar emails from same senders") + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..48498a6 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,705 @@ +package server + +import ( + "bytes" + "encoding/json" + "mailboxzero/internal/config" + "mailboxzero/internal/jmap" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// setupTestServer creates a test server with mock JMAP client +func setupTestServer(t *testing.T) *Server { + t.Helper() + + // Create a minimal config + cfg := &config.Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: 8080, + Host: "localhost", + }, + DryRun: true, + DefaultSimilarity: 75, + MockMode: true, + } + + // Use mock JMAP client + mockClient := jmap.NewMockClient() + + // Create temporary template for testing + templateContent := ` + +Test + +

Mailbox Zero

+{{if .DryRun}}
DRY RUN MODE
{{end}} + +` + + // Create temp directory with proper web/templates structure + tmpDir := t.TempDir() + templatePath := tmpDir + "/web/templates" + if err := os.MkdirAll(templatePath, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + templateFile := templatePath + "/index.html" + if err := os.WriteFile(templateFile, []byte(templateContent), 0644); err != nil { + t.Fatalf("Failed to write template file: %v", err) + } + + // Change working directory temporarily + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + t.Cleanup(func() { os.Chdir(oldWd) }) + + server, err := New(cfg, mockClient) + if err != nil { + t.Fatalf("Failed to create server: %v", err) + } + + return server +} + +func TestNew(t *testing.T) { + cfg := &config.Config{ + Server: struct { + Port int `yaml:"port"` + Host string `yaml:"host"` + }{ + Port: 8080, + Host: "localhost", + }, + DryRun: true, + DefaultSimilarity: 75, + } + + mockClient := jmap.NewMockClient() + + // Create temporary template with proper structure + tmpDir := t.TempDir() + os.MkdirAll(tmpDir+"/web/templates", 0755) + os.WriteFile(tmpDir+"/web/templates/index.html", []byte(""), 0644) + + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + server, err := New(cfg, mockClient) + if err != nil { + t.Errorf("New() unexpected error = %v", err) + } + + if server == nil { + t.Fatal("New() returned nil server") + } + + if server.config != cfg { + t.Error("New() did not set config correctly") + } + + if server.jmapClient != mockClient { + t.Error("New() did not set jmapClient correctly") + } + + if server.templates == nil { + t.Error("New() did not load templates") + } +} + +func TestNew_TemplateError(t *testing.T) { + cfg := &config.Config{} + mockClient := jmap.NewMockClient() + + // Don't create any template files + tmpDir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + _, err := New(cfg, mockClient) + if err == nil { + t.Error("New() expected error for missing templates but got none") + } +} + +func TestHandleGetEmails(t *testing.T) { + server := setupTestServer(t) + + tests := []struct { + name string + query string + wantStatusCode int + }{ + { + name: "get emails without query params", + query: "", + wantStatusCode: http.StatusOK, + }, + { + name: "get emails with limit", + query: "?limit=10", + wantStatusCode: http.StatusOK, + }, + { + name: "get emails with offset", + query: "?offset=5", + wantStatusCode: http.StatusOK, + }, + { + name: "get emails with limit and offset", + query: "?limit=10&offset=5", + wantStatusCode: http.StatusOK, + }, + { + name: "get emails with invalid limit", + query: "?limit=invalid", + wantStatusCode: http.StatusOK, // Should use default + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/emails"+tt.query, nil) + w := httptest.NewRecorder() + + server.handleGetEmails(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleGetEmails() status = %v, want %v", w.Code, tt.wantStatusCode) + } + + if tt.wantStatusCode == http.StatusOK { + var response jmap.InboxInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Errorf("handleGetEmails() failed to decode response: %v", err) + } + + if response.Emails == nil { + t.Error("handleGetEmails() response.Emails is nil") + } + + if response.TotalCount < 0 { + t.Errorf("handleGetEmails() response.TotalCount = %v, want >= 0", response.TotalCount) + } + } + }) + } +} + +func TestHandleFindSimilar(t *testing.T) { + server := setupTestServer(t) + + // Get some emails first to use their IDs + mockClient := server.jmapClient.(*jmap.MockClient) + emails, _ := mockClient.GetInboxEmails(10) + + tests := []struct { + name string + requestBody interface{} + wantStatusCode int + }{ + { + name: "find similar without specific email", + requestBody: SimilarRequest{ + SimilarityThreshold: 75.0, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "find similar with specific email", + requestBody: SimilarRequest{ + EmailID: emails[0].ID, + SimilarityThreshold: 75.0, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "find similar with nonexistent email", + requestBody: SimilarRequest{ + EmailID: "nonexistent-id", + SimilarityThreshold: 75.0, + }, + wantStatusCode: http.StatusNotFound, + }, + { + name: "find similar with low threshold", + requestBody: SimilarRequest{ + SimilarityThreshold: 0.0, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "find similar with high threshold", + requestBody: SimilarRequest{ + SimilarityThreshold: 99.0, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "invalid request body", + requestBody: "invalid json", + wantStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body []byte + var err error + + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + } + + req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFindSimilar(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleFindSimilar() status = %v, want %v", w.Code, tt.wantStatusCode) + } + + if tt.wantStatusCode == http.StatusOK { + var response []jmap.Email + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Errorf("handleFindSimilar() failed to decode response: %v", err) + } + } + }) + } +} + +func TestHandleArchive(t *testing.T) { + server := setupTestServer(t) + + // Get some emails first to use their IDs + mockClient := server.jmapClient.(*jmap.MockClient) + emails, _ := mockClient.GetInboxEmails(10) + + tests := []struct { + name string + requestBody interface{} + wantStatusCode int + }{ + { + name: "archive single email", + requestBody: ArchiveRequest{ + EmailIDs: []string{emails[0].ID}, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "archive multiple emails", + requestBody: ArchiveRequest{ + EmailIDs: []string{emails[1].ID, emails[2].ID}, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "archive empty list", + requestBody: ArchiveRequest{ + EmailIDs: []string{}, + }, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "invalid request body", + requestBody: "invalid json", + wantStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body []byte + var err error + + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + } + + req := httptest.NewRequest("POST", "/api/archive", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleArchive(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleArchive() status = %v, want %v", w.Code, tt.wantStatusCode) + } + + if tt.wantStatusCode == http.StatusOK { + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Errorf("handleArchive() failed to decode response: %v", err) + } + + if success, ok := response["success"].(bool); !ok || !success { + t.Error("handleArchive() response should have success=true") + } + + if dryRun, ok := response["dryRun"].(bool); !ok { + t.Error("handleArchive() response should have dryRun field") + } else if dryRun != server.config.DryRun { + t.Errorf("handleArchive() dryRun = %v, want %v", dryRun, server.config.DryRun) + } + } + }) + } +} + +func TestHandleClear(t *testing.T) { + server := setupTestServer(t) + + req := httptest.NewRequest("POST", "/api/clear", nil) + w := httptest.NewRecorder() + + server.handleClear(w, req) + + if w.Code != http.StatusOK { + t.Errorf("handleClear() status = %v, want %v", w.Code, http.StatusOK) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Errorf("handleClear() failed to decode response: %v", err) + } + + if success, ok := response["success"].(bool); !ok || !success { + t.Error("handleClear() response should have success=true") + } +} + +func TestHandleIndex(t *testing.T) { + server := setupTestServer(t) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + server.handleIndex(w, req) + + if w.Code != http.StatusOK { + t.Errorf("handleIndex() status = %v, want %v", w.Code, http.StatusOK) + } + + body := w.Body.String() + if !strings.Contains(body, "Mailbox Zero") { + t.Error("handleIndex() response should contain 'Mailbox Zero'") + } + + // Check that DryRun mode indicator is present when enabled + if server.config.DryRun && !strings.Contains(body, "DRY RUN MODE") { + t.Error("handleIndex() response should indicate DRY RUN MODE when enabled") + } +} + +func TestPageData(t *testing.T) { + data := PageData{ + DryRun: true, + DefaultSimilarity: 75, + Emails: []jmap.Email{}, + GroupedEmails: []jmap.Email{}, + SelectedEmailID: "test-id", + } + + if !data.DryRun { + t.Error("PageData.DryRun not set correctly") + } + if data.DefaultSimilarity != 75 { + t.Errorf("PageData.DefaultSimilarity = %v, want 75", data.DefaultSimilarity) + } + if data.SelectedEmailID != "test-id" { + t.Errorf("PageData.SelectedEmailID = %v, want 'test-id'", data.SelectedEmailID) + } +} + +func TestSimilarRequest(t *testing.T) { + req := SimilarRequest{ + EmailID: "test-email-id", + SimilarityThreshold: 85.5, + } + + if req.EmailID != "test-email-id" { + t.Errorf("SimilarRequest.EmailID = %v, want 'test-email-id'", req.EmailID) + } + if req.SimilarityThreshold != 85.5 { + t.Errorf("SimilarRequest.SimilarityThreshold = %v, want 85.5", req.SimilarityThreshold) + } +} + +func TestArchiveRequest(t *testing.T) { + req := ArchiveRequest{ + EmailIDs: []string{"id1", "id2", "id3"}, + } + + if len(req.EmailIDs) != 3 { + t.Errorf("ArchiveRequest.EmailIDs length = %v, want 3", len(req.EmailIDs)) + } +} + +func TestHandleGetEmails_ParseErrors(t *testing.T) { + server := setupTestServer(t) + + tests := []struct { + name string + query string + wantStatusCode int + }{ + { + name: "negative limit", + query: "?limit=-5", + wantStatusCode: http.StatusOK, // Should use default + }, + { + name: "zero limit", + query: "?limit=0", + wantStatusCode: http.StatusOK, // Should use default + }, + { + name: "negative offset", + query: "?offset=-5", + wantStatusCode: http.StatusOK, // Should use default (0) + }, + { + name: "very large limit", + query: "?limit=99999", + wantStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/emails"+tt.query, nil) + w := httptest.NewRecorder() + + server.handleGetEmails(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleGetEmails() status = %v, want %v", w.Code, tt.wantStatusCode) + } + }) + } +} + +func TestHandleIndex_WithPageData(t *testing.T) { + server := setupTestServer(t) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + server.handleIndex(w, req) + + if w.Code != http.StatusOK { + t.Errorf("handleIndex() status = %v, want %v", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check that the template was rendered with the page data + if server.config.DryRun && !strings.Contains(body, "DRY RUN MODE") { + t.Error("handleIndex() should render DryRun indicator when enabled") + } + + // Verify it's HTML + if !strings.Contains(body, "") || !strings.Contains(body, "") { + t.Error("handleIndex() should return HTML content") + } +} + +func TestHandleFindSimilar_ErrorConditions(t *testing.T) { + server := setupTestServer(t) + + tests := []struct { + name string + requestBody string + contentType string + wantStatusCode int + }{ + { + name: "malformed JSON", + requestBody: "{invalid json", + contentType: "application/json", + wantStatusCode: http.StatusBadRequest, + }, + { + name: "empty body", + requestBody: "", + contentType: "application/json", + wantStatusCode: http.StatusBadRequest, + }, + { + name: "wrong content type", + requestBody: `{"similarityThreshold": 75}`, + contentType: "text/plain", + wantStatusCode: http.StatusOK, // JSON decoder doesn't check content-type + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/similar", strings.NewReader(tt.requestBody)) + if tt.contentType != "" { + req.Header.Set("Content-Type", tt.contentType) + } + w := httptest.NewRecorder() + + server.handleFindSimilar(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleFindSimilar() status = %v, want %v", w.Code, tt.wantStatusCode) + } + }) + } +} + +func TestHandleArchive_ErrorConditions(t *testing.T) { + server := setupTestServer(t) + + tests := []struct { + name string + requestBody string + wantStatusCode int + }{ + { + name: "malformed JSON", + requestBody: "{invalid json", + wantStatusCode: http.StatusBadRequest, + }, + { + name: "null emailIds", + requestBody: `{"emailIds": null}`, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "missing emailIds field", + requestBody: `{}`, + wantStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/archive", strings.NewReader(tt.requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleArchive(w, req) + + if w.Code != tt.wantStatusCode { + t.Errorf("handleArchive() status = %v, want %v", w.Code, tt.wantStatusCode) + } + }) + } +} + +func TestHandleFindSimilar_BoundaryValues(t *testing.T) { + server := setupTestServer(t) + + tests := []struct { + name string + threshold float64 + }{ + { + name: "zero threshold", + threshold: 0.0, + }, + { + name: "max threshold", + threshold: 100.0, + }, + { + name: "mid threshold", + threshold: 50.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := SimilarRequest{ + SimilarityThreshold: tt.threshold, + } + + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/similar", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleFindSimilar(w, req) + + if w.Code != http.StatusOK { + t.Errorf("handleFindSimilar() with threshold %v status = %v, want %v", + tt.threshold, w.Code, http.StatusOK) + } + }) + } +} + +func TestHandleGetEmails_JSONEncoding(t *testing.T) { + server := setupTestServer(t) + + req := httptest.NewRequest("GET", "/api/emails?limit=5", nil) + w := httptest.NewRecorder() + + server.handleGetEmails(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("handleGetEmails() status = %v, want %v", w.Code, http.StatusOK) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("handleGetEmails() Content-Type = %v, want application/json", contentType) + } + + // Verify response is valid JSON + var response jmap.InboxInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Errorf("handleGetEmails() returned invalid JSON: %v", err) + } +} + +func TestHandleClear_JSONResponse(t *testing.T) { + server := setupTestServer(t) + + req := httptest.NewRequest("POST", "/api/clear", nil) + w := httptest.NewRecorder() + + server.handleClear(w, req) + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("handleClear() Content-Type = %v, want application/json", contentType) + } +} + +func TestServer_ConfigValues(t *testing.T) { + server := setupTestServer(t) + + if !server.config.DryRun { + t.Error("Test server should have DryRun enabled") + } + + if server.config.DefaultSimilarity != 75 { + t.Errorf("Test server DefaultSimilarity = %v, want 75", server.config.DefaultSimilarity) + } +} diff --git a/internal/similarity/similarity_test.go b/internal/similarity/similarity_test.go new file mode 100644 index 0000000..90bc874 --- /dev/null +++ b/internal/similarity/similarity_test.go @@ -0,0 +1,876 @@ +package similarity + +import ( + "mailboxzero/internal/jmap" + "strings" + "testing" + "time" +) + +func TestLevenshteinDistance(t *testing.T) { + tests := []struct { + name string + s1 string + s2 string + want int + }{ + { + name: "identical strings", + s1: "hello", + s2: "hello", + want: 0, + }, + { + name: "one character difference", + s1: "hello", + s2: "hella", + want: 1, + }, + { + name: "empty strings", + s1: "", + s2: "", + want: 0, + }, + { + name: "one empty string", + s1: "hello", + s2: "", + want: 5, + }, + { + name: "completely different", + s1: "abc", + s2: "xyz", + want: 3, + }, + { + name: "insertion", + s1: "cat", + s2: "cats", + want: 1, + }, + { + name: "deletion", + s1: "cats", + s2: "cat", + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := levenshteinDistance(tt.s1, tt.s2) + if got != tt.want { + t.Errorf("levenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, got, tt.want) + } + }) + } +} + +func TestNormalizeString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "lowercase conversion", + input: "Hello World", + want: "hello world", + }, + { + name: "punctuation removal", + input: "Hello, World!", + want: "hello world", // Punctuation becomes spaces, not collapsed + }, + { + name: "multiple spaces", + input: "Hello World", + want: "hello world", // Multiple spaces preserved + }, + { + name: "special characters", + input: "Hello@World#2023", + want: "hello world 2023", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "only punctuation", + input: "!!!???", + want: "", + }, + { + name: "leading and trailing spaces", + input: " hello world ", + want: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeString(tt.input) + if got != tt.want { + t.Errorf("normalizeString(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestContainsCommonWords(t *testing.T) { + tests := []struct { + name string + s1 string + s2 string + want bool + }{ + { + name: "has common words", + s1: "hello world test", + s2: "hello world example", + want: true, + }, + { + name: "no common words", + s1: "hello world", + s2: "foo bar", + want: false, + }, + { + name: "short words ignored", + s1: "a b c", + s2: "a b c", + want: false, + }, + { + name: "one common word only", + s1: "hello world", + s2: "hello foo", + want: false, + }, + { + name: "empty strings", + s1: "", + s2: "", + want: false, + }, + { + name: "exactly two common words", + s1: "newsletter weekly update", + s2: "weekly newsletter digest", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsCommonWords(tt.s1, tt.s2) + if got != tt.want { + t.Errorf("containsCommonWords(%q, %q) = %v, want %v", tt.s1, tt.s2, got, tt.want) + } + }) + } +} + +func TestStringSimilarity(t *testing.T) { + tests := []struct { + name string + s1 string + s2 string + want float64 + }{ + { + name: "identical strings", + s1: "hello world", + s2: "hello world", + want: 1.0, + }, + { + name: "empty strings", + s1: "", + s2: "", + want: 1.0, // Empty strings are considered identical after normalization + }, + { + name: "one empty string", + s1: "hello", + s2: "", + want: 0.0, + }, + { + name: "similar strings with punctuation", + s1: "Hello, World!", + s2: "Hello World", + want: 1.0, // Should be normalized to same string + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stringSimilarity(tt.s1, tt.s2) + if got != tt.want { + t.Errorf("stringSimilarity(%q, %q) = %v, want %v", tt.s1, tt.s2, got, tt.want) + } + }) + } +} + +func TestCalculateEmailSimilarity(t *testing.T) { + email1 := jmap.Email{ + ID: "1", + Subject: "Weekly Newsletter", + From: []jmap.EmailAddress{ + {Email: "newsletter@example.com"}, + }, + Preview: "This is a test newsletter", + } + + email2 := jmap.Email{ + ID: "2", + Subject: "Weekly Newsletter", + From: []jmap.EmailAddress{ + {Email: "newsletter@example.com"}, + }, + Preview: "This is another test newsletter", + } + + email3 := jmap.Email{ + ID: "3", + Subject: "Completely Different Subject", + From: []jmap.EmailAddress{ + {Email: "different@example.com"}, + }, + Preview: "Completely different content", + } + + tests := []struct { + name string + email1 jmap.Email + email2 jmap.Email + wantRange [2]float64 // min and max expected values + }{ + { + name: "identical subject and sender", + email1: email1, + email2: email2, + wantRange: [2]float64{0.8, 1.0}, // High similarity + }, + { + name: "completely different emails", + email1: email1, + email2: email3, + wantRange: [2]float64{0.0, 0.5}, // Low to moderate similarity + }, + { + name: "same email with itself", + email1: email1, + email2: email1, + wantRange: [2]float64{1.0, 1.0}, // Perfect match + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateEmailSimilarity(tt.email1, tt.email2) + if got < tt.wantRange[0] || got > tt.wantRange[1] { + t.Errorf("calculateEmailSimilarity() = %v, want between %v and %v", + got, tt.wantRange[0], tt.wantRange[1]) + } + }) + } +} + +func TestExtractEmailBody(t *testing.T) { + tests := []struct { + name string + email jmap.Email + want string + }{ + { + name: "preview available", + email: jmap.Email{ + Preview: "Test preview", + }, + want: "Test preview", + }, + { + name: "body values available", + email: jmap.Email{ + Preview: "", + BodyValues: map[string]jmap.BodyValue{ + "1": {Value: "Test body content"}, + }, + }, + want: "test body content", + }, + { + name: "both preview and body values", + email: jmap.Email{ + Preview: "Test preview", + BodyValues: map[string]jmap.BodyValue{ + "1": {Value: "Test body content"}, + }, + }, + want: "Test preview", // Preview takes precedence + }, + { + name: "no content", + email: jmap.Email{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractEmailBody(tt.email) + if got != tt.want { + t.Errorf("extractEmailBody() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindSimilarEmails(t *testing.T) { + emails := []jmap.Email{ + { + ID: "1", + Subject: "Newsletter Issue 1", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + }, + { + ID: "2", + Subject: "Newsletter Issue 2", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + }, + { + ID: "3", + Subject: "Newsletter Issue 3", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + }, + { + ID: "4", + Subject: "Completely Different", + From: []jmap.EmailAddress{{Email: "other@example.com"}}, + Preview: "Different content", + }, + } + + tests := []struct { + name string + emails []jmap.Email + threshold float64 + wantMin int // Minimum expected similar emails + }{ + { + name: "high threshold - newsletters only", + emails: emails, + threshold: 0.8, + wantMin: 3, // Should find the 3 newsletter emails + }, + { + name: "low threshold - all emails", + emails: emails, + threshold: 0.0, + wantMin: 3, // Should find largest group + }, + { + name: "empty input", + emails: []jmap.Email{}, + threshold: 0.5, + wantMin: 0, + }, + { + name: "single email", + emails: []jmap.Email{ + {ID: "1", Subject: "Test"}, + }, + threshold: 0.5, + wantMin: 0, // Single email has no similar emails + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindSimilarEmails(tt.emails, tt.threshold) + if len(got) < tt.wantMin { + t.Errorf("FindSimilarEmails() returned %d emails, want at least %d", + len(got), tt.wantMin) + } + }) + } +} + +func TestFindSimilarToEmail(t *testing.T) { + targetEmail := jmap.Email{ + ID: "target", + Subject: "Newsletter Issue 1", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + } + + emails := []jmap.Email{ + targetEmail, + { + ID: "2", + Subject: "Newsletter Issue 2", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + }, + { + ID: "3", + Subject: "Newsletter Issue 3", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Welcome to our newsletter", + }, + { + ID: "4", + Subject: "Completely Different", + From: []jmap.EmailAddress{{Email: "other@example.com"}}, + Preview: "Different content", + }, + } + + tests := []struct { + name string + targetEmail jmap.Email + emails []jmap.Email + threshold float64 + wantMin int // Minimum expected results (includes target) + wantMax int // Maximum expected results + }{ + { + name: "high threshold - similar newsletters", + targetEmail: targetEmail, + emails: emails, + threshold: 0.8, + wantMin: 3, // Target + 2 similar + wantMax: 4, // At most all newsletters + }, + { + name: "very high threshold - only exact matches", + targetEmail: targetEmail, + emails: emails, + threshold: 0.99, + wantMin: 1, // At least the target itself + wantMax: 4, // Target + possibly similar newsletters + }, + { + name: "low threshold - all emails", + targetEmail: targetEmail, + emails: emails, + threshold: 0.0, + wantMin: 4, // Should include all emails + wantMax: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindSimilarToEmail(tt.targetEmail, tt.emails, tt.threshold) + + if len(got) < tt.wantMin || len(got) > tt.wantMax { + t.Errorf("FindSimilarToEmail() returned %d emails, want between %d and %d", + len(got), tt.wantMin, tt.wantMax) + } + + // First result should always be the target email + if len(got) > 0 && got[0].ID != tt.targetEmail.ID { + t.Errorf("FindSimilarToEmail() first result ID = %v, want %v", + got[0].ID, tt.targetEmail.ID) + } + }) + } +} + +func TestGroupSimilarEmails(t *testing.T) { + emails := []jmap.Email{ + { + ID: "1", + Subject: "Newsletter A", + From: []jmap.EmailAddress{{Email: "a@example.com"}}, + }, + { + ID: "2", + Subject: "Newsletter A", + From: []jmap.EmailAddress{{Email: "a@example.com"}}, + }, + { + ID: "3", + Subject: "Newsletter B", + From: []jmap.EmailAddress{{Email: "b@example.com"}}, + }, + { + ID: "4", + Subject: "Newsletter B", + From: []jmap.EmailAddress{{Email: "b@example.com"}}, + }, + } + + tests := []struct { + name string + emails []jmap.Email + threshold float64 + wantMinGroups int + }{ + { + name: "high threshold - find groups", + emails: emails, + threshold: 0.8, + wantMinGroups: 2, // Should find 2 groups + }, + { + name: "very high threshold - fewer groups", + emails: emails, + threshold: 0.99, + wantMinGroups: 0, + }, + { + name: "empty emails", + emails: []jmap.Email{}, + threshold: 0.5, + wantMinGroups: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := groupSimilarEmails(tt.emails, tt.threshold) + if len(got) < tt.wantMinGroups { + t.Errorf("groupSimilarEmails() returned %d groups, want at least %d", + len(got), tt.wantMinGroups) + } + + // Verify each group has at least 2 emails + for i, group := range got { + if len(group.Emails) < 2 { + t.Errorf("groupSimilarEmails() group %d has %d emails, want at least 2", + i, len(group.Emails)) + } + } + }) + } +} + +func TestCalculateGroupSimilarity(t *testing.T) { + email1 := jmap.Email{ + ID: "1", + Subject: "Test", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + } + + email2 := jmap.Email{ + ID: "2", + Subject: "Test", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + } + + tests := []struct { + name string + emails []jmap.Email + want float64 + }{ + { + name: "empty group", + emails: []jmap.Email{}, + want: 0.0, + }, + { + name: "single email", + emails: []jmap.Email{email1}, + want: 0.0, + }, + { + name: "two identical emails", + emails: []jmap.Email{email1, email2}, + want: 0.8, // 0.4 (subject) + 0.4 (sender) + 0.0 (no body) = 0.8 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateGroupSimilarity(tt.emails) + if got != tt.want { + t.Errorf("calculateGroupSimilarity() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMinMax(t *testing.T) { + t.Run("min function", func(t *testing.T) { + tests := []struct { + name string + a, b, c int + want int + }{ + {"a is minimum", 1, 2, 3, 1}, + {"b is minimum", 2, 1, 3, 1}, + {"c is minimum", 2, 3, 1, 1}, + {"all equal", 1, 1, 1, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := min(tt.a, tt.b, tt.c) + if got != tt.want { + t.Errorf("min(%d, %d, %d) = %d, want %d", tt.a, tt.b, tt.c, got, tt.want) + } + }) + } + }) + + t.Run("max function", func(t *testing.T) { + tests := []struct { + name string + a, b int + want int + }{ + {"a is maximum", 5, 3, 5}, + {"b is maximum", 3, 5, 5}, + {"equal values", 4, 4, 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := max(tt.a, tt.b) + if got != tt.want { + t.Errorf("max(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } + }) +} + +// Benchmark tests for performance-critical functions + +func BenchmarkLevenshteinDistance(b *testing.B) { + s1 := "this is a test string for benchmarking" + s2 := "this is another test string for benchmark" + + for i := 0; i < b.N; i++ { + levenshteinDistance(s1, s2) + } +} + +func BenchmarkCalculateEmailSimilarity(b *testing.B) { + email1 := jmap.Email{ + ID: "1", + Subject: "Weekly Newsletter Issue 123", + From: []jmap.EmailAddress{{Email: "newsletter@example.com"}}, + Preview: "This is a preview of the newsletter content", + ReceivedAt: time.Now(), + } + + email2 := jmap.Email{ + ID: "2", + Subject: "Weekly Newsletter Issue 124", + From: []jmap.EmailAddress{{Email: "newsletter@example.com"}}, + Preview: "This is another preview of the newsletter content", + ReceivedAt: time.Now(), + } + + for i := 0; i < b.N; i++ { + calculateEmailSimilarity(email1, email2) + } +} + +func TestStringSimilarity_EdgeCases(t *testing.T) { + tests := []struct { + name string + s1 string + s2 string + wantMin float64 + wantMax float64 + }{ + { + name: "very long similar strings", + s1: strings.Repeat("hello world ", 100), + s2: strings.Repeat("hello world ", 100), + wantMin: 1.0, + wantMax: 1.0, + }, + { + name: "string with common words boost", + s1: "newsletter weekly update digest information", + s2: "newsletter weekly report summary data", + wantMin: 0.3, + wantMax: 1.1, // Can exceed 1.0 with boost + }, + { + name: "mostly punctuation", + s1: "!!!???***", + s2: "###$$$%%%", + wantMin: 0.0, + wantMax: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stringSimilarity(tt.s1, tt.s2) + if got < tt.wantMin || got > tt.wantMax { + t.Errorf("stringSimilarity() = %v, want between %v and %v", + got, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestFindSimilarEmails_EmptyResult(t *testing.T) { + // Test with emails that are all unique (no similar pairs) + emails := []jmap.Email{ + { + ID: "1", + Subject: "Unique Subject A", + From: []jmap.EmailAddress{{Email: "a@example.com"}}, + Preview: "Completely unique content A", + }, + { + ID: "2", + Subject: "Different Subject B", + From: []jmap.EmailAddress{{Email: "b@example.com"}}, + Preview: "Totally different content B", + }, + { + ID: "3", + Subject: "Another Topic C", + From: []jmap.EmailAddress{{Email: "c@example.com"}}, + Preview: "Distinct content C", + }, + } + + result := FindSimilarEmails(emails, 0.9) + + // With high threshold and unique emails, should return nil or empty + if result != nil && len(result) > 0 { + // This is okay - might return a small group + } +} + +func TestFindSimilarEmails_NilInput(t *testing.T) { + result := FindSimilarEmails(nil, 0.5) + if result != nil { + t.Errorf("FindSimilarEmails(nil) = %v, want nil", result) + } +} + +func TestGroupSimilarEmails_SingleGroup(t *testing.T) { + // All emails very similar + emails := []jmap.Email{ + { + ID: "1", + Subject: "Newsletter", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Content", + }, + { + ID: "2", + Subject: "Newsletter", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Content", + }, + { + ID: "3", + Subject: "Newsletter", + From: []jmap.EmailAddress{{Email: "news@example.com"}}, + Preview: "Content", + }, + } + + groups := groupSimilarEmails(emails, 0.8) + + if len(groups) == 0 { + t.Error("groupSimilarEmails() should find at least one group") + } + + // First group should have all 3 emails + if len(groups) > 0 && len(groups[0].Emails) != 3 { + t.Errorf("groupSimilarEmails() first group has %d emails, want 3", + len(groups[0].Emails)) + } +} + +func TestCalculateEmailSimilarity_NoFrom(t *testing.T) { + email1 := jmap.Email{ + ID: "1", + Subject: "Test", + From: []jmap.EmailAddress{}, // Empty From + Preview: "Content", + } + + email2 := jmap.Email{ + ID: "2", + Subject: "Test", + From: []jmap.EmailAddress{}, // Empty From + Preview: "Content", + } + + similarity := calculateEmailSimilarity(email1, email2) + + // Should still calculate similarity based on subject and body + if similarity < 0.0 || similarity > 1.0 { + t.Errorf("calculateEmailSimilarity() = %v, want between 0.0 and 1.0", similarity) + } +} + +func TestCalculateEmailSimilarity_NoBody(t *testing.T) { + email1 := jmap.Email{ + ID: "1", + Subject: "Test Subject", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + Preview: "", // No preview + } + + email2 := jmap.Email{ + ID: "2", + Subject: "Test Subject", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + Preview: "", // No preview + } + + similarity := calculateEmailSimilarity(email1, email2) + + // Should calculate based on subject and sender only (0.4 + 0.4 + 0.0) + if similarity < 0.7 || similarity > 0.9 { + t.Errorf("calculateEmailSimilarity() without body = %v, want ~0.8", similarity) + } +} + +func TestCalculateGroupSimilarity_MultipleEmails(t *testing.T) { + emails := []jmap.Email{ + { + ID: "1", + Subject: "Test", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + }, + { + ID: "2", + Subject: "Test", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + }, + { + ID: "3", + Subject: "Test", + From: []jmap.EmailAddress{{Email: "test@example.com"}}, + }, + } + + similarity := calculateGroupSimilarity(emails) + + // Should average all pairwise similarities + if similarity < 0.0 || similarity > 1.0 { + t.Errorf("calculateGroupSimilarity() = %v, want between 0.0 and 1.0", similarity) + } + + // For 3 identical emails, should be high + if similarity < 0.7 { + t.Errorf("calculateGroupSimilarity() for identical emails = %v, want > 0.7", similarity) + } +} From de7f5fd9e5f0bcb05b3d12da0bfe6766e24ab107 Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 21 Nov 2025 09:36:23 +0200 Subject: [PATCH 2/3] Fix GitHub Actions test reporter configuration - Install go-junit-report to convert Go test output to JUnit XML - Change reporter from 'go-test' (invalid) to 'java-junit' (supported) - Update test results file from JSON to XML format - Update test results check to parse XML instead of JSON --- .github/workflows/test.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d85b268..c9a11a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,9 +27,13 @@ jobs: - name: Download dependencies run: go mod download + - name: Install go-junit-report + run: go install github.com/jstemmer/go-junit-report/v2@latest + - name: Run tests with coverage run: | - go test ./... -v -race -coverprofile=coverage.out -covermode=atomic -json > test-results.json || true + go test ./... -v -race -coverprofile=coverage.out -covermode=atomic 2>&1 | tee test-output.txt || true + cat test-output.txt | go-junit-report -set-exit-code > test-results.xml - name: Generate coverage report run: | @@ -48,8 +52,8 @@ jobs: if: always() with: name: Go Test Results - path: test-results.json - reporter: 'go-test' + path: test-results.xml + reporter: 'java-junit' fail-on-error: true fail-on-empty: true @@ -61,7 +65,7 @@ jobs: - name: Check test results run: | - if grep -q '"Action":"fail"' test-results.json; then + if grep -q 'failures="[1-9]' test-results.xml || grep -q 'errors="[1-9]' test-results.xml; then echo "Some tests failed" exit 1 else From 5b330812ed694d94ea35d886084fb368790cef1a Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 21 Nov 2025 09:42:35 +0200 Subject: [PATCH 3/3] Add badges to README - Add GitHub Actions test status badge - Add Go version badge - Add Go Report Card badge - Add Made with Go badge --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 8340f58..c3cf562 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Mailbox Zero - Email Cleanup Helper +[![Test Suite](https://github.com/taskinen/mailboxzero/actions/workflows/test.yml/badge.svg)](https://github.com/taskinen/mailboxzero/actions/workflows/test.yml) +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/) +[![Go Report Card](https://goreportcard.com/badge/github.com/taskinen/mailboxzero)](https://goreportcard.com/report/github.com/taskinen/mailboxzero) +[![Made with Go](https://img.shields.io/badge/Made%20with-Go-1f425f.svg)](https://go.dev/) + A Go-based web application that helps you clean up your Fastmail inbox by finding and archiving similar emails using JMAP protocol. SCR-20250827-psxl