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,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