diff --git a/internal/campaign/send.go b/internal/campaign/send.go index bada843..933056c 100644 --- a/internal/campaign/send.go +++ b/internal/campaign/send.go @@ -39,5 +39,5 @@ func Test(lm *listmonk.Client, slug string, emails []string) error { if camp == nil { return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug) } - return lm.TestCampaign(camp.ID, emails) + return lm.TestCampaign(camp.ID, camp.AsInput(), emails) } diff --git a/internal/campaign/sync.go b/internal/campaign/sync.go index 528b348..7fbdc78 100644 --- a/internal/campaign/sync.go +++ b/internal/campaign/sync.go @@ -137,7 +137,7 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [ } var previewErr error if len(recipients) > 0 { - previewErr = lm.TestCampaign(campaignID, recipients) + previewErr = lm.TestCampaign(campaignID, input, recipients) } return syncOneResult{Changed: true, PreviewErr: previewErr}, nil } diff --git a/internal/listmonk/listmonk.go b/internal/listmonk/listmonk.go index 562be3a..6325565 100644 --- a/internal/listmonk/listmonk.go +++ b/internal/listmonk/listmonk.go @@ -297,6 +297,23 @@ type Campaign struct { MediaIDs []int } +// AsInput converts a fetched Campaign back into the CampaignInput shape, +// for callers (e.g. Test) that need to re-send a campaign's current fields +// against an endpoint that validates the full campaign body. +func (camp *Campaign) AsInput() CampaignInput { + return CampaignInput{ + Name: camp.Name, + Subject: camp.Subject, + FromEmail: camp.FromEmail, + TemplateID: camp.TemplateID, + Type: camp.Type, + Tags: camp.Tags, + ListIDs: camp.ListIDs, + Body: camp.Body, + MediaIDs: camp.MediaIDs, + } +} + // CampaignInput is what sync.go builds from a campaign's frontmatter+body to // create or update a listmonk campaign. type CampaignInput struct { @@ -482,14 +499,18 @@ func (c *Client) SetCampaignStatus(id int, status string) error { } // TestCampaign sends a preview of the campaign's current content to the -// given addresses without touching its status. Request field confirmed -// against knadh/listmonk's actual handler source (cmd/campaigns.go's -// campReq.SubscriberEmails, json tag "subscribers"). -func (c *Client) TestCampaign(id int, emails []string) error { +// given addresses without touching its status. listmonk's test-send handler +// binds the request into the same campReq struct create/update use and +// validates it in full, so the campaign's other fields (in particular a +// non-empty "name") must be sent alongside "subscribers" — a request with +// just {"subscribers": [...]} fails listmonk's own validation with "Invalid +// length for name" before the "subscribers" field is ever looked at. +func (c *Client) TestCampaign(id int, in CampaignInput, emails []string) error { if len(emails) == 0 { return nil } - payload := map[string]any{"subscribers": emails} + payload := in.payload() + payload["subscribers"] = emails respBody, status, err := c.do(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/test", id), payload) if err != nil { return fmt.Errorf("sending test for campaign %d: %w", id, err) diff --git a/internal/listmonk/listmonk_test.go b/internal/listmonk/listmonk_test.go index 64183ab..16aa031 100644 --- a/internal/listmonk/listmonk_test.go +++ b/internal/listmonk/listmonk_test.go @@ -311,13 +311,17 @@ func TestTestCampaign_SendsSubscribersList(t *testing.T) { defer srv.Close() c := New(srv.URL, "u", "t") - if err := c.TestCampaign(7, []string{"me@example.com"}); err != nil { + in := CampaignInput{Name: "launch", Subject: "Hi", Body: "body", Type: "regular"} + if err := c.TestCampaign(7, in, []string{"me@example.com"}); err != nil { t.Fatalf("TestCampaign: %v", err) } subs, ok := gotBody["subscribers"].([]any) if !ok || len(subs) != 1 || subs[0] != "me@example.com" { t.Errorf("expected subscribers=[me@example.com], got %+v", gotBody) } + if gotBody["name"] != "launch" { + t.Errorf("expected the full campaign body (name=launch) alongside subscribers, got %+v", gotBody) + } } func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {