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
+[](https://github.com/taskinen/mailboxzero/actions/workflows/test.yml)
+[](https://go.dev/)
+[](https://goreportcard.com/report/github.com/taskinen/mailboxzero)
+[](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.
@@ -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 := `
+
+