mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-20 00:03:07 +00:00
346 lines
14 KiB
Go
346 lines
14 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Live checks for the campaign entry delay: a contact's FIRST email waits
|
|
// entry_delay_minutes after they entered the campaign. Skipped unless
|
|
// WARMBLY_TEST_DB is set; run against a migrated database with:
|
|
//
|
|
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
|
// go test ./internal/scheduler/ -run TestLiveEntryDelay -v
|
|
//
|
|
// These need the real routing SQL: the delay is applied inside the finder's
|
|
// per-lead due check, which every unit test in this package stubs out.
|
|
|
|
// entryDelayFixture is an org/mailbox/campaign/contact graph with one lead whose
|
|
// entry time and the campaign's delay are both under the test's control.
|
|
type entryDelayFixture struct {
|
|
pool *pgxpool.Pool
|
|
user uuid.UUID
|
|
org uuid.UUID
|
|
mailbox uuid.UUID
|
|
campaign uuid.UUID
|
|
contact uuid.UUID
|
|
step uuid.UUID
|
|
}
|
|
|
|
func newEntryDelayFixture(t *testing.T, pool *pgxpool.Pool, delayMinutes int) *entryDelayFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
f := &entryDelayFixture{
|
|
pool: pool, user: uuid.New(), org: uuid.New(), mailbox: uuid.New(),
|
|
campaign: uuid.New(), contact: uuid.New(), step: 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, 'Entry', 'Delay')`, f.user, "entry-"+f.user.String()[:8]+"@test.local")
|
|
exec(`INSERT INTO organizations (id, name, slug, owner_user_id)
|
|
VALUES ($1, 'Entry Delay', $2, $3)`, f.org, "entry-"+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, 'Entry', '', '', 'smtp_imap', 'active', 50, 0, 'UTC')`,
|
|
f.mailbox, f.user, f.org, "entry-"+f.mailbox.String()[:8]+"@test.local")
|
|
// An always-open window, so the only thing that can hold the send back is
|
|
// the entry delay under test.
|
|
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, status,
|
|
daily_limit, timezone, days, start_time, end_time, rotation_mode,
|
|
entry_delay_minutes, updated_at, created_at)
|
|
VALUES ($1, $2, $3, 'Entry Delay', '', 'active', 50, 'UTC', 127, '00:00', '23:59',
|
|
'least_recently_used', $4, NOW(), NOW())`, f.campaign, f.user, f.org, delayMinutes)
|
|
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')`,
|
|
f.step, f.campaign, f.org)
|
|
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields)
|
|
VALUES ($1, $2, $3, $4, 'Entry', 'Contact', '', '', '{}')`,
|
|
f.contact, f.user, f.org, "lead-"+f.contact.String()[:8]+"@test.local")
|
|
exec(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 0)`, f.campaign, f.contact)
|
|
|
|
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 campaign_logs WHERE campaign_id = $1`, f.campaign},
|
|
{`DELETE FROM campaign_daily_sends 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
|
|
}
|
|
|
|
// enteredAt moves when the lead entered the campaign. A nil value writes NULL,
|
|
// which is what a lead added before the column existed looks like.
|
|
func (f *entryDelayFixture) enteredAt(t *testing.T, at *time.Time) {
|
|
t.Helper()
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`UPDATE campaign_leads SET added_at = $3 WHERE campaign_id = $1 AND contact_id = $2`,
|
|
f.campaign, f.contact, at); err != nil {
|
|
t.Fatalf("set added_at: %v", err)
|
|
}
|
|
}
|
|
|
|
func (f *entryDelayFixture) campaignCreatedAt(t *testing.T, at time.Time) {
|
|
t.Helper()
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`UPDATE campaigns SET created_at = $2 WHERE id = $1`, f.campaign, at); err != nil {
|
|
t.Fatalf("set created_at: %v", err)
|
|
}
|
|
}
|
|
|
|
func (f *entryDelayFixture) route(t *testing.T) *repository.ContactRoute {
|
|
t.Helper()
|
|
route, err := repository.NewCampaignProgressRepository(f.pool).
|
|
RouteContact(context.Background(), f.campaign, f.contact)
|
|
if err != nil {
|
|
t.Fatalf("route contact: %v", err)
|
|
}
|
|
return route
|
|
}
|
|
|
|
// TestLiveEntryDelayHoldsTheFirstEmail is the headline check: with a two-day
|
|
// delay and a lead that entered a moment ago, the scheduler must refuse to hand
|
|
// back a sendable pair and must report the moment the email becomes due.
|
|
func TestLiveEntryDelayHoldsTheFirstEmail(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newEntryDelayFixture(t, pool, 2*24*60)
|
|
entered := time.Now().UTC().Add(-time.Minute)
|
|
f.enteredAt(t, &entered)
|
|
|
|
s := liveScheduler(t, handle, pool)
|
|
at, pair, _, err := s.CalculateNextCampaignTime(context.Background(), f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) {
|
|
t.Fatalf("want ErrCampaignDeferred while the entry delay is running, got pair=%v err=%v", pair, err)
|
|
}
|
|
if pair != nil {
|
|
t.Fatalf("a deferred pass must never hand back a sendable pair, got %+v", pair)
|
|
}
|
|
want := entered.Add(48 * time.Hour)
|
|
if at.Sub(want).Abs() > time.Minute {
|
|
t.Fatalf("re-check at %s, want the delay's end %s", at.UTC(), want)
|
|
}
|
|
|
|
route := f.route(t)
|
|
if route.Target == nil || *route.Target != f.step {
|
|
t.Fatalf("routing should still point at the first step, got %v", route.Target)
|
|
}
|
|
if route.DueAt == nil || route.DueAt.Sub(want).Abs() > time.Minute {
|
|
t.Fatalf("due at %v, want %s", route.DueAt, want)
|
|
}
|
|
|
|
pv, perr := s.(ContactSendPreviewer).PreviewContactSend(context.Background(), f.campaign, f.contact)
|
|
if perr != nil {
|
|
t.Fatalf("preview: %v", perr)
|
|
}
|
|
if pv.Constraint != ConstraintEntryDelay {
|
|
t.Fatalf("preview constraint %q, want %q", pv.Constraint, ConstraintEntryDelay)
|
|
}
|
|
// The preview reports the earliest REAL slot, so it is the delay's end
|
|
// pushed forward by the mailbox's own hours and the day's pacing — never
|
|
// anything before it.
|
|
if pv.NotBefore == nil || pv.NotBefore.Before(want.Add(-time.Minute)) {
|
|
t.Fatalf("preview not-before %v, want the delay's end (%s) or later", pv.NotBefore, want)
|
|
}
|
|
t.Logf("held until %s, %s after the contact entered", at.UTC().Format(time.RFC3339), 48*time.Hour)
|
|
}
|
|
|
|
// TestLiveEntryDelayReleasesAfterItElapses proves the same campaign sends once
|
|
// the delay has passed for that contact — the delay is per lead, not per campaign.
|
|
func TestLiveEntryDelayReleasesAfterItElapses(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newEntryDelayFixture(t, pool, 2*24*60)
|
|
entered := time.Now().UTC().Add(-72 * time.Hour)
|
|
f.enteredAt(t, &entered)
|
|
|
|
_, pair, accountID, err := liveScheduler(t, handle, pool).
|
|
CalculateNextCampaignTime(context.Background(), f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
if pair == nil || pair.ContactID != f.contact || pair.SequenceID != f.step {
|
|
t.Fatalf("want the fixture's first step for the lead, got %+v", pair)
|
|
}
|
|
if !pair.IsNewLead {
|
|
t.Fatal("the first step of a lead that has never been sent is a new lead")
|
|
}
|
|
if accountID != f.mailbox {
|
|
t.Fatalf("picked mailbox %s, want %s", accountID, f.mailbox)
|
|
}
|
|
}
|
|
|
|
// TestLiveEntryDelayIgnoredWithoutOne is the regression guard for every existing
|
|
// campaign: with no delay set, a lead that entered a moment ago is due now.
|
|
func TestLiveEntryDelayIgnoredWithoutOne(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newEntryDelayFixture(t, pool, 0)
|
|
|
|
// Deliberately does NOT write added_at: the fixture inserts the lead the
|
|
// way every production path does (column omitted), so this also proves the
|
|
// column's now() default fires instead of leaving a NULL behind.
|
|
var addedAt *time.Time
|
|
if err := pool.QueryRow(context.Background(),
|
|
`SELECT added_at FROM campaign_leads WHERE campaign_id = $1 AND contact_id = $2`,
|
|
f.campaign, f.contact).Scan(&addedAt); err != nil {
|
|
t.Fatalf("read added_at: %v", err)
|
|
}
|
|
if addedAt == nil || time.Since(*addedAt) > time.Minute {
|
|
t.Fatalf("added_at = %v, want the insert's own timestamp", addedAt)
|
|
}
|
|
|
|
_, pair, _, err := liveScheduler(t, handle, pool).
|
|
CalculateNextCampaignTime(context.Background(), f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
if pair == nil {
|
|
t.Fatal("a campaign with no entry delay must send a brand-new lead immediately")
|
|
}
|
|
if route := f.route(t); route.DueAt != nil {
|
|
t.Fatalf("no delay means no due-at, got %s", route.DueAt)
|
|
}
|
|
}
|
|
|
|
// TestLiveEntryDelayFallsBackToCampaignCreation covers the leads that predate
|
|
// the added_at column (NULL): they count from the campaign's own creation time,
|
|
// so turning a delay on never re-delays a lead enrolled weeks ago.
|
|
func TestLiveEntryDelayFallsBackToCampaignCreation(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
f := newEntryDelayFixture(t, pool, 2*24*60)
|
|
f.enteredAt(t, nil)
|
|
f.campaignCreatedAt(t, time.Now().UTC().Add(-30*24*time.Hour))
|
|
|
|
_, pair, _, err := liveScheduler(t, handle, pool).
|
|
CalculateNextCampaignTime(context.Background(), f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
if pair == nil {
|
|
t.Fatal("a lead with no recorded entry time and a month-old campaign is past any 2-day delay")
|
|
}
|
|
|
|
// The same NULL on a campaign created just now is still inside the delay.
|
|
f.campaignCreatedAt(t, time.Now().UTC())
|
|
_, pair, _, err = liveScheduler(t, handle, pool).
|
|
CalculateNextCampaignTime(context.Background(), f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) || pair != nil {
|
|
t.Fatalf("want the delay to hold from a fresh campaign's creation time, got pair=%v err=%v", pair, err)
|
|
}
|
|
}
|
|
|
|
// TestLiveEntryDelayOutlivesTodaysNewLeadCap is the completion guard. With the
|
|
// daily new-lead cap already spent, the finder skips new leads BEFORE routing
|
|
// them, so a delayed lead contributes no re-check time to that pass. Without the
|
|
// unexcluded pass handing its own re-check back, a campaign whose only remaining
|
|
// leads are inside the entry delay reports itself COMPLETE and stops.
|
|
func TestLiveEntryDelayOutlivesTodaysNewLeadCap(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
ctx := context.Background()
|
|
f := newEntryDelayFixture(t, pool, 2*24*60)
|
|
entered := time.Now().UTC()
|
|
f.enteredAt(t, &entered)
|
|
|
|
// A cap of one, already spent for today, so the delayed lead is the only
|
|
// one left and the finder's excluded pass skips it before routing it.
|
|
if _, err := pool.Exec(ctx, `UPDATE campaigns SET max_new_leads_per_day = 1 WHERE id = $1`, f.campaign); err != nil {
|
|
t.Fatalf("set cap: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `INSERT INTO campaign_daily_sends (campaign_id, send_date, emails_sent, new_leads_started)
|
|
VALUES ($1, CURRENT_DATE, 1, 1)`, f.campaign); err != nil {
|
|
t.Fatalf("spend the cap: %v", err)
|
|
}
|
|
|
|
at, pair, _, err := liveScheduler(t, handle, pool).CalculateNextCampaignTime(ctx, f.campaign)
|
|
if !errors.Is(err, ErrCampaignDeferred) {
|
|
t.Fatalf("want a deferral while a delayed lead is still waiting, got pair=%v err=%v", pair, err)
|
|
}
|
|
if want := entered.Add(48 * time.Hour); at.Sub(want).Abs() > time.Minute {
|
|
t.Fatalf("re-check at %s, want the delayed lead's due time %s", at.UTC(), want)
|
|
}
|
|
}
|
|
|
|
// TestLiveEntryDelayDoesNotBlockDueFollowUps proves the delay applies to the
|
|
// FIRST step only: a lead already inside the sequence keeps moving on its own
|
|
// step waits while a brand-new lead is still held.
|
|
func TestLiveEntryDelayDoesNotBlockDueFollowUps(t *testing.T) {
|
|
handle, pool := liveDB(t)
|
|
ctx := context.Background()
|
|
f := newEntryDelayFixture(t, pool, 7*24*60)
|
|
fresh := time.Now().UTC()
|
|
f.enteredAt(t, &fresh)
|
|
|
|
// A second step, connected from the first with no wait, and a second lead
|
|
// who already received step one.
|
|
followUp := 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', 'Again', 'Hello again', '<p>Hello again</p>', 0, 1, 'email')`,
|
|
followUp, f.campaign, f.org); err != nil {
|
|
t.Fatalf("follow-up step: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE sequences SET conditions = $2 WHERE id = $1`, f.step,
|
|
`{"branches":[{"branch_id":"b1","target_step_id":"`+followUp.String()+`"}]}`); err != nil {
|
|
t.Fatalf("connect steps: %v", err)
|
|
}
|
|
|
|
veteran := uuid.New()
|
|
if _, err := pool.Exec(ctx, `INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields)
|
|
VALUES ($1, $2, $3, $4, 'Veteran', 'Lead', '', '', '{}')`,
|
|
veteran, f.user, f.org, "vet-"+veteran.String()[:8]+"@test.local"); err != nil {
|
|
t.Fatalf("veteran contact: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `INSERT INTO campaign_leads (campaign_id, contact_id, position, added_at)
|
|
VALUES ($1, $2, 1, NOW() - INTERVAL '10 days')`, f.campaign, veteran); err != nil {
|
|
t.Fatalf("veteran lead: %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() - INTERVAL '5 days', NOW() - INTERVAL '5 days')`,
|
|
f.campaign, veteran, f.step); err != nil {
|
|
t.Fatalf("veteran progress: %v", err)
|
|
}
|
|
|
|
_, pair, _, err := liveScheduler(t, handle, pool).CalculateNextCampaignTime(ctx, f.campaign)
|
|
if err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
if pair == nil {
|
|
t.Fatal("the due follow-up must still be sendable while a new lead waits out the entry delay")
|
|
}
|
|
if pair.ContactID != veteran || pair.SequenceID != followUp {
|
|
t.Fatalf("want the veteran's follow-up, got %+v", pair)
|
|
}
|
|
}
|