mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
781 lines
32 KiB
Go
781 lines
32 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/behavior"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/encrypt"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Live scheduler checks against a real Postgres. Skipped unless WARMBLY_TEST_DB
|
|
// is set, so `go test ./...` stays hermetic; run them against the dev stack
|
|
// with:
|
|
//
|
|
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
|
// go test ./internal/scheduler/ -run Live -v
|
|
//
|
|
// These exist because every other test in this package stubs the database out.
|
|
// The thing most worth proving — that a sending-behaviour profile actually
|
|
// moves a real campaign's next send into the mailbox's rolled workday — can
|
|
// only be proven against the real query paths.
|
|
|
|
func liveDB(t *testing.T) (*db.DB, *pgxpool.Pool) {
|
|
t.Helper()
|
|
dsn := os.Getenv("WARMBLY_TEST_DB")
|
|
if dsn == "" {
|
|
t.Skip("WARMBLY_TEST_DB not set")
|
|
}
|
|
handle, err := db.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
t.Cleanup(func() { handle.Pool.Close() })
|
|
if err := handle.Ping(context.Background()); err != nil {
|
|
t.Fatalf("ping: %v", err)
|
|
}
|
|
return handle, handle.Pool
|
|
}
|
|
|
|
// liveFixture builds an isolated org/user/mailbox/campaign/contact graph and
|
|
// returns the ids, removing the whole graph on cleanup.
|
|
type liveFixture struct {
|
|
pool *pgxpool.Pool
|
|
user uuid.UUID
|
|
org uuid.UUID
|
|
mailbox uuid.UUID
|
|
campaign uuid.UUID
|
|
}
|
|
|
|
func newLiveFixture(t *testing.T, pool *pgxpool.Pool, timezone string) *liveFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
f := &liveFixture{
|
|
pool: pool, user: uuid.New(), org: uuid.New(),
|
|
mailbox: uuid.New(), campaign: uuid.New(),
|
|
}
|
|
|
|
exec := func(sql string, args ...any) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
|
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
|
}
|
|
}
|
|
|
|
exec(`INSERT INTO users (id, email, first_name, last_name)
|
|
VALUES ($1, $2, 'Live', 'Test')`, f.user, "live-"+f.user.String()[:8]+"@test.local")
|
|
exec(`INSERT INTO organizations (id, name, slug, owner_user_id)
|
|
VALUES ($1, 'Live Test', $2, $3)`, f.org, "live-"+f.org.String()[:8], f.user)
|
|
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name,
|
|
signature_plain, signature_html, provider, status, campaign_limit, min_wait_time, timezone)
|
|
VALUES ($1, $2, $3, $4, 'Live', '', '', 'smtp_imap', 'active', 50, 600, $5)`,
|
|
f.mailbox, f.user, f.org, "live-"+f.mailbox.String()[:8]+"@test.local", timezone)
|
|
|
|
// An always-open campaign window, so anything the test observes about
|
|
// timing comes from the mailbox profile and not the campaign schedule.
|
|
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, status,
|
|
daily_limit, timezone, days, start_time, end_time, rotation_mode, updated_at, created_at)
|
|
VALUES ($1, $2, $3, 'Live Test', '', 'active', 50, 'UTC', 127, '00:00', '23:59',
|
|
'least_recently_used', NOW(), NOW())`, f.campaign, f.user, f.org)
|
|
|
|
seq := uuid.New()
|
|
exec(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
|
|
body_plain, body_html, wait_after, position, kind)
|
|
VALUES ($1, $2, $3, 'Step 1', 'Hi', 'Hello', '<p>Hello</p>', 0, 0, 'email')`, seq, f.campaign, f.org)
|
|
|
|
contact := uuid.New()
|
|
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields)
|
|
VALUES ($1, $2, $3, $4, 'Live', 'Contact', '', '', '{}')`,
|
|
contact, f.user, f.org, "lead-"+contact.String()[:8]+"@test.local")
|
|
exec(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 0)`, f.campaign, contact)
|
|
|
|
// Teardown in FK order, innermost first. Each statement carries ONLY the
|
|
// argument it uses: pgx rejects a call whose argument count does not match
|
|
// the placeholders, and the first version of this passed all four ids to
|
|
// every statement, so every delete failed — silently, because the errors
|
|
// were discarded. Surfacing them is what makes the leak impossible to miss.
|
|
t.Cleanup(func() {
|
|
c := context.Background()
|
|
steps := []struct {
|
|
sql string
|
|
arg any
|
|
}{
|
|
{`DELETE FROM campaign_tasks WHERE task_id IN (SELECT id FROM tasks WHERE email_account_id = $1)`, f.mailbox},
|
|
{`DELETE FROM tasks WHERE email_account_id = $1`, f.mailbox},
|
|
{`DELETE FROM campaign_contact_progress WHERE campaign_id = $1`, f.campaign},
|
|
{`DELETE FROM campaign_leads WHERE campaign_id = $1`, f.campaign},
|
|
{`DELETE FROM sequences WHERE campaign_id = $1`, f.campaign},
|
|
{`DELETE FROM campaigns WHERE id = $1`, f.campaign},
|
|
{`DELETE FROM email_account_daily_plan WHERE email_account_id = $1`, f.mailbox},
|
|
{`DELETE FROM email_account_behavior WHERE email_account_id = $1`, f.mailbox},
|
|
{`DELETE FROM email_accounts WHERE id = $1`, f.mailbox},
|
|
{`DELETE FROM contacts WHERE organization_id = $1`, f.org},
|
|
{`DELETE FROM organizations WHERE id = $1`, f.org},
|
|
{`DELETE FROM users WHERE id = $1`, f.user},
|
|
}
|
|
for _, step := range steps {
|
|
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
|
|
t.Errorf("cleanup %q: %v", step.sql, err)
|
|
}
|
|
}
|
|
})
|
|
return f
|
|
}
|
|
|
|
// setProfile writes a sending-behaviour profile and clears any plan already
|
|
// rolled for the mailbox, so the next scheduling pass rolls from these ranges.
|
|
func (f *liveFixture) setProfile(t *testing.T, b models.SendingBehavior) {
|
|
t.Helper()
|
|
if err := b.Validate(); err != nil {
|
|
t.Fatalf("profile is invalid before we even store it: %v", err)
|
|
}
|
|
repo := repository.NewBehaviorRepository(f.pool)
|
|
b.EmailAccountID = f.mailbox
|
|
if _, err := repo.UpsertBehavior(context.Background(), &b); err != nil {
|
|
t.Fatalf("upsert profile: %v", err)
|
|
}
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`DELETE FROM email_account_daily_plan WHERE email_account_id = $1`, f.mailbox); err != nil {
|
|
t.Fatalf("clear plans: %v", err)
|
|
}
|
|
}
|
|
|
|
func liveScheduler(t *testing.T, handle *db.DB, pool *pgxpool.Pool) SchedulerService {
|
|
t.Helper()
|
|
// Any 32-byte key works: these fixtures store no sealed credentials, and
|
|
// the scheduler never decrypts. It is only needed to build the repository.
|
|
enc, err := encrypt.NewEncrypter([]byte("0123456789abcdef0123456789abcdef"))
|
|
if err != nil {
|
|
t.Fatalf("encrypter: %v", err)
|
|
}
|
|
emailRepo := repository.NewEmailRepostory(handle, enc)
|
|
s := NewSchedulerService(
|
|
repository.NewTaskRepository(pool),
|
|
repository.NewWarmupRepository(pool),
|
|
repository.NewCampaignProgressRepository(pool),
|
|
emailRepo,
|
|
repository.NewCampaignRepostory(handle),
|
|
nil,
|
|
nil,
|
|
)
|
|
if aware, ok := s.(BehaviorAware); ok {
|
|
aware.WireBehavior(behavior.NewService(repository.NewBehaviorRepository(pool), emailRepo))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// scheduleSlot runs a scheduling pass and returns the computed slot. A pass
|
|
// whose slot sits beyond the not-due grace now (correctly) reports
|
|
// ErrCampaignDeferred instead of handing back a sendable pair; for these
|
|
// placement assertions the slot is what matters, so both outcomes pass.
|
|
func scheduleSlot(t *testing.T, s SchedulerService, campaign uuid.UUID) (time.Time, uuid.UUID) {
|
|
t.Helper()
|
|
at, _, accountID, err := s.CalculateNextCampaignTime(context.Background(), campaign)
|
|
if err != nil && !errors.Is(err, ErrCampaignDeferred) {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
return at, accountID
|
|
}
|
|
|
|
func liveProfile() models.SendingBehavior {
|
|
b := models.DefaultSendingBehavior(uuid.Nil)
|
|
b.Enabled = true
|
|
return b
|
|
}
|
|
|
|
// TestLiveCampaignSendLandsInsideTheRolledWorkday is the headline check: a real
|
|
// campaign, a real mailbox, the real scheduler, and a send that has to land
|
|
// inside the workday the mailbox rolled for itself.
|
|
func TestLiveCampaignSendLandsInsideTheRolledWorkday(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
// A narrow window that cannot be hit by accident: 15:20-15:25 to 16:00-16:05.
|
|
b := liveProfile()
|
|
b.WorkStartMin, b.WorkStartMax = 920, 925
|
|
b.WorkEndMin, b.WorkEndMax = 960, 965
|
|
b.LunchEnabled = false
|
|
b.Weekdays = models.BehaviorWeekdaysAll
|
|
f.setProfile(t, b)
|
|
|
|
at, accountID := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
if accountID != f.mailbox {
|
|
t.Fatalf("picked mailbox %s, want the fixture's %s", accountID, f.mailbox)
|
|
}
|
|
|
|
plan := behavior.RollPlan(withID(b, f.mailbox), behavior.PlanDateFor(at, time.UTC))
|
|
minute := behavior.MinuteOfDay(at, time.UTC)
|
|
if !plan.Contains(minute) {
|
|
t.Fatalf("send scheduled at %s (minute %d), outside the rolled workday %d-%d",
|
|
at.UTC().Format("2006-01-02 15:04:05"), minute, plan.WorkStartMinute, plan.WorkEndMinute)
|
|
}
|
|
t.Logf("scheduled %s — inside the rolled workday %02d:%02d-%02d:%02d",
|
|
at.UTC().Format("2006-01-02 15:04:05"),
|
|
plan.WorkStartMinute/60, plan.WorkStartMinute%60, plan.WorkEndMinute/60, plan.WorkEndMinute%60)
|
|
}
|
|
|
|
// TestLiveSendSkipsTheLunchBreak proves the break is a real hole in the day and
|
|
// not just a number in a table.
|
|
func TestLiveSendSkipsTheLunchBreak(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
// A 12:00-16:00 workday that is almost entirely break: lunch runs 12:02 to
|
|
// 15:57, leaving only a couple of minutes open at each end. A scheduler
|
|
// that ignored the break would land in the 235-minute hole rather than the
|
|
// ~5 minutes of real window.
|
|
b := liveProfile()
|
|
b.WorkStartMin, b.WorkStartMax = 720, 722
|
|
b.WorkEndMin, b.WorkEndMax = 960, 962
|
|
b.LunchEnabled = true
|
|
// 12:10-15:50, chosen so the break survives EVERY roll: its start is always
|
|
// after the latest possible workday start (722) and its end always before
|
|
// the earliest possible finish (960). RollPlan drops a break that does not
|
|
// clear both, which is correct but makes a knife-edge fixture flaky.
|
|
b.LunchEarliest, b.LunchLatest = 730, 730
|
|
b.LunchMinMinutes, b.LunchMaxMinutes = 220, 220
|
|
b.Weekdays = models.BehaviorWeekdaysAll
|
|
f.setProfile(t, b)
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
|
|
assertFuture(t, at)
|
|
|
|
plan := behavior.RollPlan(withID(b, f.mailbox), behavior.PlanDateFor(at, time.UTC))
|
|
if !plan.HasLunch() {
|
|
t.Fatal("fixture should have produced a break")
|
|
}
|
|
minute := behavior.MinuteOfDay(at, time.UTC)
|
|
if minute >= *plan.LunchStartMinute && minute < *plan.LunchEndMinute {
|
|
t.Fatalf("send scheduled at minute %d, inside the break %d-%d",
|
|
minute, *plan.LunchStartMinute, *plan.LunchEndMinute)
|
|
}
|
|
t.Logf("scheduled %s (minute %d), break is %d-%d — outside it",
|
|
at.UTC().Format("2006-01-02 15:04:05"), minute, *plan.LunchStartMinute, *plan.LunchEndMinute)
|
|
}
|
|
|
|
// TestLiveNonWorkingDayRollsForward proves the weekday mask moves a send to the
|
|
// next working day rather than sending anyway.
|
|
func TestLiveNonWorkingDayRollsForward(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
// Work only on the weekday two days from now, so today is never valid.
|
|
target := time.Now().UTC().AddDate(0, 0, 2).Weekday()
|
|
b := liveProfile()
|
|
b.LunchEnabled = false
|
|
b.Weekdays = 1 << ((int(target) + 6) % 7)
|
|
f.setProfile(t, b)
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
if at.UTC().Weekday() != target {
|
|
t.Fatalf("scheduled on %s, want the only working day %s (%s)",
|
|
at.UTC().Weekday(), target, at.UTC().Format("2006-01-02 15:04"))
|
|
}
|
|
t.Logf("scheduled %s — the only working day in the mask", at.UTC().Format("2006-01-02 15:04 Mon"))
|
|
}
|
|
|
|
// TestLiveTimezoneIsTheMailboxOwn proves the workday follows the mailbox's own
|
|
// clock rather than the server's.
|
|
func TestLiveTimezoneIsTheMailboxOwn(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
tokyo, err := time.LoadLocation("Asia/Tokyo")
|
|
if err != nil {
|
|
t.Skipf("tzdata unavailable: %v", err)
|
|
}
|
|
f := newLiveFixture(t, pool, "Asia/Tokyo")
|
|
|
|
b := liveProfile()
|
|
b.WorkStartMin, b.WorkStartMax = 600, 605 // 10:00-10:05 Tokyo
|
|
b.WorkEndMin, b.WorkEndMax = 660, 665 // 11:00-11:05 Tokyo
|
|
b.LunchEnabled = false
|
|
b.Weekdays = models.BehaviorWeekdaysAll
|
|
f.setProfile(t, b)
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
|
|
assertFuture(t, at)
|
|
|
|
local := behavior.MinuteOfDay(at, tokyo)
|
|
if local < 600 || local >= 665 {
|
|
t.Fatalf("scheduled at %s Tokyo (minute %d), outside the 10:00-11:05 Tokyo window",
|
|
at.In(tokyo).Format("2006-01-02 15:04"), local)
|
|
}
|
|
t.Logf("scheduled %s Tokyo (%s UTC) — the mailbox's own clock, not the server's",
|
|
at.In(tokyo).Format("15:04"), at.UTC().Format("15:04"))
|
|
}
|
|
|
|
// TestLiveDisabledProfileChangesNothing is the opt-out guarantee: a mailbox with
|
|
// no profile must schedule exactly as it did before this feature existed.
|
|
func TestLiveDisabledProfileChangesNothing(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
// A profile that WOULD force a narrow window, but switched off.
|
|
b := liveProfile()
|
|
b.Enabled = false
|
|
b.WorkStartMin, b.WorkStartMax = 920, 925
|
|
b.WorkEndMin, b.WorkEndMax = 960, 965
|
|
b.LunchEnabled = false
|
|
f.setProfile(t, b)
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
|
|
// With behaviour off the send is placed by the legacy path, which is free to
|
|
// land outside 15:20-16:05. The guarantee we assert is that nothing was
|
|
// persisted: a disabled profile must not roll or store a plan.
|
|
var plans int
|
|
if err := pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM email_account_daily_plan WHERE email_account_id = $1`, f.mailbox).Scan(&plans); err != nil {
|
|
t.Fatalf("count plans: %v", err)
|
|
}
|
|
if plans != 0 {
|
|
t.Fatalf("a disabled profile wrote %d plan rows; it must write none", plans)
|
|
}
|
|
t.Logf("scheduled %s with no plan persisted — legacy path intact", at.UTC().Format("2006-01-02 15:04"))
|
|
}
|
|
|
|
// assertFuture guards the invariant every one of these tests depends on: a
|
|
// scheduled slot must be in the future. A past instant fires immediately and
|
|
// silently defeats every window the profile describes, and the minute-of-day
|
|
// assertions below would still pass on one.
|
|
func assertFuture(t *testing.T, at time.Time) {
|
|
t.Helper()
|
|
if !at.After(time.Now().Add(-time.Second)) {
|
|
t.Fatalf("scheduled %s, which is in the past (now %s)",
|
|
at.UTC().Format("2006-01-02 15:04:05"), time.Now().UTC().Format("2006-01-02 15:04:05"))
|
|
}
|
|
}
|
|
|
|
func withID(b models.SendingBehavior, id uuid.UUID) models.SendingBehavior {
|
|
b.EmailAccountID = id
|
|
return b
|
|
}
|
|
|
|
// bookTask inserts a pending campaign task for the fixture's mailbox at a given
|
|
// instant. Pending tasks count against the plan's budgets, because a booked
|
|
// slot has already spent it even though the mail has not gone out yet.
|
|
func (f *liveFixture) bookTask(t *testing.T, at time.Time) {
|
|
t.Helper()
|
|
id := uuid.New()
|
|
if _, err := f.pool.Exec(context.Background(), `
|
|
INSERT INTO tasks (id, task_type, email_account_id, status, message_id, scheduled_at)
|
|
VALUES ($1, 'campaign', $2, 'pending', '', $3)`, id, f.mailbox, at); err != nil {
|
|
t.Fatalf("book task: %v", err)
|
|
}
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`INSERT INTO campaign_tasks (task_id, campaign_id) VALUES ($1, $2)`, id, f.campaign); err != nil {
|
|
t.Fatalf("book campaign task: %v", err)
|
|
}
|
|
}
|
|
|
|
// allDayProfile is an always-open workday, so the only thing that can move a
|
|
// send is a volume ceiling.
|
|
func allDayProfile() models.SendingBehavior {
|
|
b := liveProfile()
|
|
b.WorkStartMin, b.WorkStartMax = 0, 1
|
|
b.WorkEndMin, b.WorkEndMax = 1438, 1439
|
|
b.LunchEnabled = false
|
|
b.Weekdays = models.BehaviorWeekdaysAll
|
|
return b
|
|
}
|
|
|
|
// TestLiveHourlyCeilingPushesToALaterHour proves the hourly cap is a real gate
|
|
// and not just a stored number. Without it a mailbox could empty its whole day
|
|
// into one hour and then sit silent, which is a louder pattern than volume.
|
|
func TestLiveHourlyCeilingPushesToALaterHour(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
b := allDayProfile()
|
|
b.HourlyLimitMin, b.HourlyLimitMax = 1, 1 // one send per clock hour
|
|
b.DailyLimitMin, b.DailyLimitMax = 40, 40
|
|
f.setProfile(t, b)
|
|
|
|
// Spend this hour's single slot.
|
|
now := time.Now().UTC()
|
|
f.bookTask(t, now)
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
|
|
if at.UTC().Truncate(time.Hour).Equal(now.Truncate(time.Hour)) {
|
|
t.Fatalf("scheduled %s — same hour as the slot already booked (%s); the hourly ceiling did not bind",
|
|
at.UTC().Format("2006-01-02 15:04"), now.Format("15:04"))
|
|
}
|
|
t.Logf("hour %02d was full, next send placed at %s", now.Hour(), at.UTC().Format("2006-01-02 15:04"))
|
|
}
|
|
|
|
// TestLiveDailyCeilingPushesToTheNextDay proves the day's rolled cold budget is
|
|
// enforced, not decorative.
|
|
func TestLiveDailyCeilingPushesToTheNextDay(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
b := allDayProfile()
|
|
b.DailyLimitMin, b.DailyLimitMax = 1, 1 // one cold send per day
|
|
b.HourlyLimitMin, b.HourlyLimitMax = 50, 50
|
|
f.setProfile(t, b)
|
|
|
|
now := time.Now().UTC()
|
|
f.bookTask(t, now) // spends the whole day
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
|
|
if at.UTC().Format("2006-01-02") == now.Format("2006-01-02") {
|
|
t.Fatalf("scheduled %s — same day as the send already booked; the daily ceiling did not bind",
|
|
at.UTC().Format("2006-01-02 15:04"))
|
|
}
|
|
t.Logf("today's single slot was spent, next send placed on %s", at.UTC().Format("2006-01-02 15:04"))
|
|
}
|
|
|
|
// TestLiveGapIsDrawnFromTheProfile proves the send-to-send spacing comes from
|
|
// the profile band rather than the mailbox's fixed min_wait_time.
|
|
//
|
|
// The probe has to survive the ±20 minute jitter every campaign slot carries,
|
|
// so it uses a gap FAR ABOVE the fixed 600s: a one-hour band cannot produce a
|
|
// slot less than 40 minutes out, while the 600s path cannot produce one more
|
|
// than ~30 minutes out. The two are cleanly separable; a narrow band is not.
|
|
func TestLiveGapIsDrawnFromTheProfile(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
|
|
b := allDayProfile()
|
|
b.GapMinSeconds, b.GapMaxSeconds = 3600, 3600 // exactly one hour
|
|
b.DailyLimitMin, b.DailyLimitMax = 40, 40
|
|
b.HourlyLimitMin, b.HourlyLimitMax = 50, 50
|
|
f.setProfile(t, b)
|
|
|
|
// A send completed a moment ago, so the next slot is governed by the gap.
|
|
id := uuid.New()
|
|
if _, err := pool.Exec(context.Background(), `
|
|
INSERT INTO tasks (id, task_type, email_account_id, status, message_id, scheduled_at, completed_at)
|
|
VALUES ($1, 'campaign', $2, 'completed', '', NOW(), NOW())`, id, f.mailbox); err != nil {
|
|
t.Fatalf("seed completed task: %v", err)
|
|
}
|
|
|
|
at, _ := scheduleSlot(t, liveScheduler(t, handle, pool), f.campaign)
|
|
assertFuture(t, at)
|
|
|
|
gap := time.Until(at)
|
|
if gap < 40*time.Minute {
|
|
t.Fatalf("next send is only %s away; a one-hour profile gap cannot produce that, so the fixed 600s min_wait_time is still in charge",
|
|
gap.Round(time.Second))
|
|
}
|
|
t.Logf("next send %s away — the profile's one-hour band, not the mailbox's 600s gap", gap.Round(time.Second))
|
|
}
|
|
|
|
// TestLiveFollowUpWaitIsHonored is the regression check for follow-ups riding
|
|
// an early tick (issue #171 follow-up): once step 1 is sent, a scheduling pass
|
|
// that runs immediately afterwards must NOT hand back the "wait 3 days" step as
|
|
// sendable. It must report the campaign deferred, with the slot parked at the
|
|
// follow-up's real time, so the task handler reschedules instead of sending a
|
|
// three-day follow-up seconds after step one.
|
|
func TestLiveFollowUpWaitIsHonored(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
ctx := context.Background()
|
|
|
|
step2 := uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
|
|
body_plain, body_html, wait_after, position, kind)
|
|
VALUES ($1, $2, $3, 'Step 2', 'Bump', 'Bump', '<p>Bump</p>', 3, 1, 'email')`,
|
|
step2, f.campaign, f.org); err != nil {
|
|
t.Fatalf("insert step 2: %v", err)
|
|
}
|
|
|
|
var step1, contact uuid.UUID
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT id FROM sequences WHERE campaign_id = $1 AND position = 0`, f.campaign).Scan(&step1); err != nil {
|
|
t.Fatalf("load step 1: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT contact_id FROM campaign_leads WHERE campaign_id = $1`, f.campaign).Scan(&contact); err != nil {
|
|
t.Fatalf("load contact: %v", err)
|
|
}
|
|
|
|
// Connect step 1 -> step 2 (routing has no implicit next step; the wizard
|
|
// writes this catch-all branch at creation).
|
|
if _, err := pool.Exec(ctx, `
|
|
UPDATE sequences SET conditions = jsonb_build_object('branches', jsonb_build_array(
|
|
jsonb_build_object('branch_id', 'live-else', 'target_step_id', $1::text)))
|
|
WHERE campaign_id = $2 AND position = 0`, step2.String(), f.campaign); err != nil {
|
|
t.Fatalf("connect steps: %v", err)
|
|
}
|
|
|
|
// Step 1 went out moments ago; routing now points at the wait-gated step 2.
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
|
|
VALUES ($1, $2, $3, NOW())`, f.campaign, contact, step1); err != nil {
|
|
t.Fatalf("stamp step 1 sent: %v", err)
|
|
}
|
|
|
|
at, pair, _, err := liveScheduler(t, handle, pool).CalculateNextCampaignTime(ctx, f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) {
|
|
t.Fatalf("want ErrCampaignDeferred for a not-yet-due follow-up, got pair=%v err=%v at=%s", pair, err, at)
|
|
}
|
|
if pair != nil {
|
|
t.Fatal("a deferred result must not carry a sendable pair")
|
|
}
|
|
if time.Until(at) < 47*time.Hour {
|
|
t.Fatalf("deferred slot %s is too soon for a 3-day wait", at)
|
|
}
|
|
t.Logf("follow-up correctly deferred to %s", at.UTC().Format("2006-01-02 15:04:05"))
|
|
}
|
|
|
|
// TestLiveWaitingFollowUpDoesNotBlockOtherLeads is the multi-lead stall from
|
|
// issue #171: lead A got step 1 and is routed to a "wait 3 days" follow-up,
|
|
// lead B has never been sent. B's first email is due now and must be picked,
|
|
// instead of the campaign parking on A's follow-up with B queued behind it.
|
|
func TestLiveWaitingFollowUpDoesNotBlockOtherLeads(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
ctx := context.Background()
|
|
|
|
step2 := uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
|
|
body_plain, body_html, wait_after, position, kind)
|
|
VALUES ($1, $2, $3, 'Step 2', 'Bump', 'Bump', '<p>Bump</p>', 3, 1, 'email')`,
|
|
step2, f.campaign, f.org); err != nil {
|
|
t.Fatalf("insert step 2: %v", err)
|
|
}
|
|
var step1, leadA uuid.UUID
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT id FROM sequences WHERE campaign_id = $1 AND position = 0`, f.campaign).Scan(&step1); err != nil {
|
|
t.Fatalf("load step 1: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT contact_id FROM campaign_leads WHERE campaign_id = $1`, f.campaign).Scan(&leadA); err != nil {
|
|
t.Fatalf("load lead A: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
UPDATE sequences SET conditions = jsonb_build_object('branches', jsonb_build_array(
|
|
jsonb_build_object('branch_id', 'live-else', 'target_step_id', $1::text)))
|
|
WHERE campaign_id = $2 AND position = 0`, step2.String(), f.campaign); err != nil {
|
|
t.Fatalf("connect steps: %v", err)
|
|
}
|
|
|
|
// Lead B joins after A (created_at ordering puts A first) and has never
|
|
// been sent anything.
|
|
leadB := uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, created_at)
|
|
VALUES ($1, $2, $3, $4, 'Live', 'Second', '', '', '{}', NOW() + interval '1 second')`,
|
|
leadB, f.user, f.org, "lead-"+leadB.String()[:8]+"@test.local"); err != nil {
|
|
t.Fatalf("insert lead B: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 1)`,
|
|
f.campaign, leadB); err != nil {
|
|
t.Fatalf("add lead B: %v", err)
|
|
}
|
|
|
|
// A's step 1 went out moments ago; A now routes to the wait-gated step 2.
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
|
|
VALUES ($1, $2, $3, NOW())`, f.campaign, leadA, step1); err != nil {
|
|
t.Fatalf("stamp A step 1: %v", err)
|
|
}
|
|
|
|
s := liveScheduler(t, handle, pool)
|
|
at, pair, _, err := s.CalculateNextCampaignTime(ctx, f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("want B's first step now, got err=%v at=%s", err, at)
|
|
}
|
|
if pair == nil || pair.ContactID != leadB || pair.SequenceID != step1 || !pair.IsNewLead {
|
|
t.Fatalf("want lead B / step 1 as a new lead, got %+v", pair)
|
|
}
|
|
|
|
// Once B has had step 1 too, nothing is due for 3 days: the campaign
|
|
// defers to then instead of completing or sending a follow-up early.
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
|
|
VALUES ($1, $2, $3, NOW())`, f.campaign, leadB, step1); err != nil {
|
|
t.Fatalf("stamp B step 1: %v", err)
|
|
}
|
|
at, pair, _, err = s.CalculateNextCampaignTime(ctx, f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) || pair != nil {
|
|
t.Fatalf("want ErrCampaignDeferred with no pair, got pair=%v err=%v", pair, err)
|
|
}
|
|
if until := time.Until(at); until < 71*time.Hour || until > 73*time.Hour {
|
|
t.Fatalf("deferred slot %s is not ~3 days out", at)
|
|
}
|
|
}
|
|
|
|
// TestLiveWaitNodeGatesTheStepAfterIt: a contact who passed through a "wait 90
|
|
// minutes" node must not be routed onward by a tick that fires before the
|
|
// delay elapses (multi-lead campaigns tick constantly).
|
|
func TestLiveWaitNodeGatesTheStepAfterIt(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
ctx := context.Background()
|
|
|
|
waitNode, step2 := uuid.New(), uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
|
|
body_plain, body_html, wait_after, position, kind, action, conditions)
|
|
VALUES ($1, $2, $3, 'Wait', '', '', '', 0, 1, 'wait',
|
|
'{"type":"wait","wait_minutes":90}',
|
|
jsonb_build_object('branches', jsonb_build_array(
|
|
jsonb_build_object('branch_id', 'live-else', 'target_step_id', $5::text)))),
|
|
($4, $2, $3, 'Step 2', 'Bump', 'Bump', '<p>Bump</p>', 0, 2, 'email', '{}', '{}')`,
|
|
waitNode, f.campaign, f.org, step2, step2.String()); err != nil {
|
|
t.Fatalf("insert wait node + step 2: %v", err)
|
|
}
|
|
var contact uuid.UUID
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT contact_id FROM campaign_leads WHERE campaign_id = $1`, f.campaign).Scan(&contact); err != nil {
|
|
t.Fatalf("load contact: %v", err)
|
|
}
|
|
// step 1 -> wait -> step 2; the contact just passed the wait node.
|
|
if _, err := pool.Exec(ctx, `
|
|
UPDATE sequences SET conditions = jsonb_build_object('branches', jsonb_build_array(
|
|
jsonb_build_object('branch_id', 'live-else', 'target_step_id', $1::text)))
|
|
WHERE campaign_id = $2 AND position = 0`, waitNode.String(), f.campaign); err != nil {
|
|
t.Fatalf("connect steps: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
|
|
VALUES ($1, $2, (SELECT id FROM sequences WHERE campaign_id = $1 AND position = 0), NOW() - interval '1 minute'),
|
|
($1, $2, $3, NOW())`, f.campaign, contact, waitNode); err != nil {
|
|
t.Fatalf("stamp progress: %v", err)
|
|
}
|
|
|
|
at, pair, _, err := liveScheduler(t, handle, pool).CalculateNextCampaignTime(ctx, f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) || pair != nil {
|
|
t.Fatalf("want ErrCampaignDeferred with no pair while the wait runs, got pair=%v err=%v", pair, err)
|
|
}
|
|
if until := time.Until(at); until < 85*time.Minute || until > 95*time.Minute {
|
|
t.Fatalf("deferred slot %s is not ~90 minutes out", at)
|
|
}
|
|
}
|
|
|
|
// TestLiveInFlightSendIsNotOfferedAgain is issue #169 at the scheduler level:
|
|
// lead A's first email is on the bus but its sent_at stamp never landed (the
|
|
// tick died, or the progress write failed). The scheduler must move on to lead
|
|
// B instead of handing A's step back and emailing them twice.
|
|
func TestLiveInFlightSendIsNotOfferedAgain(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
ctx := context.Background()
|
|
|
|
var step1, leadA uuid.UUID
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT id FROM sequences WHERE campaign_id = $1 AND position = 0`, f.campaign).Scan(&step1); err != nil {
|
|
t.Fatalf("load step 1: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT contact_id FROM campaign_leads WHERE campaign_id = $1`, f.campaign).Scan(&leadA); err != nil {
|
|
t.Fatalf("load lead A: %v", err)
|
|
}
|
|
|
|
leadB := uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, created_at)
|
|
VALUES ($1, $2, $3, $4, 'Live', 'Second', '', '', '{}', NOW() + interval '1 second')`,
|
|
leadB, f.user, f.org, "lead-"+leadB.String()[:8]+"@test.local"); err != nil {
|
|
t.Fatalf("insert lead B: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 1)`,
|
|
f.campaign, leadB); err != nil {
|
|
t.Fatalf("add lead B: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM campaign_leads WHERE contact_id = $1`, leadB)
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM contacts WHERE id = $1`, leadB)
|
|
})
|
|
|
|
// A's send was reserved and dispatched; the stamp never landed.
|
|
progress := repository.NewCampaignProgressRepository(pool)
|
|
reserved, err := progress.ReserveSend(ctx, f.campaign, leadA, step1, uuid.New(), true)
|
|
if err != nil || !reserved {
|
|
t.Fatalf("reserve A's send: reserved=%v err=%v", reserved, err)
|
|
}
|
|
|
|
s := liveScheduler(t, handle, pool)
|
|
_, pair, _, err := s.CalculateNextCampaignTime(ctx, f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("want lead B's first step, got err=%v", err)
|
|
}
|
|
if pair == nil || pair.ContactID != leadB {
|
|
t.Fatalf("want lead B, got %+v (A's in-flight send was offered again: issue #169)", pair)
|
|
}
|
|
|
|
// With B dispatched too, nothing is left: the campaign completes rather than
|
|
// re-offering either in-flight step.
|
|
if reserved, err := progress.ReserveSend(ctx, f.campaign, leadB, step1, uuid.New(), true); err != nil || !reserved {
|
|
t.Fatalf("reserve B's send: reserved=%v err=%v", reserved, err)
|
|
}
|
|
if _, pair, _, err = s.CalculateNextCampaignTime(ctx, f.campaign); !errors.Is(err, ErrCampaignCompleted) || pair != nil {
|
|
t.Fatalf("want ErrCampaignCompleted with no pair once both sends are in flight, got pair=%v err=%v", pair, err)
|
|
}
|
|
}
|
|
|
|
// TestLiveInFlightFollowUpIsNotOfferedAgain covers the same window one step
|
|
// further in: the follow-up, not the first email, is the one whose stamp was
|
|
// lost. Routing reaches it through the branch, so the loop guard is what has to
|
|
// stop it.
|
|
func TestLiveInFlightFollowUpIsNotOfferedAgain(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newLiveFixture(t, pool, "UTC")
|
|
ctx := context.Background()
|
|
|
|
step2 := uuid.New()
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
|
|
body_plain, body_html, wait_after, position, kind)
|
|
VALUES ($1, $2, $3, 'Step 2', 'Bump', 'Bump', '<p>Bump</p>', 0, 1, 'email')`,
|
|
step2, f.campaign, f.org); err != nil {
|
|
t.Fatalf("insert step 2: %v", err)
|
|
}
|
|
var step1, contact uuid.UUID
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT id FROM sequences WHERE campaign_id = $1 AND position = 0`, f.campaign).Scan(&step1); err != nil {
|
|
t.Fatalf("load step 1: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT contact_id FROM campaign_leads WHERE campaign_id = $1`, f.campaign).Scan(&contact); err != nil {
|
|
t.Fatalf("load contact: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
UPDATE sequences SET conditions = jsonb_build_object('branches', jsonb_build_array(
|
|
jsonb_build_object('branch_id', 'live-else', 'target_step_id', $1::text)))
|
|
WHERE campaign_id = $2 AND position = 0`, step2.String(), f.campaign); err != nil {
|
|
t.Fatalf("connect steps: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, dispatched_at)
|
|
VALUES ($1, $2, $3, NOW(), NOW())`, f.campaign, contact, step1); err != nil {
|
|
t.Fatalf("stamp step 1 sent: %v", err)
|
|
}
|
|
|
|
progress := repository.NewCampaignProgressRepository(pool)
|
|
s := liveScheduler(t, handle, pool)
|
|
|
|
// The follow-up is due now (wait_after 0) and would be sent...
|
|
if _, pair, _, err := s.CalculateNextCampaignTime(ctx, f.campaign); err != nil || pair == nil || pair.SequenceID != step2 {
|
|
t.Fatalf("precondition: step 2 should be due, got pair=%v err=%v", pair, err)
|
|
}
|
|
// ...so dispatch it, and lose the stamp.
|
|
if reserved, err := progress.ReserveSend(ctx, f.campaign, contact, step2, uuid.New(), false); err != nil || !reserved {
|
|
t.Fatalf("reserve the follow-up: reserved=%v err=%v", reserved, err)
|
|
}
|
|
if _, pair, _, err := s.CalculateNextCampaignTime(ctx, f.campaign); !errors.Is(err, ErrCampaignCompleted) || pair != nil {
|
|
t.Fatalf("the in-flight follow-up was offered again: pair=%v err=%v", pair, err)
|
|
}
|
|
}
|