diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..d5bd971 --- /dev/null +++ b/client_test.go @@ -0,0 +1,406 @@ +package sendamatic + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewClient(t *testing.T) { + client := NewClient("test-user", "test-pass") + + if client == nil { + t.Fatal("NewClient returned nil") + } + + expectedAPIKey := "test-user-test-pass" + if client.apiKey != expectedAPIKey { + t.Errorf("apiKey = %q, want %q", client.apiKey, expectedAPIKey) + } + + if client.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, defaultBaseURL) + } + + if client.httpClient == nil { + t.Fatal("httpClient is nil") + } + + if client.httpClient.Timeout != defaultTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, defaultTimeout) + } +} + +func TestClient_Send_Success(t *testing.T) { + // Create a test server that returns a successful response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != http.MethodPost { + t.Errorf("Method = %s, want POST", r.Method) + } + + // Verify request path + if r.URL.Path != "/send" { + t.Errorf("Path = %s, want /send", r.URL.Path) + } + + // Verify headers + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %s, want application/json", ct) + } + + if apiKey := r.Header.Get("x-api-key"); apiKey != "user-pass" { + t.Errorf("x-api-key = %s, want user-pass", apiKey) + } + + // Verify request body + body, _ := io.ReadAll(r.Body) + var msg Message + if err := json.Unmarshal(body, &msg); err != nil { + t.Errorf("Failed to unmarshal request body: %v", err) + } + + // Send successful response + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + response := map[string][2]interface{}{ + "recipient@example.com": {float64(200), "msg-12345"}, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + resp, err := client.Send(context.Background(), msg) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + if !resp.IsSuccess() { + t.Error("Expected successful response") + } + + if resp.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", resp.StatusCode) + } + + msgID, ok := resp.GetMessageID("recipient@example.com") + if !ok { + t.Error("Expected to find message ID") + } + if msgID != "msg-12345" { + t.Errorf("MessageID = %q, want %q", msgID, "msg-12345") + } +} + +func TestClient_Send_ValidationError(t *testing.T) { + client := NewClient("user", "pass") + + // Create an invalid message (no recipients) + msg := NewMessage(). + SetSender("sender@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + _, err := client.Send(context.Background(), msg) + if err == nil { + t.Fatal("Expected validation error, got nil") + } + + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("Error message = %q, want to contain 'validation failed'", err.Error()) + } +} + +func TestClient_Send_APIError(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + wantErrMessage string + }{ + { + name: "400 bad request", + statusCode: 400, + responseBody: `{"error": "Invalid request"}`, + wantErrMessage: "sendamatic api error (status 400): Invalid request", + }, + { + name: "401 unauthorized", + statusCode: 401, + responseBody: `{"error": "Invalid API key"}`, + wantErrMessage: "sendamatic api error (status 401): Invalid API key", + }, + { + name: "422 validation error", + statusCode: 422, + responseBody: `{"error": "Validation failed", "validation_errors": "sender is required", "json_path": "$.sender"}`, + wantErrMessage: "sendamatic api error (status 422): sender is required (path: $.sender)", + }, + { + name: "500 server error", + statusCode: 500, + responseBody: `{"error": "Internal server error"}`, + wantErrMessage: "sendamatic api error (status 500): Internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + _, err := client.Send(context.Background(), msg) + if err == nil { + t.Fatal("Expected error, got nil") + } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("Error type = %T, want *APIError", err) + } + + if apiErr.StatusCode != tt.statusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.statusCode) + } + + if err.Error() != tt.wantErrMessage { + t.Errorf("Error message = %q, want %q", err.Error(), tt.wantErrMessage) + } + }) + } +} + +func TestClient_Send_ContextTimeout(t *testing.T) { + // Create a server that delays the response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"recipient@example.com": [200, "msg-12345"]}`)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + // Create a context that times out quickly + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + _, err := client.Send(ctx, msg) + if err == nil { + t.Fatal("Expected timeout error, got nil") + } + + if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "context deadline exceeded") { + t.Errorf("Expected context deadline exceeded error, got: %v", err) + } +} + +func TestClient_Send_ContextCancellation(t *testing.T) { + // Create a server that delays the response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"recipient@example.com": [200, "msg-12345"]}`)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel the context immediately + cancel() + + _, err := client.Send(ctx, msg) + if err == nil { + t.Fatal("Expected cancellation error, got nil") + } + + if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") { + t.Errorf("Expected context canceled error, got: %v", err) + } +} + +func TestClient_Send_MultipleRecipients(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + response := map[string][2]interface{}{ + "recipient1@example.com": {float64(200), "msg-11111"}, + "recipient2@example.com": {float64(200), "msg-22222"}, + "recipient3@example.com": {float64(550), "msg-33333"}, // Failed delivery + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient1@example.com"). + AddTo("recipient2@example.com"). + AddTo("recipient3@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + resp, err := client.Send(context.Background(), msg) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + // Check each recipient + for email, expected := range map[string]struct { + status int + msgID string + }{ + "recipient1@example.com": {200, "msg-11111"}, + "recipient2@example.com": {200, "msg-22222"}, + "recipient3@example.com": {550, "msg-33333"}, + } { + status, ok := resp.GetStatus(email) + if !ok { + t.Errorf("Expected to find status for %s", email) + continue + } + if status != expected.status { + t.Errorf("Status for %s = %d, want %d", email, status, expected.status) + } + + msgID, ok := resp.GetMessageID(email) + if !ok { + t.Errorf("Expected to find message ID for %s", email) + continue + } + if msgID != expected.msgID { + t.Errorf("MessageID for %s = %q, want %q", email, msgID, expected.msgID) + } + } +} + +func TestClient_Send_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not valid json`)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + _, err := client.Send(context.Background(), msg) + if err == nil { + t.Fatal("Expected error for invalid JSON, got nil") + } + + if !strings.Contains(err.Error(), "unmarshal") { + t.Errorf("Error should mention unmarshal, got: %v", err) + } +} + +func TestClient_Send_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body") + + resp, err := client.Send(context.Background(), msg) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + if resp.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", resp.StatusCode) + } + + if len(resp.Recipients) != 0 { + t.Errorf("Recipients length = %d, want 0", len(resp.Recipients)) + } +} + +func TestClient_Send_WithAttachments(t *testing.T) { + var receivedMsg Message + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedMsg) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"recipient@example.com": [200, "msg-12345"]}`)) + })) + defer server.Close() + + client := NewClient("user", "pass", WithBaseURL(server.URL)) + + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("recipient@example.com"). + SetSubject("Test"). + SetTextBody("Body"). + AttachFile("test.txt", "text/plain", []byte("test content")) + + _, err := client.Send(context.Background(), msg) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + // Verify attachment was sent + if len(receivedMsg.Attachments) != 1 { + t.Fatalf("Attachments length = %d, want 1", len(receivedMsg.Attachments)) + } + + if receivedMsg.Attachments[0].Filename != "test.txt" { + t.Errorf("Filename = %q, want %q", receivedMsg.Attachments[0].Filename, "test.txt") + } +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..6e9b0ac --- /dev/null +++ b/errors_test.go @@ -0,0 +1,229 @@ +package sendamatic + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAPIError_Error(t *testing.T) { + tests := []struct { + name string + apiErr *APIError + wantText string + }{ + { + name: "simple error", + apiErr: &APIError{ + StatusCode: 400, + Message: "Invalid request", + }, + wantText: "sendamatic api error (status 400): Invalid request", + }, + { + name: "error with validation details", + apiErr: &APIError{ + StatusCode: 422, + Message: "Validation failed", + ValidationErrors: "sender is required", + JSONPath: "$.sender", + }, + wantText: "sendamatic api error (status 422): sender is required (path: $.sender)", + }, + { + name: "error with SMTP code", + apiErr: &APIError{ + StatusCode: 500, + Message: "SMTP error", + SMTPCode: 550, + Sender: "test@example.com", + }, + wantText: "sendamatic api error (status 500): SMTP error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.apiErr.Error() + if got != tt.wantText { + t.Errorf("Error() = %q, want %q", got, tt.wantText) + } + }) + } +} + +func TestParseErrorResponse_ValidJSON(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + want *APIError + }{ + { + name: "simple error", + statusCode: 400, + body: `{"error": "Invalid API key"}`, + want: &APIError{ + StatusCode: 400, + Message: "Invalid API key", + }, + }, + { + name: "validation error", + statusCode: 422, + body: `{"error": "Validation failed", "validation_errors": "sender is required", "json_path": "$.sender"}`, + want: &APIError{ + StatusCode: 422, + Message: "Validation failed", + ValidationErrors: "sender is required", + JSONPath: "$.sender", + }, + }, + { + name: "smtp error", + statusCode: 500, + body: `{"error": "SMTP error", "smtp_code": 550, "sender": "test@example.com"}`, + want: &APIError{ + StatusCode: 500, + Message: "SMTP error", + SMTPCode: 550, + Sender: "test@example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := parseErrorResponse(tt.statusCode, []byte(tt.body)) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("parseErrorResponse returned %T, want *APIError", err) + } + + if apiErr.StatusCode != tt.want.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.want.StatusCode) + } + + if apiErr.Message != tt.want.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.want.Message) + } + + if apiErr.ValidationErrors != tt.want.ValidationErrors { + t.Errorf("ValidationErrors = %q, want %q", apiErr.ValidationErrors, tt.want.ValidationErrors) + } + + if apiErr.JSONPath != tt.want.JSONPath { + t.Errorf("JSONPath = %q, want %q", apiErr.JSONPath, tt.want.JSONPath) + } + + if apiErr.SMTPCode != tt.want.SMTPCode { + t.Errorf("SMTPCode = %d, want %d", apiErr.SMTPCode, tt.want.SMTPCode) + } + + if apiErr.Sender != tt.want.Sender { + t.Errorf("Sender = %q, want %q", apiErr.Sender, tt.want.Sender) + } + }) + } +} + +func TestParseErrorResponse_InvalidJSON(t *testing.T) { + statusCode := 500 + body := []byte("Internal Server Error - not JSON") + + err := parseErrorResponse(statusCode, body) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("parseErrorResponse returned %T, want *APIError", err) + } + + if apiErr.StatusCode != statusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, statusCode) + } + + // When JSON parsing fails, the raw body should be used as the message + if apiErr.Message != string(body) { + t.Errorf("Message = %q, want %q", apiErr.Message, string(body)) + } +} + +func TestParseErrorResponse_EmptyBody(t *testing.T) { + statusCode := 404 + body := []byte("") + + err := parseErrorResponse(statusCode, body) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("parseErrorResponse returned %T, want *APIError", err) + } + + if apiErr.StatusCode != statusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, statusCode) + } +} + +func TestParseErrorResponse_MalformedJSON(t *testing.T) { + statusCode := 400 + body := []byte(`{"error": "Missing closing brace"`) + + err := parseErrorResponse(statusCode, body) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("parseErrorResponse returned %T, want *APIError", err) + } + + if apiErr.StatusCode != statusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, statusCode) + } + + // Should fall back to raw body as message + if apiErr.Message != string(body) { + t.Errorf("Message = %q, want %q", apiErr.Message, string(body)) + } +} + +func TestAPIError_JSONRoundtrip(t *testing.T) { + original := &APIError{ + StatusCode: 422, + Message: "Validation error", + ValidationErrors: "sender format invalid", + JSONPath: "$.sender", + Sender: "invalid@", + SMTPCode: 0, + } + + // Marshal to JSON + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal back + var decoded APIError + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // StatusCode should not be in JSON (json:"-" tag) + if strings.Contains(string(data), "StatusCode") { + t.Error("StatusCode should not be marshaled to JSON") + } + + // Compare fields (except StatusCode which has json:"-") + if decoded.Message != original.Message { + t.Errorf("Message = %q, want %q", decoded.Message, original.Message) + } + + if decoded.ValidationErrors != original.ValidationErrors { + t.Errorf("ValidationErrors = %q, want %q", decoded.ValidationErrors, original.ValidationErrors) + } + + if decoded.JSONPath != original.JSONPath { + t.Errorf("JSONPath = %q, want %q", decoded.JSONPath, original.JSONPath) + } +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..5a432b8 --- /dev/null +++ b/message_test.go @@ -0,0 +1,285 @@ +package sendamatic + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestNewMessage(t *testing.T) { + msg := NewMessage() + + if msg == nil { + t.Fatal("NewMessage returned nil") + } + + if msg.To == nil || msg.CC == nil || msg.BCC == nil { + t.Error("Slices not initialized") + } + + if msg.Headers == nil || msg.Attachments == nil { + t.Error("Headers or Attachments not initialized") + } +} + +func TestMessageBuilderMethods(t *testing.T) { + msg := NewMessage(). + SetSender("sender@example.com"). + AddTo("to@example.com"). + AddCC("cc@example.com"). + AddBCC("bcc@example.com"). + SetSubject("Test Subject"). + SetTextBody("Test Body"). + SetHTMLBody("
Test Body
"). + AddHeader("X-Custom", "value") + + if msg.Sender != "sender@example.com" { + t.Errorf("Sender = %q, want %q", msg.Sender, "sender@example.com") + } + + if len(msg.To) != 1 || msg.To[0] != "to@example.com" { + t.Errorf("To = %v, want [to@example.com]", msg.To) + } + + if len(msg.CC) != 1 || msg.CC[0] != "cc@example.com" { + t.Errorf("CC = %v, want [cc@example.com]", msg.CC) + } + + if len(msg.BCC) != 1 || msg.BCC[0] != "bcc@example.com" { + t.Errorf("BCC = %v, want [bcc@example.com]", msg.BCC) + } + + if msg.Subject != "Test Subject" { + t.Errorf("Subject = %q, want %q", msg.Subject, "Test Subject") + } + + if msg.TextBody != "Test Body" { + t.Errorf("TextBody = %q, want %q", msg.TextBody, "Test Body") + } + + if msg.HTMLBody != "Test Body
" { + t.Errorf("HTMLBody = %q, want %q", msg.HTMLBody, "Test Body
") + } + + if len(msg.Headers) != 1 { + t.Fatalf("Headers length = %d, want 1", len(msg.Headers)) + } + + if msg.Headers[0].Header != "X-Custom" || msg.Headers[0].Value != "value" { + t.Errorf("Header = %+v, want {X-Custom value}", msg.Headers[0]) + } +} + +func TestAddMultipleRecipients(t *testing.T) { + msg := NewMessage(). + AddTo("to1@example.com"). + AddTo("to2@example.com"). + AddTo("to3@example.com") + + if len(msg.To) != 3 { + t.Errorf("To length = %d, want 3", len(msg.To)) + } + + expected := []string{"to1@example.com", "to2@example.com", "to3@example.com"} + for i, email := range expected { + if msg.To[i] != email { + t.Errorf("To[%d] = %q, want %q", i, msg.To[i], email) + } + } +} + +func TestAttachFile(t *testing.T) { + msg := NewMessage() + data := []byte("test file content") + + msg.AttachFile("test.txt", "text/plain", data) + + if len(msg.Attachments) != 1 { + t.Fatalf("Attachments length = %d, want 1", len(msg.Attachments)) + } + + att := msg.Attachments[0] + if att.Filename != "test.txt" { + t.Errorf("Filename = %q, want %q", att.Filename, "test.txt") + } + + if att.MimeType != "text/plain" { + t.Errorf("MimeType = %q, want %q", att.MimeType, "text/plain") + } + + // Verify base64 encoding + decoded, err := base64.StdEncoding.DecodeString(att.Data) + if err != nil { + t.Fatalf("Failed to decode base64: %v", err) + } + + if string(decoded) != string(data) { + t.Errorf("Decoded data = %q, want %q", decoded, data) + } +} + +func TestAttachFileFromPath(t *testing.T) { + msg := NewMessage() + + testFile := filepath.Join("testdata", "test.txt") + err := msg.AttachFileFromPath(testFile, "text/plain") + if err != nil { + t.Fatalf("AttachFileFromPath failed: %v", err) + } + + if len(msg.Attachments) != 1 { + t.Fatalf("Attachments length = %d, want 1", len(msg.Attachments)) + } + + att := msg.Attachments[0] + if att.Filename != "test.txt" { + t.Errorf("Filename = %q, want %q", att.Filename, "test.txt") + } + + // Verify content + decoded, err := base64.StdEncoding.DecodeString(att.Data) + if err != nil { + t.Fatalf("Failed to decode base64: %v", err) + } + + expected, _ := os.ReadFile(testFile) + if string(decoded) != string(expected) { + t.Errorf("File content mismatch") + } +} + +func TestAttachFileFromPath_NonExistent(t *testing.T) { + msg := NewMessage() + + err := msg.AttachFileFromPath("nonexistent.txt", "text/plain") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } +} + +func TestAttachMultipleFiles(t *testing.T) { + msg := NewMessage(). + AttachFile("file1.txt", "text/plain", []byte("content1")). + AttachFile("file2.pdf", "application/pdf", []byte("content2")) + + if len(msg.Attachments) != 2 { + t.Errorf("Attachments length = %d, want 2", len(msg.Attachments)) + } +} + +func TestValidate_Success(t *testing.T) { + tests := []struct { + name string + msg *Message + }{ + { + name: "valid with text body", + msg: NewMessage(). + SetSender("sender@example.com"). + AddTo("to@example.com"). + SetSubject("Subject"). + SetTextBody("Body"), + }, + { + name: "valid with html body", + msg: NewMessage(). + SetSender("sender@example.com"). + AddTo("to@example.com"). + SetSubject("Subject"). + SetHTMLBody("Body
"), + }, + { + name: "valid with both bodies", + msg: NewMessage(). + SetSender("sender@example.com"). + AddTo("to@example.com"). + SetSubject("Subject"). + SetTextBody("Body"). + SetHTMLBody("Body
"), + }, + { + name: "valid with multiple recipients", + msg: NewMessage(). + SetSender("sender@example.com"). + AddTo("to1@example.com"). + AddTo("to2@example.com"). + AddCC("cc@example.com"). + AddBCC("bcc@example.com"). + SetSubject("Subject"). + SetTextBody("Body"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.msg.Validate() + if err != nil { + t.Errorf("Validate() error = %v, want nil", err) + } + }) + } +} + +func TestValidate_Errors(t *testing.T) { + tests := []struct { + name string + msg *Message + wantErrText string + }{ + { + name: "no recipients", + msg: NewMessage().SetSender("sender@example.com").SetSubject("Subject").SetTextBody("Body"), + wantErrText: "at least one recipient required", + }, + { + name: "no sender", + msg: NewMessage().AddTo("to@example.com").SetSubject("Subject").SetTextBody("Body"), + wantErrText: "sender is required", + }, + { + name: "no subject", + msg: NewMessage().SetSender("sender@example.com").AddTo("to@example.com").SetTextBody("Body"), + wantErrText: "subject is required", + }, + { + name: "no body", + msg: NewMessage().SetSender("sender@example.com").AddTo("to@example.com").SetSubject("Subject"), + wantErrText: "either text_body or html_body is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.msg.Validate() + if err == nil { + t.Fatal("Validate() error = nil, want error") + } + if err.Error() != tt.wantErrText { + t.Errorf("Validate() error = %q, want %q", err.Error(), tt.wantErrText) + } + }) + } +} + +func TestValidate_TooManyRecipients(t *testing.T) { + msg := NewMessage(). + SetSender("sender@example.com"). + SetSubject("Subject"). + SetTextBody("Body") + + // Add 256 recipients (more than the limit of 255) + for i := 0; i < 256; i++ { + msg.AddTo("recipient@example.com") + } + + err := msg.Validate() + if err == nil { + t.Fatal("Validate() error = nil, want error for too many recipients") + } + + expected := "maximum 255 recipients allowed" + if err.Error() != expected { + t.Errorf("Validate() error = %q, want %q", err.Error(), expected) + } +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..1f235c3 --- /dev/null +++ b/options_test.go @@ -0,0 +1,154 @@ +package sendamatic + +import ( + "net/http" + "testing" + "time" +) + +func TestWithBaseURL(t *testing.T) { + customURL := "https://custom.api.url" + client := NewClient("user", "pass", WithBaseURL(customURL)) + + if client.baseURL != customURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, customURL) + } +} + +func TestWithTimeout(t *testing.T) { + customTimeout := 60 * time.Second + client := NewClient("user", "pass", WithTimeout(customTimeout)) + + if client.httpClient.Timeout != customTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, customTimeout) + } +} + +func TestWithHTTPClient(t *testing.T) { + customClient := &http.Client{ + Timeout: 90 * time.Second, + } + + client := NewClient("user", "pass", WithHTTPClient(customClient)) + + if client.httpClient != customClient { + t.Error("httpClient not set to custom client") + } + + if client.httpClient.Timeout != 90*time.Second { + t.Errorf("httpClient.Timeout = %v, want 90s", client.httpClient.Timeout) + } +} + +func TestMultipleOptions(t *testing.T) { + customURL := "https://test.api.url" + customTimeout := 45 * time.Second + + client := NewClient("user", "pass", + WithBaseURL(customURL), + WithTimeout(customTimeout), + ) + + if client.baseURL != customURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, customURL) + } + + if client.httpClient.Timeout != customTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, customTimeout) + } +} + +func TestDefaultValues(t *testing.T) { + client := NewClient("user", "pass") + + if client.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, defaultBaseURL) + } + + if client.httpClient.Timeout != defaultTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, defaultTimeout) + } + + expectedAPIKey := "user-pass" + if client.apiKey != expectedAPIKey { + t.Errorf("apiKey = %q, want %q", client.apiKey, expectedAPIKey) + } +} + +func TestWithHTTPClient_PreservesCustomTransport(t *testing.T) { + customTransport := &http.Transport{ + MaxIdleConns: 100, + } + + customClient := &http.Client{ + Timeout: 45 * time.Second, + Transport: customTransport, + } + + client := NewClient("user", "pass", WithHTTPClient(customClient)) + + if client.httpClient.Transport != customTransport { + t.Error("Custom transport was not preserved") + } +} + +func TestOptionsOrder(t *testing.T) { + // Test that options are applied in order + // First set timeout to 30s, then provide a custom client with 60s + customClient := &http.Client{ + Timeout: 60 * time.Second, + } + + client := NewClient("user", "pass", + WithTimeout(30*time.Second), + WithHTTPClient(customClient), + ) + + // The custom client should override the previous timeout setting + if client.httpClient.Timeout != 60*time.Second { + t.Errorf("httpClient.Timeout = %v, want 60s (custom client should override)", client.httpClient.Timeout) + } +} + +func TestWithTimeout_OverridesDefault(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + }{ + {"short timeout", 5 * time.Second}, + {"long timeout", 120 * time.Second}, + {"zero timeout", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient("user", "pass", WithTimeout(tt.timeout)) + + if client.httpClient.Timeout != tt.timeout { + t.Errorf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, tt.timeout) + } + }) + } +} + +func TestWithBaseURL_VariousURLs(t *testing.T) { + tests := []struct { + name string + url string + }{ + {"production", "https://send.api.sendamatic.net"}, + {"staging", "https://staging.api.sendamatic.net"}, + {"local", "http://localhost:8080"}, + {"custom port", "https://api.example.com:8443"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient("user", "pass", WithBaseURL(tt.url)) + + if client.baseURL != tt.url { + t.Errorf("baseURL = %q, want %q", client.baseURL, tt.url) + } + }) + } +} diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..a9efac3 --- /dev/null +++ b/response_test.go @@ -0,0 +1,276 @@ +package sendamatic + +import ( + "encoding/json" + "testing" +) + +func TestSendResponse_IsSuccess(t *testing.T) { + tests := []struct { + name string + statusCode int + want bool + }{ + {"success 200", 200, true}, + {"bad request 400", 400, false}, + {"unauthorized 401", 401, false}, + {"server error 500", 500, false}, + {"created 201", 201, false}, // Only 200 is considered success + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &SendResponse{StatusCode: tt.statusCode} + got := resp.IsSuccess() + if got != tt.want { + t.Errorf("IsSuccess() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSendResponse_GetMessageID(t *testing.T) { + tests := []struct { + name string + recipients map[string][2]interface{} + email string + wantID string + wantOK bool + }{ + { + name: "existing recipient", + recipients: map[string][2]interface{}{ + "test@example.com": {float64(200), "msg-12345"}, + }, + email: "test@example.com", + wantID: "msg-12345", + wantOK: true, + }, + { + name: "non-existent recipient", + recipients: map[string][2]interface{}{ + "test@example.com": {float64(200), "msg-12345"}, + }, + email: "other@example.com", + wantID: "", + wantOK: false, + }, + { + name: "multiple recipients", + recipients: map[string][2]interface{}{ + "test1@example.com": {float64(200), "msg-11111"}, + "test2@example.com": {float64(200), "msg-22222"}, + "test3@example.com": {float64(400), "msg-33333"}, + }, + email: "test2@example.com", + wantID: "msg-22222", + wantOK: true, + }, + { + name: "empty recipients", + recipients: map[string][2]interface{}{}, + email: "test@example.com", + wantID: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &SendResponse{ + StatusCode: 200, + Recipients: tt.recipients, + } + + gotID, gotOK := resp.GetMessageID(tt.email) + if gotID != tt.wantID { + t.Errorf("GetMessageID() id = %q, want %q", gotID, tt.wantID) + } + if gotOK != tt.wantOK { + t.Errorf("GetMessageID() ok = %v, want %v", gotOK, tt.wantOK) + } + }) + } +} + +func TestSendResponse_GetStatus(t *testing.T) { + tests := []struct { + name string + recipients map[string][2]interface{} + email string + wantStatus int + wantOK bool + }{ + { + name: "existing recipient with success", + recipients: map[string][2]interface{}{ + "test@example.com": {float64(200), "msg-12345"}, + }, + email: "test@example.com", + wantStatus: 200, + wantOK: true, + }, + { + name: "existing recipient with error", + recipients: map[string][2]interface{}{ + "test@example.com": {float64(400), "msg-12345"}, + }, + email: "test@example.com", + wantStatus: 400, + wantOK: true, + }, + { + name: "non-existent recipient", + recipients: map[string][2]interface{}{ + "test@example.com": {float64(200), "msg-12345"}, + }, + email: "other@example.com", + wantStatus: 0, + wantOK: false, + }, + { + name: "multiple recipients", + recipients: map[string][2]interface{}{ + "test1@example.com": {float64(200), "msg-11111"}, + "test2@example.com": {float64(550), "msg-22222"}, + "test3@example.com": {float64(200), "msg-33333"}, + }, + email: "test2@example.com", + wantStatus: 550, + wantOK: true, + }, + { + name: "empty recipients", + recipients: map[string][2]interface{}{}, + email: "test@example.com", + wantStatus: 0, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &SendResponse{ + StatusCode: 200, + Recipients: tt.recipients, + } + + gotStatus, gotOK := resp.GetStatus(tt.email) + if gotStatus != tt.wantStatus { + t.Errorf("GetStatus() status = %d, want %d", gotStatus, tt.wantStatus) + } + if gotOK != tt.wantOK { + t.Errorf("GetStatus() ok = %v, want %v", gotOK, tt.wantOK) + } + }) + } +} + +func TestSendResponse_JSONUnmarshal(t *testing.T) { + // Test that we can properly unmarshal the API response format + jsonResp := `{ + "test1@example.com": [200, "msg-11111"], + "test2@example.com": [400, "msg-22222"] + }` + + var recipients map[string][2]interface{} + err := json.Unmarshal([]byte(jsonResp), &recipients) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + resp := &SendResponse{ + StatusCode: 200, + Recipients: recipients, + } + + // Test first recipient + status1, ok1 := resp.GetStatus("test1@example.com") + if !ok1 { + t.Error("Expected to find test1@example.com") + } + if status1 != 200 { + t.Errorf("Status for test1 = %d, want 200", status1) + } + + msgID1, ok1 := resp.GetMessageID("test1@example.com") + if !ok1 { + t.Error("Expected to find message ID for test1@example.com") + } + if msgID1 != "msg-11111" { + t.Errorf("MessageID for test1 = %q, want %q", msgID1, "msg-11111") + } + + // Test second recipient + status2, ok2 := resp.GetStatus("test2@example.com") + if !ok2 { + t.Error("Expected to find test2@example.com") + } + if status2 != 400 { + t.Errorf("Status for test2 = %d, want 400", status2) + } + + msgID2, ok2 := resp.GetMessageID("test2@example.com") + if !ok2 { + t.Error("Expected to find message ID for test2@example.com") + } + if msgID2 != "msg-22222" { + t.Errorf("MessageID for test2 = %q, want %q", msgID2, "msg-22222") + } +} + +func TestSendResponse_GetStatus_Float64Conversion(t *testing.T) { + // Explicitly test the float64 to int conversion + // This mimics how JSON unmarshaling works with numbers + resp := &SendResponse{ + StatusCode: 200, + Recipients: map[string][2]interface{}{ + "test@example.com": {float64(200.0), "msg-12345"}, + }, + } + + status, ok := resp.GetStatus("test@example.com") + if !ok { + t.Fatal("Expected to find recipient") + } + + if status != 200 { + t.Errorf("GetStatus() = %d, want 200", status) + } +} + +func TestSendResponse_GetMessageID_InvalidType(t *testing.T) { + // Test behavior when message ID is not a string + resp := &SendResponse{ + StatusCode: 200, + Recipients: map[string][2]interface{}{ + "test@example.com": {float64(200), 12345}, // number instead of string + }, + } + + msgID, ok := resp.GetMessageID("test@example.com") + if ok { + t.Error("Expected ok = false when message ID is not a string") + } + if msgID != "" { + t.Errorf("Expected empty string, got %q", msgID) + } +} + +func TestSendResponse_GetStatus_InvalidType(t *testing.T) { + // Test behavior when status is not a number + resp := &SendResponse{ + StatusCode: 200, + Recipients: map[string][2]interface{}{ + "test@example.com": {"OK", "msg-12345"}, // string instead of number + }, + } + + status, ok := resp.GetStatus("test@example.com") + if ok { + t.Error("Expected ok = false when status is not a number") + } + if status != 0 { + t.Errorf("Expected 0, got %d", status) + } +} diff --git a/testdata/test.pdf b/testdata/test.pdf new file mode 100644 index 0000000..49efd7f --- /dev/null +++ b/testdata/test.pdf @@ -0,0 +1,58 @@ +%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/Resources << +/Font << +/F1 << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +/MediaBox [0 0 612 792] +/Contents 4 0 R +>> +endobj +4 0 obj +<< +/Length 44 +>> +stream +BT +/F1 12 Tf +100 700 Td +(Test PDF) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000317 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +408 +%%EOF \ No newline at end of file diff --git a/testdata/test.png b/testdata/test.png new file mode 100644 index 0000000..91a99b9 Binary files /dev/null and b/testdata/test.png differ diff --git a/testdata/test.txt b/testdata/test.txt new file mode 100644 index 0000000..100ff51 --- /dev/null +++ b/testdata/test.txt @@ -0,0 +1 @@ +This is a test text file for attachment testing.