Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>",
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 <id>",
Short: "Get a campaign",
Expand Down Expand Up @@ -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)
}
45 changes: 45 additions & 0 deletions cmd/campaigns_update_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
33 changes: 33 additions & 0 deletions internal/api/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 123 additions & 0 deletions internal/api/campaigns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down