a630a8f8ab
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.
658 lines
20 KiB
Go
658 lines
20 KiB
Go
package campaign
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"reground.org/eec-campaigns/internal/listmonk"
|
|
)
|
|
|
|
// ---- fake listmonk server ----
|
|
//
|
|
// Following eec's/drip's existing pattern (httptest.NewServer fakes, no
|
|
// mocking library), but stateful enough to exercise sync.go's real
|
|
// idempotency/diff/segmentation logic rather than just counting hits.
|
|
|
|
type fakeList struct {
|
|
ID int
|
|
Name string
|
|
}
|
|
|
|
type fakeCampaign struct {
|
|
ID int
|
|
Name string
|
|
Status string
|
|
Subject string
|
|
Body string
|
|
FromEmail string
|
|
TemplateID int
|
|
Type string
|
|
Tags []string
|
|
ListIDs []int
|
|
MediaIDs []int
|
|
}
|
|
|
|
type fakeMedia struct {
|
|
ID int
|
|
Filename string
|
|
}
|
|
|
|
type fakeTemplate struct {
|
|
ID int
|
|
Name string
|
|
Body string
|
|
IsDefault bool
|
|
}
|
|
|
|
type testCall struct {
|
|
CampaignID int
|
|
Emails []string
|
|
}
|
|
|
|
type segmentQueryCall struct {
|
|
Query string
|
|
ListID int
|
|
}
|
|
|
|
type fakeListmonk struct {
|
|
mu sync.Mutex
|
|
t *testing.T
|
|
nextID int
|
|
lists []fakeList
|
|
campaigns []fakeCampaign
|
|
media []fakeMedia
|
|
templates []fakeTemplate
|
|
testCalls []testCall
|
|
segmentQueryCalls []segmentQueryCall
|
|
failSegmentQuery bool
|
|
}
|
|
|
|
func newFakeListmonk(t *testing.T) *fakeListmonk {
|
|
return &fakeListmonk{t: t, nextID: 1}
|
|
}
|
|
|
|
func (f *fakeListmonk) id() int {
|
|
id := f.nextID
|
|
f.nextID++
|
|
return id
|
|
}
|
|
|
|
func (f *fakeListmonk) client() *listmonk.Client {
|
|
srv := httptest.NewServer(http.HandlerFunc(f.handle))
|
|
f.t.Cleanup(srv.Close)
|
|
return listmonk.New(srv.URL, "u", "t")
|
|
}
|
|
|
|
func (f *fakeListmonk) handle(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/lists":
|
|
f.writeLists(w)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/lists":
|
|
f.createList(w, r)
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/campaigns":
|
|
f.writeCampaigns(w)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/campaigns":
|
|
f.createCampaign(w, r)
|
|
case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/status"):
|
|
f.setStatus(w, r)
|
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/test"):
|
|
f.recordTest(w, r)
|
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/campaigns/"):
|
|
f.updateCampaign(w, r)
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/media":
|
|
f.writeMedia(w)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/media":
|
|
f.uploadMedia(w, r)
|
|
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/query/lists":
|
|
f.queryAddToList(w, r)
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/templates":
|
|
f.writeTemplates(w)
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/templates":
|
|
f.createTemplate(w, r)
|
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/templates/"):
|
|
f.updateTemplate(w, r)
|
|
default:
|
|
f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func (c fakeCampaign) toJSON() map[string]any {
|
|
lists := make([]map[string]any, 0, len(c.ListIDs))
|
|
for _, id := range c.ListIDs {
|
|
lists = append(lists, map[string]any{"id": id})
|
|
}
|
|
media := make([]map[string]any, 0, len(c.MediaIDs))
|
|
for _, id := range c.MediaIDs {
|
|
media = append(media, map[string]any{"id": id})
|
|
}
|
|
return map[string]any{
|
|
"id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject,
|
|
"body": c.Body, "from_email": c.FromEmail, "template_id": c.TemplateID,
|
|
"type": c.Type, "tags": c.Tags, "lists": lists, "media": media,
|
|
}
|
|
}
|
|
|
|
func (f *fakeListmonk) writeCampaigns(w http.ResponseWriter) {
|
|
results := make([]map[string]any, 0, len(f.campaigns))
|
|
for _, c := range f.campaigns {
|
|
results = append(results, c.toJSON())
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
|
|
}
|
|
|
|
func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
|
|
body := decodeBody(r)
|
|
c := fakeCampaign{
|
|
ID: f.id(), Name: str(body["name"]), Status: "draft",
|
|
Subject: str(body["subject"]), Body: str(body["body"]),
|
|
FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]),
|
|
Type: str(body["type"]), Tags: toStrings(body["tags"]),
|
|
ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]),
|
|
}
|
|
f.campaigns = append(f.campaigns, c)
|
|
json.NewEncoder(w).Encode(map[string]any{"data": c.toJSON()})
|
|
}
|
|
|
|
func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
|
|
id := pathID(r.URL.Path, "/api/campaigns/")
|
|
body := decodeBody(r)
|
|
for i := range f.campaigns {
|
|
if f.campaigns[i].ID == id {
|
|
f.campaigns[i].Subject = str(body["subject"])
|
|
f.campaigns[i].Body = str(body["body"])
|
|
f.campaigns[i].FromEmail = str(body["from_email"])
|
|
f.campaigns[i].TemplateID = toInt(body["template_id"])
|
|
f.campaigns[i].Type = str(body["type"])
|
|
f.campaigns[i].Tags = toStrings(body["tags"])
|
|
f.campaigns[i].ListIDs = toInts(body["lists"])
|
|
f.campaigns[i].MediaIDs = toInts(body["media"])
|
|
json.NewEncoder(w).Encode(map[string]any{"data": f.campaigns[i].toJSON()})
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
|
|
func (f *fakeListmonk) setStatus(w http.ResponseWriter, r *http.Request) {
|
|
id := pathID(strings.TrimSuffix(r.URL.Path, "/status"), "/api/campaigns/")
|
|
body := decodeBody(r)
|
|
for i := range f.campaigns {
|
|
if f.campaigns[i].ID == id {
|
|
f.campaigns[i].Status = str(body["status"])
|
|
w.Write([]byte(`{}`))
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
|
|
func (f *fakeListmonk) recordTest(w http.ResponseWriter, r *http.Request) {
|
|
id := pathID(strings.TrimSuffix(r.URL.Path, "/test"), "/api/campaigns/")
|
|
body := decodeBody(r)
|
|
f.testCalls = append(f.testCalls, testCall{CampaignID: id, Emails: toStrings(body["subscribers"])})
|
|
w.Write([]byte(`{}`))
|
|
}
|
|
|
|
func (f *fakeListmonk) writeLists(w http.ResponseWriter) {
|
|
results := make([]map[string]any, 0, len(f.lists))
|
|
for _, l := range f.lists {
|
|
results = append(results, map[string]any{"id": l.ID, "name": l.Name})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
|
|
}
|
|
|
|
func (f *fakeListmonk) createList(w http.ResponseWriter, r *http.Request) {
|
|
body := decodeBody(r)
|
|
l := fakeList{ID: f.id(), Name: str(body["name"])}
|
|
f.lists = append(f.lists, l)
|
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": l.ID}})
|
|
}
|
|
|
|
func (f *fakeListmonk) writeMedia(w http.ResponseWriter) {
|
|
results := make([]map[string]any, 0, len(f.media))
|
|
for _, m := range f.media {
|
|
results = append(results, map[string]any{"id": m.ID, "filename": m.Filename})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": results})
|
|
}
|
|
|
|
func (f *fakeListmonk) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
_, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
m := fakeMedia{ID: f.id(), Filename: header.Filename}
|
|
f.media = append(f.media, m)
|
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": m.ID}})
|
|
}
|
|
|
|
func (f *fakeListmonk) queryAddToList(w http.ResponseWriter, r *http.Request) {
|
|
if f.failSegmentQuery {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"message":"invalid SQL expression"}`))
|
|
return
|
|
}
|
|
body := decodeBody(r)
|
|
targetIDs := toInts(body["target_list_ids"])
|
|
var listID int
|
|
if len(targetIDs) > 0 {
|
|
listID = targetIDs[0]
|
|
}
|
|
f.segmentQueryCalls = append(f.segmentQueryCalls, segmentQueryCall{Query: str(body["query"]), ListID: listID})
|
|
w.Write([]byte(`{}`))
|
|
}
|
|
|
|
func (f *fakeListmonk) writeTemplates(w http.ResponseWriter) {
|
|
results := make([]map[string]any, 0, len(f.templates))
|
|
for _, t := range f.templates {
|
|
results = append(results, map[string]any{"id": t.ID, "name": t.Name, "type": "campaign", "is_default": t.IsDefault})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"data": results})
|
|
}
|
|
|
|
func (f *fakeListmonk) createTemplate(w http.ResponseWriter, r *http.Request) {
|
|
body := decodeBody(r)
|
|
isDefault, _ := body["is_default"].(bool)
|
|
tpl := fakeTemplate{ID: f.id(), Name: str(body["name"]), Body: str(body["body"]), IsDefault: isDefault}
|
|
f.templates = append(f.templates, tpl)
|
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": tpl.ID}})
|
|
}
|
|
|
|
func (f *fakeListmonk) updateTemplate(w http.ResponseWriter, r *http.Request) {
|
|
id := pathID(r.URL.Path, "/api/templates/")
|
|
body := decodeBody(r)
|
|
for i := range f.templates {
|
|
if f.templates[i].ID == id {
|
|
f.templates[i].Name = str(body["name"])
|
|
f.templates[i].Body = str(body["body"])
|
|
f.templates[i].IsDefault, _ = body["is_default"].(bool)
|
|
w.Write([]byte(`{}`))
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
|
|
func decodeBody(r *http.Request) map[string]any {
|
|
var body map[string]any
|
|
b, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(b, &body)
|
|
return body
|
|
}
|
|
|
|
func str(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func toInt(v any) int {
|
|
f, _ := v.(float64)
|
|
return int(f)
|
|
}
|
|
|
|
func toStrings(v any) []string {
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(arr))
|
|
for _, x := range arr {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toInts(v any) []int {
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]int, 0, len(arr))
|
|
for _, x := range arr {
|
|
if n, ok := x.(float64); ok {
|
|
out = append(out, int(n))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pathID(path, prefix string) int {
|
|
id, _ := strconv.Atoi(strings.TrimPrefix(path, prefix))
|
|
return id
|
|
}
|
|
|
|
// ---- test fixtures ----
|
|
|
|
func writeCampaignDir(t *testing.T, root, slug, frontmatterExtra, body string) {
|
|
t.Helper()
|
|
path := filepath.Join(root, "campaigns", slug, "campaign.md")
|
|
writeFile(t, path, "---\n"+frontmatterExtra+"---\n"+body+"\n")
|
|
}
|
|
|
|
const baseFrontmatter = "subject: \"Big Announcement\"\nlists: [\"Newsletter\"]\nfrom_email: hello@example.com\n"
|
|
|
|
// ---- tests ----
|
|
|
|
func TestSyncDir_CreatesNewDraftCampaign(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Rejected) != 0 {
|
|
t.Fatalf("expected no rejections, got %v", result.Rejected)
|
|
}
|
|
if len(result.Synced) != 1 || result.Synced[0] != "launch" {
|
|
t.Fatalf("expected launch in Synced, got %v", result.Synced)
|
|
}
|
|
if len(f.campaigns) != 1 {
|
|
t.Fatalf("expected 1 campaign created, got %d", len(f.campaigns))
|
|
}
|
|
c := f.campaigns[0]
|
|
if c.Status != "draft" {
|
|
t.Errorf("expected status draft, got %q", c.Status)
|
|
}
|
|
if len(c.ListIDs) != 1 || c.ListIDs[0] != 3 {
|
|
t.Errorf("expected list ID resolved to [3], got %v", c.ListIDs)
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_SecondSyncWithUnchangedContentIsNoop(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
|
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
|
t.Fatalf("first SyncDir: %v", err)
|
|
}
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("second SyncDir: %v", err)
|
|
}
|
|
if len(result.Synced) != 0 {
|
|
t.Errorf("expected no re-sync of unchanged content, got Synced=%v", result.Synced)
|
|
}
|
|
if len(result.Unchanged) != 1 || result.Unchanged[0] != "launch" {
|
|
t.Errorf("expected launch in Unchanged, got %v", result.Unchanged)
|
|
}
|
|
if len(f.campaigns) != 1 {
|
|
t.Errorf("expected still exactly 1 campaign, got %d", len(f.campaigns))
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
|
t.Fatalf("first SyncDir: %v", err)
|
|
}
|
|
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello, updated world.")
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("second SyncDir: %v", err)
|
|
}
|
|
if len(result.Synced) != 1 || result.Synced[0] != "launch" {
|
|
t.Fatalf("expected launch re-synced, got Synced=%v Unchanged=%v", result.Synced, result.Unchanged)
|
|
}
|
|
if len(f.campaigns) != 1 || !strings.Contains(f.campaigns[0].Body, "updated") {
|
|
t.Errorf("expected the existing campaign's body to be updated, got %+v", f.campaigns[0])
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running", Subject: "Original"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "running") {
|
|
t.Fatalf("expected a rejection mentioning 'running', got %v", result.Rejected)
|
|
}
|
|
if f.campaigns[0].Subject != "Original" {
|
|
t.Errorf("expected the live campaign's content to be untouched, got %+v", f.campaigns[0])
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_AmbiguousListNameRejects(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}, {ID: 4, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Rejected) != 1 {
|
|
t.Fatalf("expected 1 rejection for an ambiguous list name, got %v", result.Rejected)
|
|
}
|
|
if len(f.campaigns) != 0 {
|
|
t.Errorf("expected no campaign created for a rejected sync, got %d", len(f.campaigns))
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_SegmentQueryMaterializesIntoManagedList(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
query := "subscribers.attribs->>'source' = 'workshop'"
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
fm := baseFrontmatter + "segment_query: \"" + query + "\"\n"
|
|
writeCampaignDir(t, root, "launch", fm, "Hello world.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Rejected) != 0 {
|
|
t.Fatalf("expected no rejections, got %v", result.Rejected)
|
|
}
|
|
if len(f.segmentQueryCalls) != 1 {
|
|
t.Fatalf("expected 1 segment query call, got %d", len(f.segmentQueryCalls))
|
|
}
|
|
if f.segmentQueryCalls[0].Query != query {
|
|
t.Errorf("expected the query sent verbatim, got %q", f.segmentQueryCalls[0].Query)
|
|
}
|
|
|
|
var segList *fakeList
|
|
for i := range f.lists {
|
|
if f.lists[i].Name == "segment:launch" {
|
|
segList = &f.lists[i]
|
|
}
|
|
}
|
|
if segList == nil {
|
|
t.Fatal("expected a managed 'segment:launch' list to be created")
|
|
}
|
|
if f.segmentQueryCalls[0].ListID != segList.ID {
|
|
t.Errorf("expected the segment query call to target list %d, got %d", segList.ID, f.segmentQueryCalls[0].ListID)
|
|
}
|
|
if len(f.campaigns) != 1 {
|
|
t.Fatalf("expected 1 campaign, got %d", len(f.campaigns))
|
|
}
|
|
found := false
|
|
for _, id := range f.campaigns[0].ListIDs {
|
|
if id == segList.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected campaign to target the segment list %d, got %v", segList.ID, f.campaigns[0].ListIDs)
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_InvalidSegmentQueryRejectsWithoutCreatingCampaign(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
f.failSegmentQuery = true
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
fm := baseFrontmatter + "segment_query: \"not valid sql\"\n"
|
|
writeCampaignDir(t, root, "launch", fm, "Hello world.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "segment_query") {
|
|
t.Fatalf("expected a segment_query rejection, got %v", result.Rejected)
|
|
}
|
|
if len(f.campaigns) != 0 {
|
|
t.Errorf("expected no campaign created when segment_query fails, got %d", len(f.campaigns))
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_AttachmentUploadAndDedup(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, "campaigns", "launch", "assets", "flyer.pdf"), "pdf-v1")
|
|
fm := baseFrontmatter + "attachments:\n - assets/flyer.pdf\n"
|
|
writeCampaignDir(t, root, "launch", fm, "Hello world.")
|
|
|
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
|
t.Fatalf("first SyncDir: %v", err)
|
|
}
|
|
if len(f.media) != 1 {
|
|
t.Fatalf("expected 1 media upload, got %d", len(f.media))
|
|
}
|
|
|
|
// Re-sync unchanged: no new upload.
|
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
|
t.Fatalf("second SyncDir: %v", err)
|
|
}
|
|
if len(f.media) != 1 {
|
|
t.Errorf("expected dedup to avoid a second upload, got %d media items", len(f.media))
|
|
}
|
|
|
|
// Change the attachment's content: a fresh upload.
|
|
writeFile(t, filepath.Join(root, "campaigns", "launch", "assets", "flyer.pdf"), "pdf-v2")
|
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
|
t.Fatalf("third SyncDir: %v", err)
|
|
}
|
|
if len(f.media) != 2 {
|
|
t.Errorf("expected a fresh upload when attachment content changes, got %d media items", len(f.media))
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_AutomaticPreviewFiresOnlyOnChange(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
|
|
|
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
|
|
t.Fatalf("first SyncDir: %v", err)
|
|
}
|
|
if len(f.testCalls) != 1 {
|
|
t.Fatalf("expected exactly 1 preview send after create, got %d", len(f.testCalls))
|
|
}
|
|
if f.testCalls[0].Emails[0] != "me@example.com" {
|
|
t.Errorf("expected preview sent to default address, got %v", f.testCalls[0].Emails)
|
|
}
|
|
|
|
// Unchanged re-sync: no additional preview.
|
|
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
|
|
t.Fatalf("second SyncDir: %v", err)
|
|
}
|
|
if len(f.testCalls) != 1 {
|
|
t.Errorf("expected no additional preview for an unchanged campaign, got %d total", len(f.testCalls))
|
|
}
|
|
|
|
// Change content: exactly one more preview.
|
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello, updated world.")
|
|
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
|
|
t.Fatalf("third SyncDir: %v", err)
|
|
}
|
|
if len(f.testCalls) != 2 {
|
|
t.Errorf("expected exactly 2 total previews after one content change, got %d", len(f.testCalls))
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_PreviewEmailsOverridesDefault(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
fm := baseFrontmatter + "preview_emails:\n - cofounder@example.com\n"
|
|
writeCampaignDir(t, root, "launch", fm, "Hello world.")
|
|
|
|
if _, err := SyncDir(lm, root, []string{"default@example.com"}); err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(f.testCalls) != 1 {
|
|
t.Fatalf("expected 1 preview send, got %d", len(f.testCalls))
|
|
}
|
|
if f.testCalls[0].Emails[0] != "cofounder@example.com" {
|
|
t.Errorf("expected campaign-level preview_emails to override the default, got %v", f.testCalls[0].Emails)
|
|
}
|
|
}
|
|
|
|
func TestSyncDir_OneBadCampaignDoesNotAbortOthers(t *testing.T) {
|
|
f := newFakeListmonk(t)
|
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
|
lm := f.client()
|
|
|
|
root := t.TempDir()
|
|
writeCampaignDir(t, root, "good", baseFrontmatter, "Hello world.")
|
|
writeCampaignDir(t, root, "bad", "lists: [\"Newsletter\"]\nfrom_email: hello@example.com\n", "Missing a subject.")
|
|
|
|
result, err := SyncDir(lm, root, nil)
|
|
if err != nil {
|
|
t.Fatalf("SyncDir: %v", err)
|
|
}
|
|
if len(result.Synced) != 1 || result.Synced[0] != "good" {
|
|
t.Errorf("expected good to sync despite bad's failure, got Synced=%v", result.Synced)
|
|
}
|
|
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "bad:") {
|
|
t.Errorf("expected bad to be rejected with a slug-prefixed message, got %v", result.Rejected)
|
|
}
|
|
}
|