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) 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.StatusUnauthorized) fmt.Fprintf(w, `{"Code":401,"Message":"invalid token"}`) }) _, err := client.ListMessages(ListMessagesRequest{ Page: 1, PageSize: 10, Passphrase: "pass", }) if err == nil { t.Fatal("expected error for 401 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") } } 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) 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) 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) 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 "}, {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) 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) } }