diff --git a/cmd/contacts_suppression.go b/cmd/contacts_suppression.go new file mode 100644 index 0000000..c2b541c --- /dev/null +++ b/cmd/contacts_suppression.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + + "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +var contactsSuppressionCmd = &cobra.Command{ + Use: "suppression", + Short: "Manage contact suppression", +} + +// check + +func runContactsSuppressionCheck(cfg *config.Config, email, userID string) (*api.ContactSuppression, error) { + return newAPIClient(cfg).CheckContactSuppression(email, userID) +} + +var contactsSuppressionCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check suppression status for a contact", + RunE: func(cmd *cobra.Command, args []string) error { + email, _ := cmd.Flags().GetString("email") + userID, _ := cmd.Flags().GetString("user-id") + + if (email == "") == (userID == "") { + return fmt.Errorf("exactly one of --email or --user-id is required") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + result, err := runContactsSuppressionCheck(cfg, email, userID) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), result) + } + + suppressed := "no" + if result.IsSuppressed { + suppressed = "yes" + } + fmt.Fprintf(cmd.OutOrStdout(), "Suppressed: %s\n", suppressed) + fmt.Fprintf(cmd.OutOrStdout(), "Removal quota: %d/%d remaining\n", result.RemovalQuota.Remaining, result.RemovalQuota.Limit) + return nil + }, +} + +// remove + +func runContactsSuppressionRemove(cfg *config.Config, email, userID string) (*api.ContactSuppressionRemoval, error) { + return newAPIClient(cfg).RemoveContactSuppression(email, userID) +} + +var contactsSuppressionRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a contact from the suppression list", + RunE: func(cmd *cobra.Command, args []string) error { + email, _ := cmd.Flags().GetString("email") + userID, _ := cmd.Flags().GetString("user-id") + + if (email == "") == (userID == "") { + return fmt.Errorf("exactly one of --email or --user-id is required") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + result, err := runContactsSuppressionRemove(cfg, email, userID) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), result) + } + + fmt.Fprintln(cmd.OutOrStdout(), result.Message) + fmt.Fprintf(cmd.OutOrStdout(), "Removal quota: %d/%d remaining\n", result.RemovalQuota.Remaining, result.RemovalQuota.Limit) + return nil + }, +} + +func init() { + contactsSuppressionCheckCmd.Flags().StringP("email", "e", "", "Contact email address") + contactsSuppressionCheckCmd.Flags().StringP("user-id", "u", "", "Contact user ID") + contactsSuppressionCmd.AddCommand(contactsSuppressionCheckCmd) + + contactsSuppressionRemoveCmd.Flags().StringP("email", "e", "", "Contact email address") + contactsSuppressionRemoveCmd.Flags().StringP("user-id", "u", "", "Contact user ID") + contactsSuppressionCmd.AddCommand(contactsSuppressionRemoveCmd) + + contactsCmd.AddCommand(contactsSuppressionCmd) +} diff --git a/cmd/contacts_suppression_check_test.go b/cmd/contacts_suppression_check_test.go new file mode 100644 index 0000000..8c7ef6e --- /dev/null +++ b/cmd/contacts_suppression_check_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "net/http" + "testing" +) + +func TestRunContactsSuppressionCheck(t *testing.T) { + body := `{"contact":{"id":"cnt_abc123","email":"bob@example.com","userId":"user_123"},"isSuppressed":true,"removalQuota":{"limit":10,"remaining":8}}` + + t.Run("checks by email", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + result, err := runContactsSuppressionCheck(cfg(t), "bob@example.com", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Contact.ID != "cnt_abc123" || + result.Contact.Email != "bob@example.com" || + deref(result.Contact.UserID) != "user_123" || + !result.IsSuppressed || + result.RemovalQuota.Limit != 10 || + result.RemovalQuota.Remaining != 8 { + t.Errorf("unexpected result: %+v", result) + } + }) + + t.Run("checks by user ID", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + result, err := runContactsSuppressionCheck(cfg(t), "", "user_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.IsSuppressed { + t.Errorf("expected suppressed=true, got false") + } + }) + + t.Run("not suppressed", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"contact":{"id":"cnt_abc123","email":"bob@example.com"},"isSuppressed":false,"removalQuota":{"limit":10,"remaining":10}}`) + result, err := runContactsSuppressionCheck(cfg(t), "bob@example.com", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.IsSuppressed { + t.Errorf("expected suppressed=false, got true") + } + }) + + t.Run("returns error on 404", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"message":"Contact not found."}`) + _, err := runContactsSuppressionCheck(cfg(t), "nobody@example.com", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runContactsSuppressionCheck(cfg(t), "bob@example.com", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/contacts_suppression_remove_test.go b/cmd/contacts_suppression_remove_test.go new file mode 100644 index 0000000..a63d8b5 --- /dev/null +++ b/cmd/contacts_suppression_remove_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "net/http" + "testing" +) + +func TestRunContactsSuppressionRemove(t *testing.T) { + body := `{"success":true,"message":"Email removed from suppression list.","removalQuota":{"limit":10,"remaining":7}}` + + t.Run("removes by email", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + result, err := runContactsSuppressionRemove(cfg(t), "bob@example.com", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success || + result.Message != "Email removed from suppression list." || + result.RemovalQuota.Limit != 10 || + result.RemovalQuota.Remaining != 7 { + t.Errorf("unexpected result: %+v", result) + } + }) + + t.Run("removes by user ID", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + result, err := runContactsSuppressionRemove(cfg(t), "", "user_123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Errorf("expected success=true") + } + }) + + t.Run("returns error when not suppressed", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"message":"Contact is not suppressed."}`) + _, err := runContactsSuppressionRemove(cfg(t), "bob@example.com", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("returns error on 404", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"message":"Contact not found."}`) + _, err := runContactsSuppressionRemove(cfg(t), "nobody@example.com", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runContactsSuppressionRemove(cfg(t), "bob@example.com", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/api/contacts.go b/internal/api/contacts.go index 2076891..45785e8 100644 --- a/internal/api/contacts.go +++ b/internal/api/contacts.go @@ -177,6 +177,94 @@ func (c *Client) DeleteContact(email, userID string) error { return nil } +type ContactSuppression struct { + Contact struct { + ID string `json:"id"` + Email string `json:"email"` + UserID *string `json:"userId"` + } `json:"contact"` + IsSuppressed bool `json:"isSuppressed"` + RemovalQuota struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + } `json:"removalQuota"` +} + +type ContactSuppressionRemoval struct { + Success bool `json:"success"` + Message string `json:"message"` + RemovalQuota struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + } `json:"removalQuota"` +} + +func (c *Client) CheckContactSuppression(email, userID string) (*ContactSuppression, error) { + req, err := c.newRequest(http.MethodGet, "/contacts/suppression", nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + if email != "" { + q.Set("email", email) + } + if userID != "" { + q.Set("userId", userID) + } + req.URL.RawQuery = q.Encode() + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result ContactSuppression + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +func (c *Client) RemoveContactSuppression(email, userID string) (*ContactSuppressionRemoval, error) { + req, err := c.newRequest(http.MethodDelete, "/contacts/suppression", nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + if email != "" { + q.Set("email", email) + } + if userID != "" { + q.Set("userId", userID) + } + req.URL.RawQuery = q.Encode() + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result ContactSuppressionRemoval + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + type FindContactParams struct { Email string UserID string diff --git a/internal/api/contacts_test.go b/internal/api/contacts_test.go index 89f8f57..d329097 100644 --- a/internal/api/contacts_test.go +++ b/internal/api/contacts_test.go @@ -365,6 +365,231 @@ func TestUpdateContact(t *testing.T) { } } +func TestCheckContactSuppression(t *testing.T) { + tests := []struct { + name string + email string + userID string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + wantQuery string + wantResult *ContactSuppression + }{ + { + name: "suppressed by email", + email: "bob@example.com", + statusCode: http.StatusOK, + body: `{"contact":{"id":"cnt_abc123","email":"bob@example.com","userId":"user_123"},"isSuppressed":true,"removalQuota":{"limit":10,"remaining":8}}`, + wantQuery: "email=bob%40example.com", + wantResult: &ContactSuppression{IsSuppressed: true}, + }, + { + name: "not suppressed by userId", + userID: "user_123", + statusCode: http.StatusOK, + body: `{"contact":{"id":"cnt_abc123","email":"bob@example.com"},"isSuppressed":false,"removalQuota":{"limit":10,"remaining":10}}`, + wantQuery: "userId=user_123", + wantResult: &ContactSuppression{IsSuppressed: false}, + }, + { + name: "decodes quota fields", + email: "bob@example.com", + statusCode: http.StatusOK, + body: `{"contact":{"id":"cnt_abc123","email":"bob@example.com"},"isSuppressed":true,"removalQuota":{"limit":10,"remaining":3}}`, + wantResult: &ContactSuppression{IsSuppressed: true}, + }, + { + name: "not found", + email: "nobody@example.com", + statusCode: http.StatusNotFound, + body: `{"message":"Contact not found."}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Contact not found."}, + }, + { + name: "unauthorized", + email: "bob@example.com", + statusCode: http.StatusUnauthorized, + body: `{"error":"Invalid API key"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid API key"}, + }, + { + name: "invalid json", + email: "bob@example.com", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + result, err := client.CheckContactSuppression(tt.email, tt.userID) + + if tt.wantQuery != "" && gotQuery != tt.wantQuery { + t.Errorf("query = %q, want %q", gotQuery, tt.wantQuery) + } + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if tt.wantAPIErr.Message != "" && apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.IsSuppressed != tt.wantResult.IsSuppressed { + t.Errorf("IsSuppressed = %v, want %v", result.IsSuppressed, tt.wantResult.IsSuppressed) + } + }) + } +} + +func TestRemoveContactSuppression(t *testing.T) { + tests := []struct { + name string + email string + userID string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + wantQuery string + wantMsg string + }{ + { + name: "removes by email", + email: "bob@example.com", + statusCode: http.StatusOK, + body: `{"success":true,"message":"Email removed from suppression list.","removalQuota":{"limit":10,"remaining":7}}`, + wantQuery: "email=bob%40example.com", + wantMsg: "Email removed from suppression list.", + }, + { + name: "removes by userId", + userID: "user_123", + statusCode: http.StatusOK, + body: `{"success":true,"message":"Email removed from suppression list.","removalQuota":{"limit":10,"remaining":7}}`, + wantQuery: "userId=user_123", + wantMsg: "Email removed from suppression list.", + }, + { + name: "not found", + email: "nobody@example.com", + statusCode: http.StatusNotFound, + body: `{"message":"Contact not found."}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Contact not found."}, + }, + { + name: "not suppressed", + email: "bob@example.com", + statusCode: http.StatusBadRequest, + body: `{"message":"Contact is not suppressed."}`, + wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Contact is not suppressed."}, + }, + { + name: "quota exceeded", + email: "bob@example.com", + statusCode: http.StatusBadRequest, + body: `{"message":"Removal quota exceeded."}`, + wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Removal quota exceeded."}, + }, + { + name: "unauthorized", + email: "bob@example.com", + statusCode: http.StatusUnauthorized, + body: `{"error":"Invalid API key"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid API key"}, + }, + { + name: "invalid json", + email: "bob@example.com", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + result, err := client.RemoveContactSuppression(tt.email, tt.userID) + + if tt.wantQuery != "" && gotQuery != tt.wantQuery { + t.Errorf("query = %q, want %q", gotQuery, tt.wantQuery) + } + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if tt.wantAPIErr.Message != "" && apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Message != tt.wantMsg { + t.Errorf("Message = %q, want %q", result.Message, tt.wantMsg) + } + }) + } +} + func TestFindContacts(t *testing.T) { tests := []struct { name string