Files
eec-campaigns/internal/campaign/sync_test.go
T
will c375916f05
CI / test (push) Successful in 8s
Scope sync to campaigns changed since a given commit
Add SyncChanged (and the ChangedSlugs git-diff helper it's built on) as
an opt-in narrower version of SyncDir: pass the push's "before" SHA and
only the campaign directories that actually changed get looked at at
all, instead of every campaign getting resolved and diffed against
listmonk on every push. An empty or unresolvable since (new branch's
first push, a shallow clone missing that commit, any git failure) always
falls back to a full scan -- this can only ever cause more work than
strictly necessary, never a silently-skipped campaign. SyncDir itself is
now a thin wrapper (SyncChanged with since=""), so every existing caller
and test is unaffected.

campaigns sync PATH now accepts an optional trailing SINCE_SHA.
2026-08-13 13:40:10 -04:00

895 lines
28 KiB
Go

package campaign
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"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}})
}
// fakeDefaultTemplateID is the nonzero template_id real listmonk assigns
// server-side to a campaign whose create/update request omitted template_id
// entirely -- never 0, which is what makes the zero value ambiguous between
// "unspecified" and "really is 0" in campaignUnchanged.
const fakeDefaultTemplateID = 1
func (f *fakeListmonk) resolveTemplateID(body map[string]any) int {
if v, ok := body["template_id"]; ok {
return toInt(v)
}
return fakeDefaultTemplateID
}
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: f.resolveTemplateID(body),
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 = f.resolveTemplateID(body)
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")
}
// ---- git test fixtures, for ChangedSlugs/SyncChanged ----
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func initGitRepo(t *testing.T, dir string) {
t.Helper()
runGit(t, dir, "init", "-q", "-b", "master")
}
// gitCommit stages everything and commits, returning the new commit's SHA.
func gitCommit(t *testing.T, dir, message string) string {
t.Helper()
runGit(t, dir, "add", "-A")
runGit(t, dir, "commit", "-q", "-m", message)
return runGit(t, dir, "rev-parse", "HEAD")
}
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_UnspecifiedTemplateIDDoesNotTriggerSpuriousResync(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
// baseFrontmatter never sets template_id, matching real campaign.md files
// like reground-campaigns' welcome/campaign.md.
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
if f.campaigns[0].TemplateID != fakeDefaultTemplateID {
t.Fatalf("expected the fake to assign its default template_id like real listmonk does, got %d", f.campaigns[0].TemplateID)
}
// Re-sync with the exact same, still-unspecified template_id: this must
// not be flagged as changed just because listmonk's stored, server-
// assigned template_id (nonzero) doesn't literally equal the frontmatter's
// unset value (0).
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(result.Synced) != 0 {
t.Errorf("expected no spurious re-sync from an unspecified template_id, got Synced=%v", result.Synced)
}
if len(result.Unchanged) != 1 || result.Unchanged[0] != "launch" {
t.Errorf("expected launch in Unchanged, got %v", result.Unchanged)
}
}
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_SkipsNonDraftCampaignWithoutFailing(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)
}
// A sent/running campaign is a historical record, not an error -- it
// must never land in Rejected (which fails the whole CI job) just
// because its campaign.md is still around.
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejection for an already-sent campaign, got %v", result.Rejected)
}
if len(result.Skipped) != 1 || !strings.Contains(result.Skipped[0], "running") {
t.Fatalf("expected launch in Skipped mentioning 'running', got %v", result.Skipped)
}
if f.campaigns[0].Subject != "Original" {
t.Errorf("expected the live campaign's content to be untouched, got %+v", f.campaigns[0])
}
}
func TestSyncDir_NonDraftCampaignSkipsEvenWithAStaleListReference(t *testing.T) {
// A finished campaign's campaign.md might reference a list that's since
// been renamed or removed -- that must never break the build, since an
// archived campaign has nothing left to resolve.
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "finished", Subject: "Original"}}
lm := f.client()
root := t.TempDir()
fm := "subject: \"Big Announcement\"\nlists: [\"No Longer Exists\"]\nfrom_email: hello@example.com\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 rejection despite the stale list reference, got %v", result.Rejected)
}
if len(result.Skipped) != 1 {
t.Fatalf("expected launch in Skipped, got %v", result.Skipped)
}
}
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)
}
}
// ---- ChangedSlugs / SyncChanged ----
func TestChangedSlugs_ReturnsOnlySlugsTouchedSinceGivenCommit(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
writeCampaignDir(t, root, "b", baseFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a")
slugs, ok := ChangedSlugs(root, base)
if !ok {
t.Fatal("expected ChangedSlugs to resolve a valid commit")
}
if !slugs["a"] || slugs["b"] {
t.Errorf("expected only 'a' in changed slugs, got %v", slugs)
}
}
func TestChangedSlugs_AllZeroShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// The all-zero SHA is what a push event reports as "before" for a
// brand-new branch's first push -- there's no real commit to diff
// against, so this must signal "can't determine," not "empty diff."
_, ok := ChangedSlugs(root, strings.Repeat("0", 40))
if ok {
t.Error("expected the all-zero SHA to be treated as unresolvable")
}
}
func TestChangedSlugs_UnknownShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// A commit git has never heard of (e.g. one this shallow clone never
// fetched) must fall back to "can't determine," not error out or
// silently produce an empty diff.
_, ok := ChangedSlugs(root, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if ok {
t.Error("expected an unknown commit to be treated as unresolvable")
}
}
func TestChangedSlugs_EmptySinceMeansUnresolvable(t *testing.T) {
root := t.TempDir()
_, ok := ChangedSlugs(root, "")
if ok {
t.Error("expected an empty since to be treated as unresolvable")
}
}
func TestSyncChanged_OnlyProcessesCampaignsTouchedSinceGivenCommit(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
// b's frontmatter references a list that doesn't exist in listmonk --
// if SyncChanged ever looks at it, it comes back Rejected.
bFrontmatter := "subject: \"B\"\nlists: [\"No Such List\"]\nfrom_email: hello@example.com\n"
writeCampaignDir(t, root, "b", bFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
// A full scan at this point must reject b -- sanity check that b's
// broken list reference is real, not a mistake in the fixture.
sanity, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("sanity SyncDir: %v", err)
}
if len(sanity.Rejected) != 1 || !strings.Contains(sanity.Rejected[0], "b:") {
t.Fatalf("expected a full scan to reject b, got Rejected=%v", sanity.Rejected)
}
// Reset: the sanity run may have created campaign "a" in listmonk.
f.campaigns = nil
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a only")
result, err := SyncChanged(lm, root, nil, base)
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected only a synced, got Synced=%v", result.Synced)
}
// The real point of this test: b was never even looked at, despite its
// broken list reference, because it wasn't touched by this push.
if len(result.Rejected) != 0 {
t.Errorf("expected b to be left untouched (not rejected) since it wasn't in the diff, got Rejected=%v", result.Rejected)
}
if !strings.Contains(result.Mode, "1 of 2") {
t.Errorf("expected Mode to report 1 of 2 campaigns touched, got %q", result.Mode)
}
}
func TestSyncChanged_FallsBackToFullScanWhenSinceUnresolvable(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello a.")
gitCommit(t, root, "add a")
result, err := SyncChanged(lm, root, nil, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected a fallback full scan to still sync a, got Synced=%v", result.Synced)
}
if !strings.Contains(result.Mode, "full scan") {
t.Errorf("expected Mode to note the fallback to a full scan, got %q", result.Mode)
}
}