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}}) } // 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") } 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) } }