Fix TestCampaign sending only subscribers, failing listmonk validation
CI / test (push) Successful in 9s

listmonk's /api/campaigns/:id/test handler binds the request into the
same campReq struct create/update use and validates it in full, so a
request with only {"subscribers": [...]} fails with "Invalid length
for name" before subscribers is ever read. TestCampaign now sends the
campaign's full CampaignInput alongside subscribers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 21:51:20 -04:00
parent 7eea2b1a8b
commit 39d02303c7
4 changed files with 33 additions and 8 deletions
+1 -1
View File
@@ -39,5 +39,5 @@ func Test(lm *listmonk.Client, slug string, emails []string) error {
if camp == nil { if camp == nil {
return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug) 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)
} }
+1 -1
View File
@@ -137,7 +137,7 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
} }
var previewErr error var previewErr error
if len(recipients) > 0 { if len(recipients) > 0 {
previewErr = lm.TestCampaign(campaignID, recipients) previewErr = lm.TestCampaign(campaignID, input, recipients)
} }
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
} }
+26 -5
View File
@@ -297,6 +297,23 @@ type Campaign struct {
MediaIDs []int 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 // CampaignInput is what sync.go builds from a campaign's frontmatter+body to
// create or update a listmonk campaign. // create or update a listmonk campaign.
type CampaignInput struct { 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 // TestCampaign sends a preview of the campaign's current content to the
// given addresses without touching its status. Request field confirmed // given addresses without touching its status. listmonk's test-send handler
// against knadh/listmonk's actual handler source (cmd/campaigns.go's // binds the request into the same campReq struct create/update use and
// campReq.SubscriberEmails, json tag "subscribers"). // validates it in full, so the campaign's other fields (in particular a
func (c *Client) TestCampaign(id int, emails []string) error { // 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 { if len(emails) == 0 {
return nil 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) respBody, status, err := c.do(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/test", id), payload)
if err != nil { if err != nil {
return fmt.Errorf("sending test for campaign %d: %w", id, err) return fmt.Errorf("sending test for campaign %d: %w", id, err)
+5 -1
View File
@@ -311,13 +311,17 @@ func TestTestCampaign_SendsSubscribersList(t *testing.T) {
defer srv.Close() defer srv.Close()
c := New(srv.URL, "u", "t") 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) t.Fatalf("TestCampaign: %v", err)
} }
subs, ok := gotBody["subscribers"].([]any) subs, ok := gotBody["subscribers"].([]any)
if !ok || len(subs) != 1 || subs[0] != "me@example.com" { if !ok || len(subs) != 1 || subs[0] != "me@example.com" {
t.Errorf("expected subscribers=[me@example.com], got %+v", gotBody) 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) { func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {