Files
eec-campaigns/internal/listmonk/listmonk_test.go
T
will a630a8f8ab Add template-as-code sync (campaigns template NAME PATH)
Answers "can the unsubscribe template be added via git+API too": commit
listmonk's own stock campaign template (already unsubscribe-capable) as
email-templates/campaign.html, add a small find-or-create-or-update
Template client (confirmed against knadh/listmonk's actual model/handlers),
a new `template` subcommand, and a manual-dispatch-only workflow to push it
in as the default campaign template. Unlike campaign sync there's no
draft/live status to protect, so this is always a safe overwrite.
2026-07-10 08:54:31 -04:00

413 lines
12 KiB
Go

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 TestQueryAddToList_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")
query := "subscribers.attribs->>'source' = 'workshop'"
if err := c.QueryAddToList(query, 9); err != nil {
t.Fatalf("QueryAddToList: %v", err)
}
if gotMethod != http.MethodPut {
t.Errorf("expected PUT, got %s", gotMethod)
}
if gotPath != "/api/subscribers/query/lists" {
t.Errorf("expected /api/subscribers/query/lists, got %q", gotPath)
}
if gotBody["query"] != query {
t.Errorf("expected query sent verbatim, got %+v", gotBody)
}
if gotBody["action"] != "add" {
t.Errorf("expected action=add, got %+v", gotBody)
}
}
func TestQueryAddToList_SurfacesListmonkErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"message":"invalid SQL expression"}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
err := c.QueryAddToList("not valid sql", 9)
if err == nil || !strings.Contains(err.Error(), "invalid SQL expression") {
t.Fatalf("expected listmonk's error message to surface, got %v", err)
}
}
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)
}
}
func TestFindTemplateByName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":4,"name":"eec-campaigns default","type":"campaign","is_default":true}]}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.FindTemplateByName("eec-campaigns default")
if err != nil {
t.Fatalf("FindTemplateByName: %v", err)
}
if tpl == nil || tpl.ID != 4 || !tpl.IsDefault {
t.Fatalf("unexpected template: %+v", tpl)
}
}
func TestFindTemplateByName_NilOnMiss(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.FindTemplateByName("missing")
if err != nil {
t.Fatalf("FindTemplateByName: %v", err)
}
if tpl != nil {
t.Errorf("expected nil for a miss, got %+v", tpl)
}
}
func TestCreateTemplate_SendsCampaignTypeAndIsDefault(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":9}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.CreateTemplate("eec-campaigns default", "<html></html>", true)
if err != nil {
t.Fatalf("CreateTemplate: %v", err)
}
if tpl.ID != 9 {
t.Errorf("expected id 9, got %d", tpl.ID)
}
if gotBody["type"] != "campaign" || gotBody["is_default"] != true {
t.Errorf("expected type=campaign, is_default=true, got %+v", gotBody)
}
}
func TestUpdateTemplate_SendsExpectedPayload(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.UpdateTemplate(9, "eec-campaigns default", "<html>v2</html>", true); err != nil {
t.Fatalf("UpdateTemplate: %v", err)
}
if gotPath != "/api/templates/9" {
t.Errorf("expected /api/templates/9, got %q", gotPath)
}
if gotBody["body"] != "<html>v2</html>" {
t.Errorf("expected updated body to be sent, got %+v", gotBody)
}
}