Initial implementation of the eec-campaigns tool
Drives listmonk's real Campaign API from git-authored Markdown+frontmatter, so broadcast/segment emails get listmonk's mature unsubscribe/bulk-send/ attachment handling instead of reimplementing it. sync only ever creates or updates a draft (idempotent, diff-based, refuses to touch a non-draft campaign); a pushed send/<slug> tag or manual workflow run is the only way to actually trigger a send. Includes list-name resolution, segment_query materialization into managed lists, content-hash-deduped attachment uploads, and an automatic post-sync preview email.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
// Package listmonk is a small client for the listmonk REST calls
|
||||
// eec-campaigns needs: managing campaigns (create/update/status/test),
|
||||
// resolving list names to IDs, uploading media for attachments, and
|
||||
// materializing a segment_query into list membership. Deliberately not
|
||||
// shared with eec's or drip's own listmonk clients — same reasoning drip's
|
||||
// client comment already gives: Go's internal/ visibility rules would block
|
||||
// it anyway, and this client's needs (campaigns, media) diverge enough from
|
||||
// eec's (transactional sends) and drip's (subscriber sweep) that duplicating
|
||||
// the small HTTP-plumbing overlap beats a shared module.
|
||||
package listmonk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string // e.g. https://listmonk.reground.org
|
||||
APIUser string
|
||||
APIToken string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, apiUser, apiToken string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
APIUser: apiUser,
|
||||
APIToken: apiToken,
|
||||
HTTP: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body any) ([]byte, int, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.SetBasicAuth(c.APIUser, c.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// ---- Lists ----
|
||||
|
||||
type List struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *Client) listAll() ([]List, error) {
|
||||
respBody, status, err := c.do(http.MethodGet, "/api/lists?per_page=all", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing lists: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("listing lists failed (%d): %s", status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
Results []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing list response: %w", err)
|
||||
}
|
||||
lists := make([]List, 0, len(parsed.Data.Results))
|
||||
for _, r := range parsed.Data.Results {
|
||||
lists = append(lists, List{ID: r.ID, Name: r.Name})
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
// FindListByName resolves a list name to its numeric ID, matching exactly
|
||||
// (never fuzzy) so a campaign's target audience is never guessed. Fetches
|
||||
// all lists and filters client-side rather than depending on /api/lists'
|
||||
// query-string search semantics, matching drip's existing style of not
|
||||
// relying on unconfirmed server-side query behavior.
|
||||
func (c *Client) FindListByName(name string) (id int, err error) {
|
||||
lists, err := c.listAll()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var matches []List
|
||||
for _, l := range lists {
|
||||
if l.Name == name {
|
||||
matches = append(matches, l)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return 0, fmt.Errorf("no list named %q", name)
|
||||
case 1:
|
||||
return matches[0].ID, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%d lists named %q — ambiguous, refusing to guess", len(matches), name)
|
||||
}
|
||||
}
|
||||
|
||||
// FindOrCreateListByName is used for segment_query-backed lists
|
||||
// (segment:<slug>), which this tool owns and manages itself rather than
|
||||
// requiring the user to pre-create in the Listmonk admin UI.
|
||||
func (c *Client) FindOrCreateListByName(name string) (int, error) {
|
||||
lists, err := c.listAll()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, l := range lists {
|
||||
if l.Name == name {
|
||||
return l.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": name,
|
||||
"type": "private",
|
||||
}
|
||||
respBody, status, err := c.do(http.MethodPost, "/api/lists", payload)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating list %q: %w", name, err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return 0, fmt.Errorf("creating list %q failed (%d): %s", name, status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return 0, fmt.Errorf("parsing list creation response: %w", err)
|
||||
}
|
||||
return parsed.Data.ID, nil
|
||||
}
|
||||
|
||||
// ---- Subscribers / segmentation ----
|
||||
|
||||
// QuerySubscriberIDs runs a raw SQL boolean expression against the
|
||||
// subscribers table (the same segmentation mechanism listmonk's own admin
|
||||
// UI search box uses) and returns the IDs of every match. per_page=all
|
||||
// deliberately skips pagination, matching drip's existing Query method.
|
||||
func (c *Client) QuerySubscriberIDs(query string) ([]int, error) {
|
||||
path := "/api/subscribers?per_page=all&query=" + url.QueryEscape(query)
|
||||
respBody, status, err := c.do(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying subscribers: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("segment_query failed (%d): %s", status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
Results []struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing subscriber query response: %w", err)
|
||||
}
|
||||
ids := make([]int, 0, len(parsed.Data.Results))
|
||||
for _, r := range parsed.Data.Results {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// BulkAddToList adds the given subscriber IDs to listID.
|
||||
//
|
||||
// TODO(verify): the bulk list-membership action's exact endpoint/body shape
|
||||
// isn't confirmed against a live listmonk instance — this targets the
|
||||
// documented bulk subscriber-action endpoint (PUT /api/subscribers/lists
|
||||
// with explicit ids+action+target_list_ids), which is the same mechanism
|
||||
// listmonk's admin UI uses for "add selected subscribers to list". Confirm
|
||||
// against the real instance/version before relying on this in production.
|
||||
func (c *Client) BulkAddToList(ids []int, listID int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"ids": ids,
|
||||
"action": "add",
|
||||
"target_list_ids": []int{listID},
|
||||
"status": "unconfirmed",
|
||||
}
|
||||
respBody, status, err := c.do(http.MethodPut, "/api/subscribers/lists", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bulk-adding %d subscriber(s) to list %d: %w", len(ids), listID, err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("bulk-adding subscribers to list %d failed (%d): %s", listID, status, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Media ----
|
||||
|
||||
type Media struct {
|
||||
ID int
|
||||
Filename string
|
||||
}
|
||||
|
||||
func (c *Client) mediaAll() ([]Media, error) {
|
||||
respBody, status, err := c.do(http.MethodGet, "/api/media", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing media: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("listing media failed (%d): %s", status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data []struct {
|
||||
ID int `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing media response: %w", err)
|
||||
}
|
||||
media := make([]Media, 0, len(parsed.Data))
|
||||
for _, r := range parsed.Data {
|
||||
media = append(media, Media{ID: r.ID, Filename: r.Filename})
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// FindMediaByFilename looks for a previously uploaded file by its exact
|
||||
// (content-hash-synthesized) filename — see internal/campaign/sync.go for
|
||||
// why the filename itself is the dedup key. Returns (0, false, nil) if not
|
||||
// found, never an error for a plain miss.
|
||||
func (c *Client) FindMediaByFilename(filename string) (id int, found bool, err error) {
|
||||
media, err := c.mediaAll()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
for _, m := range media {
|
||||
if m.Filename == filename {
|
||||
return m.ID, true, nil
|
||||
}
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// UploadMedia uploads content under filename and returns its new media ID.
|
||||
func (c *Client) UploadMedia(filename string, content io.Reader) (int, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
part, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := io.Copy(part, content); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/api/media", &buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.SetBasicAuth(c.APIUser, c.APIToken)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("uploading media %q: %w", filename, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("uploading media %q failed (%d): %s", filename, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return 0, fmt.Errorf("parsing media upload response: %w", err)
|
||||
}
|
||||
return parsed.Data.ID, nil
|
||||
}
|
||||
|
||||
// ---- Campaigns ----
|
||||
|
||||
// Campaign is the subset of listmonk's campaign fields eec-campaigns cares
|
||||
// about — enough to decide whether a re-sync's desired content differs from
|
||||
// what's already there.
|
||||
type Campaign struct {
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Subject string
|
||||
Body string
|
||||
FromEmail string
|
||||
TemplateID int
|
||||
Type string
|
||||
Tags []string
|
||||
ListIDs []int
|
||||
MediaIDs []int
|
||||
}
|
||||
|
||||
// CampaignInput is what sync.go builds from a campaign's frontmatter+body to
|
||||
// create or update a listmonk campaign.
|
||||
type CampaignInput struct {
|
||||
Name string
|
||||
Subject string
|
||||
FromEmail string
|
||||
TemplateID int // 0 means "omit, use listmonk's default template"
|
||||
Type string
|
||||
Tags []string
|
||||
ListIDs []int
|
||||
Body string // raw Markdown; content_type "markdown" below
|
||||
MediaIDs []int
|
||||
}
|
||||
|
||||
func (in CampaignInput) payload() map[string]any {
|
||||
p := map[string]any{
|
||||
"name": in.Name,
|
||||
"subject": in.Subject,
|
||||
"lists": in.ListIDs,
|
||||
"content_type": "markdown",
|
||||
"body": in.Body,
|
||||
"type": in.Type,
|
||||
}
|
||||
if in.FromEmail != "" {
|
||||
p["from_email"] = in.FromEmail
|
||||
}
|
||||
if in.TemplateID != 0 {
|
||||
p["template_id"] = in.TemplateID
|
||||
}
|
||||
if len(in.Tags) > 0 {
|
||||
p["tags"] = in.Tags
|
||||
}
|
||||
if len(in.MediaIDs) > 0 {
|
||||
// TODO(verify): field name for attaching media library items to a
|
||||
// campaign isn't documented publicly — confirm against a live
|
||||
// listmonk admin UI network trace (attach a file to any test
|
||||
// campaign and inspect the PUT /api/campaigns/{id} request it
|
||||
// sends) before relying on this in production.
|
||||
p["media_ids"] = in.MediaIDs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func parseCampaign(data []byte) (*Campaign, error) {
|
||||
var parsed struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
FromEmail string `json:"from_email"`
|
||||
TemplateID int `json:"template_id"`
|
||||
Type string `json:"type"`
|
||||
Tags []string `json:"tags"`
|
||||
Lists []struct {
|
||||
ID int `json:"id"`
|
||||
} `json:"lists"`
|
||||
MediaIDs []int `json:"media_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listIDs := make([]int, 0, len(parsed.Lists))
|
||||
for _, l := range parsed.Lists {
|
||||
listIDs = append(listIDs, l.ID)
|
||||
}
|
||||
return &Campaign{
|
||||
ID: parsed.ID,
|
||||
Name: parsed.Name,
|
||||
Status: parsed.Status,
|
||||
Subject: parsed.Subject,
|
||||
Body: parsed.Body,
|
||||
FromEmail: parsed.FromEmail,
|
||||
TemplateID: parsed.TemplateID,
|
||||
Type: parsed.Type,
|
||||
Tags: parsed.Tags,
|
||||
ListIDs: listIDs,
|
||||
MediaIDs: parsed.MediaIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindCampaignByName resolves a campaign's identity (see
|
||||
// internal/campaign/sync.go — the directory slug is sent as this name) by
|
||||
// fetching every campaign and filtering client-side, same reasoning as
|
||||
// FindListByName. Returns (nil, nil) on a plain miss, never an error.
|
||||
func (c *Client) FindCampaignByName(name string) (*Campaign, error) {
|
||||
respBody, status, err := c.do(http.MethodGet, "/api/campaigns?per_page=all", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing campaigns: %w", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("listing campaigns failed (%d): %s", status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
Results []json.RawMessage `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing campaign list response: %w", err)
|
||||
}
|
||||
var matches []*Campaign
|
||||
for _, raw := range parsed.Data.Results {
|
||||
camp, err := parseCampaign(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing campaign in list response: %w", err)
|
||||
}
|
||||
if camp.Name == name {
|
||||
matches = append(matches, camp)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%d campaigns named %q — ambiguous, refusing to guess", len(matches), name)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCampaign creates a new campaign. listmonk's create-time default
|
||||
// status is draft, which is this tool's core safety invariant — see the
|
||||
// TODO below.
|
||||
//
|
||||
// TODO(verify): confirm that POST /api/campaigns' create-time default
|
||||
// status really is "draft" against a live instance (and whether an explicit
|
||||
// status field is even accepted on create) before depending on it.
|
||||
func (c *Client) CreateCampaign(in CampaignInput) (*Campaign, error) {
|
||||
respBody, status, err := c.do(http.MethodPost, "/api/campaigns", in.payload())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating campaign %q: %w", in.Name, err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("creating campaign %q failed (%d): %s", in.Name, status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing campaign creation response: %w", err)
|
||||
}
|
||||
return parseCampaign(parsed.Data)
|
||||
}
|
||||
|
||||
// UpdateCampaign overwrites a draft campaign's content. Callers (sync.go)
|
||||
// must have already confirmed the campaign is still in draft status —
|
||||
// this client does not re-check, since the point of a fresh
|
||||
// FindCampaignByName just beforehand is exactly that check.
|
||||
func (c *Client) UpdateCampaign(id int, in CampaignInput) (*Campaign, error) {
|
||||
respBody, status, err := c.do(http.MethodPut, fmt.Sprintf("/api/campaigns/%d", id), in.payload())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating campaign %d: %w", id, err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("updating campaign %d failed (%d): %s", id, status, string(respBody))
|
||||
}
|
||||
var parsed struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parsing campaign update response: %w", err)
|
||||
}
|
||||
return parseCampaign(parsed.Data)
|
||||
}
|
||||
|
||||
// SetCampaignStatus transitions a campaign's status — "running" is the one
|
||||
// real send trigger in this whole tool (see cmd/send).
|
||||
func (c *Client) SetCampaignStatus(id int, status string) error {
|
||||
payload := map[string]any{"status": status}
|
||||
respBody, code, err := c.do(http.MethodPut, fmt.Sprintf("/api/campaigns/%d/status", id), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting campaign %d status to %q: %w", id, status, err)
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
return fmt.Errorf("setting campaign %d status to %q failed (%d): %s", id, status, code, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCampaign sends a preview of the campaign's current content to the
|
||||
// given addresses without touching its status.
|
||||
//
|
||||
// TODO(verify): the request body key for the recipient address list isn't
|
||||
// confirmed against a live instance — using "subscribers" per listmonk's
|
||||
// documented shape for this endpoint; confirm before relying on it.
|
||||
func (c *Client) TestCampaign(id int, emails []string) error {
|
||||
if len(emails) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{"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)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("sending test for campaign %d failed (%d): %s", id, status, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package listmonk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindListByName_ReturnsIDOnExactMatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"},{"id":4,"name":"Workshop Leads"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
id, err := c.FindListByName("Newsletter")
|
||||
if err != nil {
|
||||
t.Fatalf("FindListByName: %v", err)
|
||||
}
|
||||
if id != 3 {
|
||||
t.Errorf("expected id 3, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindListByName_ErrorsOnZeroMatches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if _, err := c.FindListByName("Nonexistent"); err == nil {
|
||||
t.Fatal("expected an error for zero matches, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindListByName_ErrorsOnAmbiguousMatches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"},{"id":5,"name":"Newsletter"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if _, err := c.FindListByName("Newsletter"); err == nil {
|
||||
t.Fatal("expected an error for ambiguous matches, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindOrCreateListByName_ReusesExisting(t *testing.T) {
|
||||
var postCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
postCount++
|
||||
}
|
||||
w.Write([]byte(`{"data":{"results":[{"id":9,"name":"segment:launch"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
id, err := c.FindOrCreateListByName("segment:launch")
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrCreateListByName: %v", err)
|
||||
}
|
||||
if id != 9 {
|
||||
t.Errorf("expected existing id 9, got %d", id)
|
||||
}
|
||||
if postCount != 0 {
|
||||
t.Errorf("expected no create call when list already exists, got %d POSTs", postCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindOrCreateListByName_CreatesWhenMissing(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Write([]byte(`{"data":{"results":[]}}`))
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &gotBody)
|
||||
w.Write([]byte(`{"data":{"id":11}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
id, err := c.FindOrCreateListByName("segment:launch")
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrCreateListByName: %v", err)
|
||||
}
|
||||
if id != 11 {
|
||||
t.Errorf("expected new id 11, got %d", id)
|
||||
}
|
||||
if gotBody["name"] != "segment:launch" {
|
||||
t.Errorf("expected create payload to carry the list name, got %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySubscriberIDs_UsesPerPageAllAndParsesIDs(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.RequestURI()
|
||||
w.Write([]byte(`{"data":{"results":[{"id":1},{"id":2}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
ids, err := c.QuerySubscriberIDs("subscribers.attribs->>'source' = 'workshop'")
|
||||
if err != nil {
|
||||
t.Fatalf("QuerySubscriberIDs: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotPath, "per_page=all") {
|
||||
t.Errorf("expected per_page=all, got %q", gotPath)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Errorf("expected [1 2], got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAddToList_SendsExpectedPayload(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &gotBody)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if err := c.BulkAddToList([]int{1, 2, 3}, 9); err != nil {
|
||||
t.Fatalf("BulkAddToList: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPut {
|
||||
t.Errorf("expected PUT, got %s", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/subscribers/lists" {
|
||||
t.Errorf("expected /api/subscribers/lists, got %q", gotPath)
|
||||
}
|
||||
if gotBody["action"] != "add" {
|
||||
t.Errorf("expected action=add, got %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAddToList_NoopOnEmptyIDs(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if err := c.BulkAddToList(nil, 9); err != nil {
|
||||
t.Fatalf("BulkAddToList: %v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("expected no request for empty id list, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMediaByFilename(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":[{"id":5,"filename":"launch-abc123-flyer.pdf"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
id, found, err := c.FindMediaByFilename("launch-abc123-flyer.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("FindMediaByFilename: %v", err)
|
||||
}
|
||||
if !found || id != 5 {
|
||||
t.Errorf("expected found id 5, got found=%v id=%d", found, id)
|
||||
}
|
||||
|
||||
_, found, err = c.FindMediaByFilename("nope.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("FindMediaByFilename: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected not found for a filename with no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMedia_SendsMultipartFormFile(t *testing.T) {
|
||||
var gotFilename string
|
||||
var gotContent string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Fatalf("ParseMultipartForm: %v", err)
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
t.Fatalf("FormFile: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
gotFilename = header.Filename
|
||||
b, _ := io.ReadAll(file)
|
||||
gotContent = string(b)
|
||||
w.Write([]byte(`{"data":{"id":42}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
id, err := c.UploadMedia("flyer.pdf", strings.NewReader("pdf-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("UploadMedia: %v", err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Errorf("expected id 42, got %d", id)
|
||||
}
|
||||
if gotFilename != "flyer.pdf" {
|
||||
t.Errorf("expected filename flyer.pdf, got %q", gotFilename)
|
||||
}
|
||||
if gotContent != "pdf-bytes" {
|
||||
t.Errorf("expected uploaded content to match, got %q", gotContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCampaignByName(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[
|
||||
{"id":1,"name":"launch","status":"draft","subject":"Hi","lists":[{"id":3,"name":"Newsletter"}]}
|
||||
]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
camp, err := c.FindCampaignByName("launch")
|
||||
if err != nil {
|
||||
t.Fatalf("FindCampaignByName: %v", err)
|
||||
}
|
||||
if camp == nil || camp.ID != 1 || camp.Status != "draft" {
|
||||
t.Fatalf("unexpected campaign: %+v", camp)
|
||||
}
|
||||
if len(camp.ListIDs) != 1 || camp.ListIDs[0] != 3 {
|
||||
t.Errorf("expected list IDs [3], got %v", camp.ListIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCampaignByName_NilOnMiss(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
camp, err := c.FindCampaignByName("missing")
|
||||
if err != nil {
|
||||
t.Fatalf("FindCampaignByName: %v", err)
|
||||
}
|
||||
if camp != nil {
|
||||
t.Errorf("expected nil for a miss, got %+v", camp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCampaignByName_ErrorsOnAmbiguous(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"results":[
|
||||
{"id":1,"name":"launch"},{"id":2,"name":"launch"}
|
||||
]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if _, err := c.FindCampaignByName("launch"); err == nil {
|
||||
t.Fatal("expected an error for ambiguous campaign names, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCampaign_SendsMarkdownContentType(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &gotBody)
|
||||
w.Write([]byte(`{"data":{"id":7,"name":"launch","status":"draft"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
camp, err := c.CreateCampaign(CampaignInput{
|
||||
Name: "launch", Subject: "Hi", FromEmail: "a@b.com",
|
||||
Type: "regular", ListIDs: []int{3}, Body: "# hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCampaign: %v", err)
|
||||
}
|
||||
if camp.ID != 7 || camp.Status != "draft" {
|
||||
t.Errorf("unexpected campaign: %+v", camp)
|
||||
}
|
||||
if gotBody["content_type"] != "markdown" {
|
||||
t.Errorf("expected content_type=markdown, got %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCampaignStatus_SendsStatusToRunning(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &gotBody)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if err := c.SetCampaignStatus(7, "running"); err != nil {
|
||||
t.Fatalf("SetCampaignStatus: %v", err)
|
||||
}
|
||||
if gotPath != "/api/campaigns/7/status" {
|
||||
t.Errorf("expected /api/campaigns/7/status, got %q", gotPath)
|
||||
}
|
||||
if gotBody["status"] != "running" {
|
||||
t.Errorf("expected status=running, got %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestCampaign_SendsSubscribersList(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(b, &gotBody)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
if err := c.TestCampaign(7, []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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"boom"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "u", "t")
|
||||
_, err := c.CreateCampaign(CampaignInput{Name: "x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("expected error to surface response body, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user