Files
pop/internal/mail/client_test.go
Senior Engineer 5dc4a1b742
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Fix FRE-4693 code review findings: 2-arg constructor, 403 error test, error content check
- Pass nil refresher to NewProtonMailClient at all 5 call sites
- Change TestListMessages_APIError from 401 to 403 (avoids refresh interception)
- Add error content assertion to TestGetMessage_NotFound
2026-05-10 09:38:21 -04:00

1390 lines
35 KiB
Go

package mail
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
)
// mockServer wraps an httptest.Server with configurable handlers.
type mockServer struct {
mux *http.ServeMux
server *httptest.Server
handlers sync.Map
}
func newMockServer(t *testing.T) *mockServer {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
ms := &mockServer{
mux: mux,
server: srv,
}
mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /api/messages/{id}", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /api/messages/{id}/movetotrash", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /api/messages/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
mux.HandleFunc("POST /api/messages/{id}/send", func(w http.ResponseWriter, r *http.Request) {
resolveHandler(ms, w, r)
})
return ms
}
func (ms *mockServer) URL() string {
return ms.server.URL
}
func (ms *mockServer) Close() {
ms.server.Close()
}
func (ms *mockServer) Handle(key string, handler http.HandlerFunc) {
ms.handlers.Store(key, handler)
}
func resolveHandler(ms *mockServer, w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + r.URL.Path
if handler, loaded := ms.handlers.Load(key); loaded {
handler.(http.HandlerFunc)(w, r)
return
}
// When POST /api/messages is called, it matches both list and send/draft.
// The generic handler for POST /api/messages catches all unmatched POST /api/messages calls.
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
}
// newTestClient creates a mail.Client backed by a mock server.
func newTestClient(t *testing.T, srv *mockServer) *Client {
t.Helper()
cfg := &config.Config{
APIBaseURL: srv.URL(),
TimeoutSec: 5,
RateLimitReq: 100,
RateLimitWin: 60,
}
apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token")
return NewClient(apiClient)
}
// readJSON reads request body and returns parsed map.
func readJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
return m
}
// writeJSON writes a JSON response.
func writeJSON(t *testing.T, w http.ResponseWriter, code int, v interface{}) {
t.Helper()
w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// ---------- ListMessages ----------
func TestListMessages_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
expected := ListMessagesResponse{
Total: 2,
Messages: []Message{
{MessageID: "msg-1", Subject: "Hello", Sender: Recipient{Address: "alice@example.com"}},
{MessageID: "msg-2", Subject: "World", Sender: Recipient{Address: "bob@example.com"}},
},
}
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Page"] != float64(1) {
t.Errorf("expected page 1, got %v", body["Page"])
}
if body["PageSize"] != float64(25) {
t.Errorf("expected page size 25, got %v", body["PageSize"])
}
if body["Passphrase"] != "test-pass" {
t.Errorf("expected passphrase test-pass, got %v", body["Passphrase"])
}
writeJSON(t, w, http.StatusOK, expected)
})
resp, err := client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 25,
Passphrase: "test-pass",
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if resp.Total != 2 {
t.Errorf("expected 2 messages, got %d", resp.Total)
}
if len(resp.Messages) != 2 {
t.Errorf("expected 2 messages in slice, got %d", len(resp.Messages))
}
if resp.Messages[0].Subject != "Hello" {
t.Errorf("expected subject Hello, got %s", resp.Messages[0].Subject)
}
}
func TestListMessages_WithFolderFilter(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Type"] != float64(FolderSent) {
t.Errorf("expected Type=3 (sent), got %v", body["Type"])
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderSent,
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}
func TestListMessages_InboxOmitsType(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["Type"]; ok {
t.Error("Inbox should omit Type field")
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderInbox,
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}
func TestListMessages_WithStarredFilter(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
starred := true
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Starred"] != true {
t.Errorf("expected Starred=true, got %v", body["Starred"])
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderInbox,
Page: 1,
PageSize: 10,
Passphrase: "pass",
Starred: &starred,
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}
func TestListMessages_WithReadFilter(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
unread := false
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Read"] != false {
t.Errorf("expected Read=false, got %v", body["Read"])
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderInbox,
Page: 1,
PageSize: 10,
Passphrase: "pass",
Read: &unread,
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}
func TestListMessages_WithSinceFilter(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
since := int64(1700000000)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Since"] != float64(since) {
t.Errorf("expected Since=%d, got %v", since, body["Since"])
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderInbox,
Page: 1,
PageSize: 10,
Passphrase: "pass",
Since: since,
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}
func TestListMessages_APIError(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"Code":403,"Message":"invalid token"}`)
})
_, err := client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected error for 403 response")
}
if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("expected 'invalid token' in error, got: %s", err.Error())
}
}
func TestListMessages_BadJSON(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`)
})
_, err := client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected error for bad JSON")
}
if !strings.Contains(err.Error(), "failed to parse response") {
t.Errorf("unexpected error: %s", err.Error())
}
}
// ---------- GetMessage ----------
func TestGetMessage_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
expectedMsg := Message{
MessageID: "msg-42",
Subject: "Test Message",
Sender: Recipient{Address: "sender@example.com"},
Body: "Decrypted body content",
}
srv.Handle("POST /api/messages/msg-42", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Passphrase"] != "pass" {
t.Errorf("expected passphrase pass, got %v", body["Passphrase"])
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": expectedMsg})
})
msg, err := client.GetMessage("msg-42", "pass")
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.MessageID != "msg-42" {
t.Errorf("expected msg-42, got %s", msg.MessageID)
}
if msg.Subject != "Test Message" {
t.Errorf("expected subject Test Message, got %s", msg.Subject)
}
if msg.Body != "Decrypted body content" {
t.Errorf("expected body content, got %s", msg.Body)
}
}
func TestGetMessage_URLEscape(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
msgID := "msg/with/slashes"
srv.Handle("POST /api/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": Message{MessageID: msgID}})
})
msg, err := client.GetMessage(msgID, "pass")
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.MessageID != msgID {
t.Errorf("expected %s, got %s", msgID, msg.MessageID)
}
}
func TestGetMessage_NotFound(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-999", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`)
})
_, err := client.GetMessage("msg-999", "pass")
if err == nil {
t.Fatal("expected error for 404")
}
if !strings.Contains(err.Error(), "message not found") {
t.Errorf("expected 'message not found' in error, got: %s", err.Error())
}
}
func TestGetMessage_DecryptBody(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
svc, _, pass := newTestService(t)
cfg := &config.Config{
APIBaseURL: srv.URL(),
TimeoutSec: 5,
RateLimitReq: 100,
RateLimitWin: 60,
}
apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient)
client.SetPGPService(svc)
// Simulate an encrypted body that the API returns
encryptedBody, err := svc.EncryptBody("Secret message content", pass)
if err != nil {
t.Fatalf("EncryptBody: %v", err)
}
msgWithEncryptedBody := Message{
MessageID: "msg-enc",
Subject: "Encrypted",
BodyEnc: encryptedBody,
}
srv.Handle("POST /api/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": msgWithEncryptedBody})
})
msg, err := client.GetMessage("msg-enc", pass)
if err != nil {
t.Fatalf("GetMessage: %v", err)
}
if msg.BodyEnc != encryptedBody {
t.Error("encrypted body should be preserved")
}
}
// ---------- Send ----------
func TestSend_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Subject"] != "Test Subject" {
t.Errorf("expected subject Test Subject, got %v", body["Subject"])
}
if body["HTML"] != true {
t.Error("expected HTML=true")
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": map[string]string{"MessageID": "sent-1"}})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
Subject: "Test Subject",
Body: "Plain body",
HTML: true,
Passphrase: "pass",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
func TestSend_WithPGP(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
svc, _, pass := newTestService(t)
cfg := &config.Config{
APIBaseURL: srv.URL(),
TimeoutSec: 5,
RateLimitReq: 100,
RateLimitWin: 60,
}
apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient)
client.SetPGPService(svc)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
// When PGP service is set, BodyEnc should be present instead of Body
if _, hasBody := body["Body"]; hasBody {
t.Error("expected BodyEnc instead of Body when PGP service is set")
}
if body["BodyEnc"] == "" {
t.Error("expected BodyEnc to be set")
}
writeJSON(t, w, http.StatusCreated, map[string]interface{}{})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
Subject: "PGP Encrypted",
Body: "Secret content",
Passphrase: pass,
})
if err != nil {
t.Fatalf("Send with PGP: %v", err)
}
}
func TestSend_WithCC(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
cc, ok := body["CC"].([]interface{})
if !ok || len(cc) != 1 {
t.Error("expected CC with 1 recipient")
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
CC: []Recipient{{Address: "cc@example.com"}},
Subject: "With CC",
Passphrase: "pass",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
func TestSend_WithBCC(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
bcc, ok := body["BCC"].([]interface{})
if !ok || len(bcc) != 1 {
t.Error("expected BCC with 1 recipient")
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
BCC: []Recipient{{Address: "bcc@example.com"}},
Subject: "With BCC",
Passphrase: "pass",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
func TestSend_HTTPError(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
})
err := client.Send(SendRequest{
To: []Recipient{{Address: ""}},
Subject: "Bad",
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected error for 400")
}
// API error is returned directly by Do(), then wrapped by Send()
if !strings.Contains(err.Error(), "invalid recipient") {
t.Errorf("expected 'invalid recipient' in error, got: %s", err.Error())
}
}
func TestSend_CreatedStatus(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`)
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
Subject: "Created",
Passphrase: "pass",
})
if err != nil {
t.Fatalf("Send with 201: %v", err)
}
}
// ---------- MoveToTrash ----------
func TestMoveToTrash_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Error("expected form-urlencoded content type")
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") {
t.Errorf("expected Passphrase in form body, got %s", body)
}
w.WriteHeader(http.StatusOK)
})
err := client.MoveToTrash("msg-1", "pass")
if err != nil {
t.Fatalf("MoveToTrash: %v", err)
}
}
func TestMoveToTrash_Error(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "server error")
})
err := client.MoveToTrash("msg-1", "pass")
if err == nil {
t.Fatal("expected error for 500")
}
if !strings.Contains(err.Error(), "trash failed") {
t.Errorf("unexpected error: %s", err.Error())
}
}
// ---------- PermanentlyDelete ----------
func TestPermanentlyDelete_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
err := client.PermanentlyDelete("msg-1")
if err != nil {
t.Fatalf("PermanentlyDelete: %v", err)
}
}
func TestPermanentlyDelete_Error(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
})
err := client.PermanentlyDelete("msg-1")
if err == nil {
t.Fatal("expected error for 404")
}
// When API returns parseable error JSON, Do() returns *APIError which is wrapped
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %s", err.Error())
}
}
func TestPermanentlyDelete_URLEscape(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
msgID := "msg/with/slashes"
srv.Handle("POST /api/messages/msg/with/slashes/delete", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
err := client.PermanentlyDelete(msgID)
if err != nil {
t.Fatalf("PermanentlyDelete: %v", err)
}
}
// ---------- SaveDraft ----------
func TestSaveDraft_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Type"] != MessageTypeDraft {
t.Errorf("expected Type=%s, got %v", MessageTypeDraft, body["Type"])
}
if body["Subject"] != "Draft Subject" {
t.Errorf("expected subject Draft Subject, got %v", body["Subject"])
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{
"Data": map[string]string{"MessageID": "draft-1"},
})
})
id, err := client.SaveDraft(Draft{
To: []Recipient{{Address: "to@example.com"}},
Subject: "Draft Subject",
Body: "Draft body",
}, "pass")
if err != nil {
t.Fatalf("SaveDraft: %v", err)
}
if id != "draft-1" {
t.Errorf("expected draft-1, got %s", id)
}
}
func TestSaveDraft_WithCC(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["CC"]; !ok {
t.Error("expected CC field")
}
if _, ok := body["BCC"]; !ok {
t.Error("expected BCC field")
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{
"Data": map[string]string{"MessageID": "draft-2"},
})
})
_, err := client.SaveDraft(Draft{
To: []Recipient{{Address: "to@example.com"}},
CC: []Recipient{{Address: "cc@example.com"}},
BCC: []Recipient{{Address: "bcc@example.com"}},
Subject: "Draft with CC/BCC",
Body: "Body",
}, "pass")
if err != nil {
t.Fatalf("SaveDraft: %v", err)
}
}
func TestSaveDraft_Error(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
})
_, err := client.SaveDraft(Draft{
To: []Recipient{{Address: ""}},
Subject: "Bad Draft",
Body: "Body",
}, "pass")
if err == nil {
t.Fatal("expected error for 400")
}
}
func TestSaveDraft_BadJSON(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`)
})
_, err := client.SaveDraft(Draft{
To: []Recipient{{Address: "to@example.com"}},
Subject: "Bad JSON",
Body: "Body",
}, "pass")
if err == nil {
t.Fatal("expected error for bad JSON")
}
if !strings.Contains(err.Error(), "failed to parse response") {
t.Errorf("unexpected error: %s", err.Error())
}
}
// ---------- UpdateDraft ----------
func TestUpdateDraft_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Subject"] != "Updated Subject" {
t.Errorf("expected Updated Subject, got %v", body["Subject"])
}
w.WriteHeader(http.StatusOK)
})
err := client.UpdateDraft("draft-1", Draft{
To: []Recipient{{Address: "updated@example.com"}},
Subject: "Updated Subject",
Body: "Updated body",
}, "pass")
if err != nil {
t.Fatalf("UpdateDraft: %v", err)
}
}
func TestUpdateDraft_WithCC(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, ok := body["CC"]; !ok {
t.Error("expected CC field in update")
}
w.WriteHeader(http.StatusOK)
})
err := client.UpdateDraft("draft-1", Draft{
To: []Recipient{{Address: "to@example.com"}},
CC: []Recipient{{Address: "cc@example.com"}},
Subject: "Updated",
Body: "Body",
}, "pass")
if err != nil {
t.Fatalf("UpdateDraft: %v", err)
}
}
func TestUpdateDraft_Error(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`)
})
err := client.UpdateDraft("draft-1", Draft{
To: []Recipient{{Address: "to@example.com"}},
Subject: "Conflict",
Body: "Body",
}, "pass")
if err == nil {
t.Fatal("expected error for 409")
}
// API error JSON is parsed by Do(), returns *APIError wrapped by UpdateDraft
if !strings.Contains(err.Error(), "draft locked") {
t.Errorf("expected 'draft locked' in error, got: %s", err.Error())
}
}
// ---------- SendDraft ----------
func TestSendDraft_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Error("expected form-urlencoded content type")
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") {
t.Errorf("expected Passphrase in form, got %s", body)
}
w.WriteHeader(http.StatusOK)
})
err := client.SendDraft("draft-1", "pass")
if err != nil {
t.Fatalf("SendDraft: %v", err)
}
}
func TestSendDraft_Error(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "not found")
})
err := client.SendDraft("draft-1", "pass")
if err == nil {
t.Fatal("expected error for 404")
}
if !strings.Contains(err.Error(), "send draft failed") {
t.Errorf("unexpected error: %s", err.Error())
}
}
// ---------- ListDrafts ----------
func TestListDrafts_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
expected := ListMessagesResponse{
Total: 1,
Messages: []Message{
{MessageID: "draft-1", Subject: "My Draft", Type: int(FolderDraft)},
},
}
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Type"] != float64(FolderDraft) {
t.Errorf("expected Type=2 (draft), got %v", body["Type"])
}
writeJSON(t, w, http.StatusOK, expected)
})
resp, err := client.ListDrafts(1, 10, "pass")
if err != nil {
t.Fatalf("ListDrafts: %v", err)
}
if resp.Total != 1 {
t.Errorf("expected 1 draft, got %d", resp.Total)
}
}
// ---------- SearchMessages ----------
func TestSearchMessages_Success(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
expected := SearchResponse{
Total: 3,
Messages: []Message{
{MessageID: "s1", Subject: "Result 1"},
{MessageID: "s2", Subject: "Result 2"},
{MessageID: "s3", Subject: "Result 3"},
},
}
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Query"] != "invoice" {
t.Errorf("expected query invoice, got %v", body["Query"])
}
if body["Page"] != float64(1) {
t.Errorf("expected page 1, got %v", body["Page"])
}
if body["PageSize"] != float64(20) {
t.Errorf("expected page size 20, got %v", body["PageSize"])
}
writeJSON(t, w, http.StatusOK, expected)
})
resp, err := client.SearchMessages(SearchRequest{
Query: "invoice",
Page: 1,
PageSize: 20,
Passphrase: "pass",
})
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if resp.Total != 3 {
t.Errorf("expected 3 results, got %d", resp.Total)
}
if len(resp.Messages) != 3 {
t.Errorf("expected 3 messages, got %d", len(resp.Messages))
}
}
func TestSearchMessages_EmptyResults(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, SearchResponse{Total: 0, Messages: []Message{}})
})
resp, err := client.SearchMessages(SearchRequest{
Query: "nonexistent",
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if resp.Total != 0 {
t.Errorf("expected 0 results, got %d", resp.Total)
}
}
func TestSearchMessages_APIError(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`)
})
_, err := client.SearchMessages(SearchRequest{
Query: "test",
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected error for 429")
}
if !strings.Contains(err.Error(), "rate limited") {
t.Errorf("expected 'rate limited' in error, got: %s", err.Error())
}
}
func TestSearchMessages_BadJSON(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `not json at all`)
})
_, err := client.SearchMessages(SearchRequest{
Query: "test",
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected error for bad JSON")
}
if !strings.Contains(err.Error(), "failed to parse response") {
t.Errorf("unexpected error: %s", err.Error())
}
}
// ---------- Auth Header Propagation ----------
func TestAuthHeader_Propagated(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
cfg := &config.Config{
APIBaseURL: srv.URL(),
TimeoutSec: 5,
RateLimitReq: 100,
RateLimitWin: 60,
}
apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("my-test-token")
client := NewClient(apiClient)
var capturedAuth string
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
})
_, _ = client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if capturedAuth != "Bearer my-test-token" {
t.Errorf("expected Bearer my-test-token, got %s", capturedAuth)
}
}
// ---------- Content-Type Headers ----------
func TestContentTypes_JSON(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
})
_, _ = client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
}
func TestContentTypes_FormUrlEncoded(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if ct != "application/x-www-form-urlencoded" {
t.Errorf("expected application/x-www-form-urlencoded, got %s", ct)
}
w.WriteHeader(http.StatusOK)
})
_ = client.MoveToTrash("msg-1", "pass")
}
// ---------- Concurrent Access ----------
func TestListMessages_Concurrent(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
var mu sync.Mutex
callCount := 0
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
callCount++
mu.Unlock()
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0})
})
const goroutines = 10
var wg sync.WaitGroup
var results [10]error
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, results[idx] = client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
}(i)
}
wg.Wait()
for i, err := range results {
if err != nil {
t.Errorf("goroutine %d: %v", i, err)
}
}
mu.Lock()
if callCount != goroutines {
t.Errorf("expected %d calls, got %d", goroutines, callCount)
}
mu.Unlock()
}
// ---------- Folder Name ----------
func TestFolder_Name(t *testing.T) {
tests := []struct {
folder Folder
name string
}{
{FolderInbox, "Inbox"},
{FolderSent, "Sent"},
{FolderDraft, "Drafts"},
{FolderTrash, "Trash"},
{FolderSpam, "Spam"},
{Folder(99), "Unknown"},
}
for _, tt := range tests {
if tt.folder.Name() != tt.name {
t.Errorf("Folder(%d).Name() = %q, want %q", tt.folder, tt.folder.Name(), tt.name)
}
}
}
// ---------- Recipient DisplayName ----------
func TestRecipient_DisplayName(t *testing.T) {
tests := []struct {
r Recipient
expect string
}{
{Recipient{Name: "Alice", Address: "alice@example.com"}, "Alice <alice@example.com>"},
{Recipient{Address: "bob@example.com"}, "bob@example.com"},
}
for i, tt := range tests {
got := tt.r.DisplayName()
if got != tt.expect {
t.Errorf("case %d: got %q, want %q", i, got, tt.expect)
}
}
}
// ---------- Message Folder ----------
func TestMessage_Folder(t *testing.T) {
tests := []struct {
msg Message
folder Folder
}{
{Message{Type: int(FolderDraft)}, FolderDraft},
{Message{Type: int(FolderSent)}, FolderSent},
{Message{Type: int(FolderInbox)}, FolderInbox},
{Message{Type: 0}, FolderInbox},
{Message{Type: 99}, FolderInbox},
}
for i, tt := range tests {
got := tt.msg.Folder()
if got != tt.folder {
t.Errorf("case %d: got %v, want %v", i, got, tt.folder)
}
}
}
// ---------- SetPGPService ----------
func TestSetPGPService(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
svc, _, _ := newTestService(t)
client.SetPGPService(svc)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
// PGP service should cause BodyEnc instead of Body
if _, hasBody := body["Body"]; hasBody {
t.Error("expected BodyEnc when PGP service is set")
}
writeJSON(t, w, http.StatusCreated, map[string]interface{}{})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
Subject: "PGP Test",
Body: "Secret",
Passphrase: "test-passphrase",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
// ---------- Send without Body ----------
func TestSend_WithoutBody(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if _, hasBody := body["Body"]; hasBody {
t.Error("Body should be omitted when empty")
}
if _, hasBodyEnc := body["BodyEnc"]; hasBodyEnc {
t.Error("BodyEnc should be omitted when Body is empty")
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{})
})
err := client.Send(SendRequest{
To: []Recipient{{Address: "to@example.com"}},
Subject: "No Body",
Passphrase: "pass",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
// ---------- Timeout ----------
func TestListMessages_Timeout(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
cfg := &config.Config{
APIBaseURL: srv.URL(),
TimeoutSec: 1,
RateLimitReq: 100,
RateLimitWin: 60,
}
apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
})
_, err := client.ListMessages(ListMessagesRequest{
Page: 1,
PageSize: 10,
Passphrase: "pass",
})
if err == nil {
t.Fatal("expected timeout error")
}
}
// ---------- Multiple Combined Filters ----------
func TestListMessages_CombinedFilters(t *testing.T) {
srv := newMockServer(t)
defer srv.Close()
client := newTestClient(t, srv)
starred := true
unread := false
since := int64(1700000000)
srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r)
if body["Type"] != float64(FolderSent) {
t.Errorf("expected Type=3, got %v", body["Type"])
}
if body["Starred"] != true {
t.Errorf("expected Starred=true, got %v", body["Starred"])
}
if body["Read"] != false {
t.Errorf("expected Read=false, got %v", body["Read"])
}
if body["Since"] != float64(since) {
t.Errorf("expected Since=%d, got %v", since, body["Since"])
}
writeJSON(t, w, http.StatusOK, ListMessagesResponse{Total: 0, Messages: []Message{}})
})
_, err := client.ListMessages(ListMessagesRequest{
Folder: FolderSent,
Page: 1,
PageSize: 10,
Passphrase: "pass",
Starred: &starred,
Read: &unread,
Since: since,
})
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
}