diff --git a/cmd/campaigns.go b/cmd/campaigns.go index c13af82..5795c65 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -101,6 +101,44 @@ var campaignsCreateCmd = &cobra.Command{ }, } +func runCampaignsUpdate(cfg *config.Config, id string, req api.UpdateCampaignRequest) (*api.Campaign, error) { + return newAPIClient(cfg).UpdateCampaign(id, req) +} + +var campaignsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a draft campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + + cfg, err := loadConfig() + if err != nil { + return err + } + + c, err := runCampaignsUpdate(cfg, args[0], api.UpdateCampaignRequest{Name: name}) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), c) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", c.CampaignID) + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("campaignId", c.CampaignID) + t.Row("emailMessageId", deref(c.EmailMessageID)) + t.Row("name", c.Name) + t.Row("status", c.Status) + t.Row("createdAt", c.CreatedAt) + t.Row("updatedAt", c.UpdatedAt) + return t.Render() + }, +} + var campaignsGetCmd = &cobra.Command{ Use: "get ", Short: "Get a campaign", @@ -140,5 +178,9 @@ func init() { campaignsCreateCmd.MarkFlagRequired("name") campaignsCmd.AddCommand(campaignsCreateCmd) + campaignsUpdateCmd.Flags().StringP("name", "n", "", "Campaign name (required)") + campaignsUpdateCmd.MarkFlagRequired("name") + campaignsCmd.AddCommand(campaignsUpdateCmd) + rootCmd.AddCommand(campaignsCmd) } diff --git a/cmd/campaigns_update_test.go b/cmd/campaigns_update_test.go new file mode 100644 index 0000000..986f120 --- /dev/null +++ b/cmd/campaigns_update_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/cli/internal/api" +) + +func TestRunCampaignsUpdate(t *testing.T) { + body := `{ + "success": true, + "campaignId": "cmp_abc123", + "emailMessageId": "em_abc123", + "name": "Renamed", + "status": "Draft", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-25T10:00:00Z" + }` + + t.Run("returns campaign on success", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + c, err := runCampaignsUpdate(cfg(t), "cmp_abc123", api.UpdateCampaignRequest{Name: "Renamed"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.CampaignID != "cmp_abc123" { + t.Errorf("CampaignID = %q, want cmp_abc123", c.CampaignID) + } + if c.Name != "Renamed" { + t.Errorf("Name = %q, want Renamed", c.Name) + } + if deref(c.EmailMessageID) != "em_abc123" { + t.Errorf("EmailMessageID = %q, want em_abc123", deref(c.EmailMessageID)) + } + }) + + t.Run("returns error when not in draft", func(t *testing.T) { + serveJSON(t, http.StatusConflict, `{"success":false,"message":"Campaign is not in draft status"}`) + _, err := runCampaignsUpdate(cfg(t), "cmp_abc123", api.UpdateCampaignRequest{Name: "Renamed"}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/api/campaigns.go b/internal/api/campaigns.go index e5e5f42..6155edc 100644 --- a/internal/api/campaigns.go +++ b/internal/api/campaigns.go @@ -47,6 +47,10 @@ type CreateCampaignRequest struct { Name string `json:"name"` } +type UpdateCampaignRequest struct { + Name string `json:"name"` +} + type CampaignCreateResponse struct { Campaign EmailMessageContentRevisionID *string `json:"emailMessageContentRevisionId"` @@ -81,6 +85,35 @@ func (c *Client) CreateCampaign(req CreateCampaignRequest) (*CampaignCreateRespo return &result, nil } +func (c *Client) UpdateCampaign(id string, req UpdateCampaignRequest) (*Campaign, error) { + b, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + httpReq, err := c.newRequest(http.MethodPost, "/campaigns/"+id, bytes.NewReader(b)) + if err != nil { + return nil, err + } + + resp, err := c.do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result Campaign + 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) GetCampaign(id string) (*Campaign, error) { req, err := c.newRequest(http.MethodGet, "/campaigns/"+id, nil) if err != nil { diff --git a/internal/api/campaigns_test.go b/internal/api/campaigns_test.go index fd699ab..17612ec 100644 --- a/internal/api/campaigns_test.go +++ b/internal/api/campaigns_test.go @@ -154,6 +154,129 @@ func TestCreateCampaign_RequestBody(t *testing.T) { } } +const updateCampaignResponse = `{ + "success": true, + "campaignId": "cmp_abc123", + "emailMessageId": "em_abc123", + "name": "Renamed", + "status": "Draft", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-25T10:00:00Z" +}` + +func TestUpdateCampaign(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + }{ + { + name: "success", + statusCode: http.StatusOK, + body: updateCampaignResponse, + }, + { + name: "not found", + statusCode: http.StatusNotFound, + body: `{"success":false,"message":"Campaign not found"}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Campaign not found"}, + }, + { + name: "not in draft", + statusCode: http.StatusConflict, + body: `{"success":false,"message":"Campaign is not in draft status"}`, + wantAPIErr: &APIError{StatusCode: http.StatusConflict, Message: "Campaign is not in draft status"}, + }, + { + name: "invalid json", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + 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.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + result, err := client.UpdateCampaign("cmp_abc123", UpdateCampaignRequest{Name: "Renamed"}) + + 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 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.CampaignID != "cmp_abc123" { + t.Errorf("CampaignID = %q, want cmp_abc123", result.CampaignID) + } + if result.Name != "Renamed" { + t.Errorf("Name = %q, want Renamed", result.Name) + } + }) + } +} + +func TestUpdateCampaign_RequestBodyAndPath(t *testing.T) { + var ( + gotPath string + gotMethod string + body map[string]any + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(updateCampaignResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + if _, err := client.UpdateCampaign("cmp_abc123", UpdateCampaignRequest{Name: "Renamed"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want POST", gotMethod) + } + if gotPath != "/campaigns/cmp_abc123" { + t.Errorf("path = %q, want /campaigns/cmp_abc123", gotPath) + } + if body["name"] != "Renamed" { + t.Errorf("name = %v, want Renamed", body["name"]) + } +} + func TestGetCampaign(t *testing.T) { body := `{ "success": true,