Compare commits

...

3 commits

10 changed files with 1411 additions and 1 deletions

View file

@ -2,6 +2,7 @@
[![Mirror on GitHub](https://img.shields.io/badge/mirror-GitHub-blue)](https://github.com/jakoubek/sendamatic)
[![Go Reference](https://pkg.go.dev/badge/code.beautifulmachines.dev/jakoubek/sendamatic.svg)](https://pkg.go.dev/code.beautifulmachines.dev/jakoubek/sendamatic)
[![Go Report Card](https://goreportcard.com/badge/code.beautifulmachines.dev/jakoubek/sendamatic)](https://goreportcard.com/report/code.beautifulmachines.dev/jakoubek/sendamatic)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
A Go client library for the [Sendamatic](https://www.sendamatic.net) email delivery API.

406
client_test.go Normal file
View file

@ -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")
}
}

229
errors_test.go Normal file
View file

@ -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)
}
}

2
go.mod
View file

@ -1,3 +1,3 @@
module code.beautifulmachines.dev/jakoubek/sendamatic
go 1.25.4
go 1.22

285
message_test.go Normal file
View file

@ -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("<p>Test Body</p>").
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 != "<p>Test Body</p>" {
t.Errorf("HTMLBody = %q, want %q", msg.HTMLBody, "<p>Test Body</p>")
}
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("<p>Body</p>"),
},
{
name: "valid with both bodies",
msg: NewMessage().
SetSender("sender@example.com").
AddTo("to@example.com").
SetSubject("Subject").
SetTextBody("Body").
SetHTMLBody("<p>Body</p>"),
},
{
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)
}
}

154
options_test.go Normal file
View file

@ -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)
}
})
}
}

276
response_test.go Normal file
View file

@ -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)
}
}

58
testdata/test.pdf vendored Normal file
View file

@ -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

BIN
testdata/test.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

1
testdata/test.txt vendored Normal file
View file

@ -0,0 +1 @@
This is a test text file for attachment testing.