Initial implementation of the eec-campaigns tool
Drives listmonk's real Campaign API from git-authored Markdown+frontmatter, so broadcast/segment emails get listmonk's mature unsubscribe/bulk-send/ attachment handling instead of reimplementing it. sync only ever creates or updates a draft (idempotent, diff-based, refuses to touch a non-draft campaign); a pushed send/<slug> tag or manual workflow run is the only way to actually trigger a send. Includes list-name resolution, segment_query materialization into managed lists, content-hash-deduped attachment uploads, and an automatic post-sync preview email.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// Package campaign parses campaign content (a campaign.md file with YAML
|
||||
// frontmatter plus a Markdown body) and syncs it into listmonk as a
|
||||
// campaign, mirroring the content-as-code pattern eec's internal/course
|
||||
// package uses for course steps — but flattened, since a campaign has no
|
||||
// step sequencing.
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Frontmatter is the parsed YAML block at the top of a campaign.md file.
|
||||
type Frontmatter struct {
|
||||
Subject string `yaml:"subject"`
|
||||
// Lists names Listmonk lists by name, not numeric ID (unlike eec's
|
||||
// course_steps.list_id) — resolved to IDs at sync time so a campaign
|
||||
// file stays legible without cross-referencing the Listmonk admin UI.
|
||||
Lists []string `yaml:"lists"`
|
||||
FromEmail string `yaml:"from_email"`
|
||||
Tags []string `yaml:"tags"`
|
||||
// TemplateID is optional; 0 means "use listmonk's default template".
|
||||
TemplateID int `yaml:"template_id"`
|
||||
// Type defaults to "regular" when omitted; "optin" is rejected in v1
|
||||
// (see Validate) since this tool has no opt-in-confirmation workflow.
|
||||
Type string `yaml:"type"`
|
||||
// SegmentQuery is a raw SQL boolean expression run against subscribers,
|
||||
// the same segmentation mechanism listmonk's own admin UI search box
|
||||
// uses — see internal/campaign/sync.go for how this gets materialized
|
||||
// into list membership.
|
||||
SegmentQuery string `yaml:"segment_query"`
|
||||
// PreviewEmails overrides the sync-wide default preview address for
|
||||
// this campaign's automatic post-sync preview.
|
||||
PreviewEmails []string `yaml:"preview_emails"`
|
||||
// Attachments are paths relative to the campaign's own directory
|
||||
// (typically under assets/), uploaded to listmonk's media library and
|
||||
// attached to the campaign.
|
||||
Attachments []string `yaml:"attachments"`
|
||||
}
|
||||
|
||||
// Campaign is one parsed campaign.md, identified by its directory name.
|
||||
type Campaign struct {
|
||||
Slug string // campaigns/<slug>/ — also sent to listmonk as the campaign's Name
|
||||
Dir string // full path to campaigns/<slug>/, for resolving Attachments
|
||||
Frontmatter
|
||||
Body string
|
||||
}
|
||||
|
||||
// ParseFile reads a campaign.md file with a leading
|
||||
// "---\n...\n---\n" frontmatter block and returns the parsed frontmatter and
|
||||
// body, or a descriptive error — same shape as eec's course.parseFrontmatter.
|
||||
func ParseFile(path string) (Frontmatter, string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Frontmatter{}, "", err
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return Frontmatter{}, "", fmt.Errorf("missing frontmatter (expected file to start with '---')")
|
||||
}
|
||||
rest := text[4:]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end == -1 {
|
||||
return Frontmatter{}, "", fmt.Errorf("unterminated frontmatter (missing closing '---')")
|
||||
}
|
||||
rawFM := rest[:end]
|
||||
body := strings.TrimPrefix(rest[end+len("\n---\n"):], "\n")
|
||||
|
||||
var fm Frontmatter
|
||||
if err := yaml.Unmarshal([]byte(rawFM), &fm); err != nil {
|
||||
return Frontmatter{}, "", fmt.Errorf("parsing frontmatter: %w", err)
|
||||
}
|
||||
if fm.Type == "" {
|
||||
fm.Type = "regular"
|
||||
}
|
||||
if err := fm.Validate(); err != nil {
|
||||
return Frontmatter{}, "", err
|
||||
}
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
// Validate checks the fields sync.go depends on before ever talking to
|
||||
// listmonk, so a bad file is rejected with one clear message instead of a
|
||||
// confusing API error partway through syncing.
|
||||
func (fm Frontmatter) Validate() error {
|
||||
if fm.Subject == "" {
|
||||
return fmt.Errorf("frontmatter missing 'subject'")
|
||||
}
|
||||
if len(fm.Lists) == 0 && fm.SegmentQuery == "" {
|
||||
return fmt.Errorf("frontmatter must set 'lists' and/or 'segment_query' — a campaign needs a target audience")
|
||||
}
|
||||
if fm.FromEmail == "" {
|
||||
return fmt.Errorf("frontmatter missing 'from_email'")
|
||||
}
|
||||
if fm.Type != "regular" {
|
||||
return fmt.Errorf("type %q is not supported yet (only 'regular' campaigns) — optin campaigns need their own confirmation workflow this tool doesn't have", fm.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_ParsesFrontmatterAndBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
subject: "Big Announcement"
|
||||
lists: ["Newsletter"]
|
||||
from_email: hello@reground.org
|
||||
tags: ["announcement"]
|
||||
attachments:
|
||||
- assets/one-pager.pdf
|
||||
---
|
||||
Hello **world**.
|
||||
`)
|
||||
|
||||
fm, body, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if fm.Subject != "Big Announcement" {
|
||||
t.Errorf("unexpected subject: %q", fm.Subject)
|
||||
}
|
||||
if len(fm.Lists) != 1 || fm.Lists[0] != "Newsletter" {
|
||||
t.Errorf("unexpected lists: %v", fm.Lists)
|
||||
}
|
||||
if fm.Type != "regular" {
|
||||
t.Errorf("expected type to default to regular, got %q", fm.Type)
|
||||
}
|
||||
if !strings.Contains(body, "Hello **world**.") {
|
||||
t.Errorf("unexpected body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MissingFrontmatterDelimiter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, "no frontmatter here\n")
|
||||
|
||||
if _, _, err := ParseFile(path); err == nil {
|
||||
t.Fatal("expected an error for a file with no frontmatter block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_UnterminatedFrontmatter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, "---\nsubject: hi\nbody with no closing delimiter")
|
||||
|
||||
if _, _, err := ParseFile(path); err == nil {
|
||||
t.Fatal("expected an error for unterminated frontmatter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MissingSubject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
lists: ["Newsletter"]
|
||||
from_email: hello@reground.org
|
||||
---
|
||||
body
|
||||
`)
|
||||
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "subject") {
|
||||
t.Fatalf("expected a 'missing subject' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MissingFromEmail(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
subject: hi
|
||||
lists: ["Newsletter"]
|
||||
---
|
||||
body
|
||||
`)
|
||||
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "from_email") {
|
||||
t.Fatalf("expected a 'missing from_email' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_RequiresListsOrSegmentQuery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
subject: hi
|
||||
from_email: hello@reground.org
|
||||
---
|
||||
body
|
||||
`)
|
||||
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "target audience") {
|
||||
t.Fatalf("expected a 'needs a target audience' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_SegmentQueryAloneSatisfiesTargetAudience(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
subject: hi
|
||||
from_email: hello@reground.org
|
||||
segment_query: "subscribers.attribs->>'source' = 'workshop'"
|
||||
---
|
||||
body
|
||||
`)
|
||||
if _, _, err := ParseFile(path); err != nil {
|
||||
t.Fatalf("expected segment_query alone to be sufficient, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_RejectsOptin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, `---
|
||||
subject: hi
|
||||
lists: ["Newsletter"]
|
||||
from_email: hello@reground.org
|
||||
type: optin
|
||||
---
|
||||
body
|
||||
`)
|
||||
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "optin") {
|
||||
t.Fatalf("expected an 'optin not supported' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MalformedYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.md")
|
||||
writeFile(t, path, "---\nsubject: [unterminated\n---\nbody\n")
|
||||
|
||||
if _, _, err := ParseFile(path); err == nil {
|
||||
t.Fatal("expected an error for malformed YAML frontmatter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"reground.org/eec-campaigns/internal/listmonk"
|
||||
)
|
||||
|
||||
// Send transitions a campaign from draft/paused to running — the one real
|
||||
// send trigger in this whole tool (see cmd/send, triggered only by a pushed
|
||||
// send/<slug> git tag or a manual workflow_dispatch, never by a plain
|
||||
// sync). The campaign is looked up fresh by name each call, so this always
|
||||
// acts on listmonk's current live state, not whatever sync last saw.
|
||||
func Send(lm *listmonk.Client, slug string) (*listmonk.Campaign, error) {
|
||||
camp, err := lm.FindCampaignByName(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if camp == nil {
|
||||
return nil, fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
|
||||
}
|
||||
if camp.Status != "draft" && camp.Status != "paused" {
|
||||
return nil, fmt.Errorf("campaign %q is %s in listmonk — only draft or paused campaigns can be sent", slug, camp.Status)
|
||||
}
|
||||
if err := lm.SetCampaignStatus(camp.ID, "running"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return camp, nil
|
||||
}
|
||||
|
||||
// Test sends a preview of a campaign's current content to the given
|
||||
// addresses without touching its status — the same mechanism sync's
|
||||
// automatic preview uses, exposed standalone for on-demand re-previews.
|
||||
func Test(lm *listmonk.Client, slug string, emails []string) error {
|
||||
camp, err := lm.FindCampaignByName(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if camp == nil {
|
||||
return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
|
||||
}
|
||||
return lm.TestCampaign(camp.ID, emails)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSend_TransitionsDraftToRunning(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "draft"}}
|
||||
lm := f.client()
|
||||
|
||||
camp, err := Send(lm, "launch")
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if camp.ID != 1 {
|
||||
t.Errorf("expected campaign id 1, got %d", camp.ID)
|
||||
}
|
||||
if f.campaigns[0].Status != "running" {
|
||||
t.Errorf("expected status running, got %q", f.campaigns[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_TransitionsPausedToRunning(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "paused"}}
|
||||
lm := f.client()
|
||||
|
||||
if _, err := Send(lm, "launch"); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if f.campaigns[0].Status != "running" {
|
||||
t.Errorf("expected status running, got %q", f.campaigns[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_RefusesWhenAlreadyRunning(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running"}}
|
||||
lm := f.client()
|
||||
|
||||
if _, err := Send(lm, "launch"); err == nil || !strings.Contains(err.Error(), "running") {
|
||||
t.Fatalf("expected a refusal mentioning 'running', got %v", err)
|
||||
}
|
||||
if f.campaigns[0].Status != "running" {
|
||||
t.Errorf("expected status to remain running, got %q", f.campaigns[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_ErrorsWhenCampaignMissing(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
lm := f.client()
|
||||
|
||||
if _, err := Send(lm, "nonexistent"); err == nil || !strings.Contains(err.Error(), "run sync first") {
|
||||
t.Fatalf("expected a 'run sync first' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTest_SendsPreviewToGivenAddresses(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "draft"}}
|
||||
lm := f.client()
|
||||
|
||||
if err := Test(lm, "launch", []string{"me@example.com"}); err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
if len(f.testCalls) != 1 || f.testCalls[0].CampaignID != 1 {
|
||||
t.Fatalf("expected 1 test call against campaign 1, got %+v", f.testCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTest_ErrorsWhenCampaignMissing(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
lm := f.client()
|
||||
|
||||
if err := Test(lm, "nonexistent", []string{"me@example.com"}); err == nil || !strings.Contains(err.Error(), "run sync first") {
|
||||
t.Fatalf("expected a 'run sync first' error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"reground.org/eec-campaigns/internal/listmonk"
|
||||
)
|
||||
|
||||
// SyncResult summarizes what a sync run did, split so the CI log (and the
|
||||
// person reading it) can tell at a glance what actually happened — no bad
|
||||
// campaign silently skipped or overwritten, no preview silently swallowed.
|
||||
type SyncResult struct {
|
||||
Synced []string // created or updated (content changed) in listmonk
|
||||
Unchanged []string // existing draft, content identical — no API write, no preview
|
||||
Rejected []string // validation/segmentation/list-lookup failure, or non-draft in listmonk — nothing written
|
||||
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
|
||||
}
|
||||
|
||||
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
|
||||
// listmonk draft campaign. One bad campaign doesn't abort the rest — this
|
||||
// mirrors eec's course.SyncDir/SyncResult shape exactly, extended with the
|
||||
// Unchanged/PreviewFailed buckets this tool's auto-preview and diff-based
|
||||
// sync need. defaultPreviewEmails is used for any campaign that doesn't set
|
||||
// its own preview_emails in frontmatter.
|
||||
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
|
||||
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SyncResult{}
|
||||
for _, path := range matches {
|
||||
dir := filepath.Dir(path)
|
||||
slug := filepath.Base(dir)
|
||||
|
||||
res, err := syncOne(lm, slug, dir, path, defaultPreviewEmails)
|
||||
if err != nil {
|
||||
result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err))
|
||||
continue
|
||||
}
|
||||
if !res.Changed {
|
||||
result.Unchanged = append(result.Unchanged, slug)
|
||||
continue
|
||||
}
|
||||
result.Synced = append(result.Synced, slug)
|
||||
if res.PreviewErr != nil {
|
||||
result.PreviewFailed = append(result.PreviewFailed, fmt.Sprintf("%s: %v", slug, res.PreviewErr))
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type syncOneResult struct {
|
||||
Changed bool
|
||||
PreviewErr error // set only when Changed and the automatic preview send failed
|
||||
}
|
||||
|
||||
func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) {
|
||||
fm, body, err := ParseFile(path)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
|
||||
listIDs, err := resolveLists(lm, fm.Lists)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
if fm.SegmentQuery != "" {
|
||||
segID, err := resolveSegment(lm, slug, fm.SegmentQuery)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
listIDs = append(listIDs, segID)
|
||||
}
|
||||
|
||||
mediaIDs, err := resolveAttachments(lm, slug, dir, fm.Attachments)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
|
||||
input := listmonk.CampaignInput{
|
||||
Name: slug, // the directory name IS the identity — see internal/campaign/parse.go doc
|
||||
Subject: fm.Subject,
|
||||
FromEmail: fm.FromEmail,
|
||||
TemplateID: fm.TemplateID,
|
||||
Type: fm.Type,
|
||||
Tags: fm.Tags,
|
||||
ListIDs: listIDs,
|
||||
Body: body,
|
||||
MediaIDs: mediaIDs,
|
||||
}
|
||||
|
||||
existing, err := lm.FindCampaignByName(slug)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
|
||||
var campaignID int
|
||||
var changed bool
|
||||
switch {
|
||||
case existing == nil:
|
||||
created, err := lm.CreateCampaign(input)
|
||||
if err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
campaignID, changed = created.ID, true
|
||||
|
||||
case existing.Status != "draft":
|
||||
// The core safety invariant: never silently skip or overwrite a
|
||||
// live/sent campaign. This is a hard rejection, not a warning.
|
||||
return syncOneResult{}, fmt.Errorf("campaign is %s in listmonk; sync refuses to modify a non-draft campaign", existing.Status)
|
||||
|
||||
case campaignUnchanged(existing, input):
|
||||
campaignID, changed = existing.ID, false
|
||||
|
||||
default:
|
||||
if _, err := lm.UpdateCampaign(existing.ID, input); err != nil {
|
||||
return syncOneResult{}, err
|
||||
}
|
||||
campaignID, changed = existing.ID, true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return syncOneResult{Changed: false}, nil
|
||||
}
|
||||
|
||||
recipients := fm.PreviewEmails
|
||||
if len(recipients) == 0 {
|
||||
recipients = defaultPreviewEmails
|
||||
}
|
||||
var previewErr error
|
||||
if len(recipients) > 0 {
|
||||
previewErr = lm.TestCampaign(campaignID, recipients)
|
||||
}
|
||||
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
|
||||
}
|
||||
|
||||
func resolveLists(lm *listmonk.Client, names []string) ([]int, error) {
|
||||
ids := make([]int, 0, len(names))
|
||||
for _, name := range names {
|
||||
id, err := lm.FindListByName(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %q: %w", name, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// resolveSegment materializes a segment_query into list membership, since a
|
||||
// listmonk campaign can only target whole list(s), not an arbitrary query
|
||||
// directly (still an open, unmerged upstream feature request — see the
|
||||
// plan's Open items). The list is a point-in-time snapshot: re-running sync
|
||||
// re-evaluates the query and re-syncs membership, so a later push before
|
||||
// send picks up newly-matching subscribers.
|
||||
func resolveSegment(lm *listmonk.Client, slug, query string) (int, error) {
|
||||
ids, err := lm.QuerySubscriberIDs(query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("segment_query: %w", err)
|
||||
}
|
||||
listName := "segment:" + slug
|
||||
listID, err := lm.FindOrCreateListByName(listName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("segment list %q: %w", listName, err)
|
||||
}
|
||||
if err := lm.BulkAddToList(ids, listID); err != nil {
|
||||
return 0, fmt.Errorf("segment list %q: %w", listName, err)
|
||||
}
|
||||
return listID, nil
|
||||
}
|
||||
|
||||
// resolveAttachments uploads each attachment under a content-hash-synthesized
|
||||
// filename, so editing a file naturally produces a fresh upload while an
|
||||
// unchanged file is recognized and reused — no local manifest of what's
|
||||
// already been uploaded needed, listmonk's own media library is the only
|
||||
// state store.
|
||||
func resolveAttachments(lm *listmonk.Client, slug, dir string, relPaths []string) ([]int, error) {
|
||||
ids := make([]int, 0, len(relPaths))
|
||||
for _, rel := range relPaths {
|
||||
full := filepath.Join(dir, rel)
|
||||
content, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
||||
}
|
||||
name := attachmentFilename(slug, rel, content)
|
||||
|
||||
id, found, err := lm.FindMediaByFilename(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
||||
}
|
||||
if !found {
|
||||
id, err = lm.UploadMedia(name, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
||||
}
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func attachmentFilename(slug, relPath string, content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
prefix := hex.EncodeToString(sum[:])[:8]
|
||||
return fmt.Sprintf("%s-%s-%s", slug, prefix, filepath.Base(relPath))
|
||||
}
|
||||
|
||||
// campaignUnchanged reports whether input's content already matches what's
|
||||
// stored in listmonk, so an unchanged campaign costs zero API writes and
|
||||
// never re-fires a preview. List/tag/media comparisons are order-independent
|
||||
// since neither side's ordering is meaningful.
|
||||
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
|
||||
return existing.Subject == input.Subject &&
|
||||
existing.Body == input.Body &&
|
||||
existing.FromEmail == input.FromEmail &&
|
||||
existing.TemplateID == input.TemplateID &&
|
||||
existing.Type == input.Type &&
|
||||
equalStringSets(existing.Tags, input.Tags) &&
|
||||
equalIntSets(existing.ListIDs, input.ListIDs) &&
|
||||
equalIntSets(existing.MediaIDs, input.MediaIDs)
|
||||
}
|
||||
|
||||
func equalStringSets(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
as, bs := append([]string(nil), a...), append([]string(nil), b...)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
for i := range as {
|
||||
if as[i] != bs[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalIntSets(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
as, bs := append([]int(nil), a...), append([]int(nil), b...)
|
||||
sort.Ints(as)
|
||||
sort.Ints(bs)
|
||||
for i := range as {
|
||||
if as[i] != bs[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
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 testCall struct {
|
||||
CampaignID int
|
||||
Emails []string
|
||||
}
|
||||
|
||||
type bulkAddCall struct {
|
||||
IDs []int
|
||||
ListID int
|
||||
}
|
||||
|
||||
type fakeListmonk struct {
|
||||
mu sync.Mutex
|
||||
t *testing.T
|
||||
nextID int
|
||||
lists []fakeList
|
||||
campaigns []fakeCampaign
|
||||
media []fakeMedia
|
||||
subscriberIDsForQuery map[string][]int
|
||||
testCalls []testCall
|
||||
bulkAddCalls []bulkAddCall
|
||||
}
|
||||
|
||||
func newFakeListmonk(t *testing.T) *fakeListmonk {
|
||||
return &fakeListmonk{t: t, nextID: 1, subscriberIDsForQuery: map[string][]int{}}
|
||||
}
|
||||
|
||||
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.MethodGet && r.URL.Path == "/api/subscribers":
|
||||
f.querySubscribers(w, r)
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/lists":
|
||||
f.bulkAdd(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})
|
||||
}
|
||||
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_ids": c.MediaIDs,
|
||||
}
|
||||
}
|
||||
|
||||
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_ids"]),
|
||||
}
|
||||
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_ids"])
|
||||
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) querySubscribers(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("query")
|
||||
ids := f.subscriberIDsForQuery[q]
|
||||
results := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
results = append(results, map[string]any{"id": id})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
|
||||
}
|
||||
|
||||
func (f *fakeListmonk) bulkAdd(w http.ResponseWriter, r *http.Request) {
|
||||
body := decodeBody(r)
|
||||
targetIDs := toInts(body["target_list_ids"])
|
||||
var listID int
|
||||
if len(targetIDs) > 0 {
|
||||
listID = targetIDs[0]
|
||||
}
|
||||
f.bulkAddCalls = append(f.bulkAddCalls, bulkAddCall{IDs: toInts(body["ids"]), ListID: listID})
|
||||
w.Write([]byte(`{}`))
|
||||
}
|
||||
|
||||
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'"
|
||||
f.subscriberIDsForQuery[query] = []int{10, 11, 12}
|
||||
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.bulkAddCalls) != 1 {
|
||||
t.Fatalf("expected 1 bulk-add call, got %d", len(f.bulkAddCalls))
|
||||
}
|
||||
if len(f.bulkAddCalls[0].IDs) != 3 {
|
||||
t.Errorf("expected 3 subscriber IDs bulk-added, got %v", f.bulkAddCalls[0].IDs)
|
||||
}
|
||||
|
||||
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 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_InvalidSegmentQueryRejectsWithoutSideEffects(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
||||
lm := f.client()
|
||||
|
||||
root := t.TempDir()
|
||||
// No entry seeded in subscriberIDsForQuery for this exact string simulates
|
||||
// a query listmonk would reject — here we just confirm an empty/no-match
|
||||
// result still flows through cleanly without creating a segment list
|
||||
// mistakenly treated as an error path; a real invalid-SQL rejection from
|
||||
// listmonk itself surfaces as a plain HTTP-error wrapped by
|
||||
// QuerySubscriberIDs, exercised at the listmonk package's own test level.
|
||||
fm := baseFrontmatter + "segment_query: \"subscribers.attribs->>'nope' = 'nothing'\"\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 for a zero-match segment query, got %v", result.Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user