diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c9a11a8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,73 @@ +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: 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 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: | + 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.xml + reporter: 'java-junit' + 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 'failures="[1-9]' test-results.xml || grep -q 'errors="[1-9]' test-results.xml; 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..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 @@ -169,6 +174,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) + } +}