6227d59eae
CI / test (push) Successful in 10s
Campaign/parseCampaign never fetched altbody, so campaignUnchanged() compared everything except it -- a campaign synced before v0.1.3 (altbody always null) whose content otherwise hasn't changed would be judged unchanged forever and never get updated with the new altbody, even though every subsequent sync now intends to send one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
660 lines
20 KiB
Go
660 lines
20 KiB
Go
package campaign
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.reground.org/will/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
|
|
AltBody 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, "altbody": c.AltBody, "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"]), AltBody: str(body["altbody"]),
|
|
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].AltBody = str(body["altbody"])
|
|
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)
|
|
}
|
|
}
|