Files
warmbly/internal/tasks/campaign_task_live_test.go
T
Matthew Meszaros 222c9d2554 feat: scope campaign sender resolution to the campaign's organization instead of its owner so a multi-org user can no longer send organization A's campaign from an organization B mailbox: GetByTags/GetAllActiveInScope/GetByCampaignSenders now take a repository.AccountScope keyed on organization_id where a scope with no organization resolves to no mailboxes rather than widening to the owner (tags carry no organization of their own, so one user's tag legitimately spans workspaces and the predicate is the only thing holding the boundary), the campaign scheduler and the preflight tracking-domain check build that scope from campaign.OrganizationID, unibox compose scores only the current workspace's mailboxes, the 'all' lanes of AccountHasActiveCampaign/CountActiveCampaignsForAccount join ea.organization_id = c.organization_id instead of ea.user_id = c.user_id, dead and broken PauseAllByUserID (which wrote the reason string into status and had no callers) is removed, the campaigns/unibox guides now state that senders resolve inside the campaign's workspace, and TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient is updated because an orgless campaign now finds no senders before routing is consulted while the send gate it covers still refuses; live-tested in TestLiveSenderResolutionStaysInsideTheCampaignOrg, TestLiveSenderSchedulerNeverPicksAnotherOrgMailbox, TestLiveSenderSchedulerPicksTheCampaignOrgMailbox, TestLiveSenderScopeWithoutAnOrganizationReachesNothing and TestLiveActiveCampaignLookupIsOrgScoped
2026-08-24 09:20:41 -07:00

425 lines
16 KiB
Go

package tasks
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/app/cipher"
"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"
"github.com/warmbly/warmbly/internal/scheduler"
"github.com/warmbly/warmbly/internal/tasks/proto"
)
// Live checks of the campaign send path against a real Postgres. Skipped unless
// WARMBLY_TEST_DB is set (same convention as internal/scheduler):
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
// go test ./internal/tasks/ -run Live -v
//
// What is worth proving here is that recipient suppression cannot be bypassed:
// it is the control that stops mail to an address that unsubscribed, bounced or
// complained, and it used to be skipped in full when the campaign had no
// organization (issue #168). That only holds against the real query paths.
func liveCampaignDB(t *testing.T) *db.DB {
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() })
return handle
}
// campaignSendFixture is one org/user/mailbox/campaign/step/lead graph, ready
// for a single campaign tick.
type campaignSendFixture struct {
pool *pgxpool.Pool
user, org, mailbox, campaign, step, contact uuid.UUID
contactEmail string
}
func newCampaignSendFixture(t *testing.T, pool *pgxpool.Pool) *campaignSendFixture {
t.Helper()
ctx := context.Background()
f := &campaignSendFixture{
pool: pool, user: uuid.New(), org: uuid.New(), mailbox: uuid.New(),
campaign: uuid.New(), step: uuid.New(), contact: uuid.New(),
}
f.contactEmail = "lead-" + f.contact.String()[:8] + "@test.local"
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, 0, 'UTC')`,
f.mailbox, f.user, f.org, "live-"+f.mailbox.String()[:8]+"@test.local")
// An always-open window so nothing the test observes comes from scheduling.
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)
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, 'Live', 'Contact', '', '', '{}')`,
f.contact, f.user, f.org, f.contactEmail)
exec(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 0)`, f.campaign, f.contact)
t.Cleanup(func() {
c := context.Background()
for _, step := range []struct {
sql string
arg any
}{
{`DELETE FROM task_execution_keys WHERE task_id IN (SELECT id FROM tasks WHERE email_account_id = $1)`, f.mailbox},
{`DELETE FROM task_failures WHERE task_id IN (SELECT id FROM tasks WHERE email_account_id = $1)`, f.mailbox},
{`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_logs WHERE campaign_id = $1`, f.campaign},
{`DELETE FROM campaign_daily_sends WHERE campaign_id = $1`, f.campaign},
{`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 suppressed_recipients WHERE organization_id = $1`, f.org},
{`DELETE FROM campaigns WHERE id = $1`, f.campaign},
{`DELETE FROM email_accounts WHERE id = $1`, f.mailbox},
{`DELETE FROM contacts WHERE organization_id = $1`, f.org},
{`DELETE FROM crm_task_types WHERE organization_id = $1`, f.org},
{`DELETE FROM organizations WHERE id = $1`, f.org},
{`DELETE FROM users WHERE id = $1`, f.user},
} {
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
t.Errorf("cleanup %q: %v", step.sql, err)
}
}
})
return f
}
// suppress puts the fixture's lead on the organization's suppression list, the
// state a bounce, complaint or unsubscribe leaves behind.
func (f *campaignSendFixture) suppress(t *testing.T, reason string) {
t.Helper()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO suppressed_recipients (organization_id, email, reason, source)
VALUES ($1, $2, $3, 'unsubscribe')`, f.org, f.contactEmail, reason); err != nil {
t.Fatalf("suppress: %v", err)
}
}
// dropOrganization detaches the campaign from its workspace, the pre-migration
// state issue #168 reports. campaigns.organization_id is NOT NULL since
// migration 000092, so the constraint is lifted for the duration of the test
// and restored before anything else runs.
func (f *campaignSendFixture) dropOrganization(t *testing.T) {
t.Helper()
ctx := context.Background()
if _, err := f.pool.Exec(ctx, `ALTER TABLE campaigns ALTER COLUMN organization_id DROP NOT NULL`); err != nil {
t.Fatalf("drop not null: %v", err)
}
t.Cleanup(func() {
// Runs before the fixture teardown (LIFO), so the orgless row is gone
// by the time the constraint comes back.
if _, err := f.pool.Exec(context.Background(),
`UPDATE campaigns SET organization_id = $2 WHERE id = $1`, f.campaign, f.org); err != nil {
t.Errorf("restore campaign org: %v", err)
}
if _, err := f.pool.Exec(context.Background(),
`ALTER TABLE campaigns ALTER COLUMN organization_id SET NOT NULL`); err != nil {
t.Errorf("restore not null: %v", err)
}
})
if _, err := f.pool.Exec(ctx,
`UPDATE campaigns SET organization_id = NULL WHERE id = $1`, f.campaign); err != nil {
t.Fatalf("null out organization: %v", err)
}
}
// queueTick creates the pending campaign task a scheduler tick would run.
func (f *campaignSendFixture) queueTick(t *testing.T, taskRepo repository.TaskRepository) uuid.UUID {
t.Helper()
taskID := uuid.New()
now := time.Now()
created, err := taskRepo.CreateTaskWithLock(context.Background(), &repository.Task{
ID: taskID, TaskType: "campaign", EmailAccountID: f.mailbox, Status: "pending", ScheduledAt: &now,
}, &repository.CampaignTask{TaskID: taskID, CampaignID: &f.campaign})
if err != nil || !created {
t.Fatalf("queue tick: created=%v err=%v", created, err)
}
return taskID
}
func (f *campaignSendFixture) sendsRecorded(t *testing.T) int {
t.Helper()
var n int
if err := f.pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM campaign_contact_progress WHERE campaign_id = $1 AND sent_at IS NOT NULL`,
f.campaign).Scan(&n); err != nil {
t.Fatalf("count sends: %v", err)
}
return n
}
func (f *campaignSendFixture) campaignStatus(t *testing.T) string {
t.Helper()
var status string
if err := f.pool.QueryRow(context.Background(),
`SELECT status FROM campaigns WHERE id = $1`, f.campaign).Scan(&status); err != nil {
t.Fatalf("read status: %v", err)
}
return status
}
func (f *campaignSendFixture) logEvents(t *testing.T) []string {
t.Helper()
rows, err := f.pool.Query(context.Background(),
`SELECT event_type FROM campaign_logs WHERE campaign_id = $1`, f.campaign)
if err != nil {
t.Fatalf("read logs: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var e string
if err := rows.Scan(&e); err != nil {
t.Fatalf("scan log: %v", err)
}
out = append(out, e)
}
return out
}
// noopTaskScheduler stands in for the task queue. The tick writes the successor
// `tasks` row itself; the local dev provider treats the enqueue as a no-op too,
// so nothing about scheduling is being faked away here.
type noopTaskScheduler struct{}
func (noopTaskScheduler) CreateTask(ctx context.Context, taskData *proto.ProcessTask, scheduleTime time.Time) (string, error) {
return "", nil
}
func (noopTaskScheduler) DeleteTask(ctx context.Context, name string) error { return nil }
// noopCipher stands in for the envelope-encryption warm-up the send path does
// before dispatch. The tick only checks that a DEK resolves; nothing in these
// tests encrypts, so KMS and Redis stay out of the harness.
type noopCipher struct{}
func (noopCipher) Cipher(ctx context.Context, orgID uuid.UUID) (*cipher.Cipher, error) {
return &cipher.Cipher{}, nil
}
// recordingSender stands in for the worker dispatch: the tick's real side
// effect is the stamped send, and this is what makes it observable without a
// live worker. fail makes Send behave like a dispatch that could not be
// published, which is what the reservation tests need; the mutex is there
// because those tests run ticks concurrently.
type recordingSender struct {
mu sync.Mutex
sent int
fail error
}
func (r *recordingSender) Send(ctx context.Context, taskID uuid.UUID, msg EmailMessage, account models.Email) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.fail != nil {
return r.fail
}
r.sent++
return nil
}
// count reads the send tally under the lock.
func (r *recordingSender) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.sent
}
// setFail makes every subsequent Send return err (nil restores dispatch).
func (r *recordingSender) setFail(err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.fail = err
}
// liveCampaignService wires the real repositories the campaign tick uses.
// Everything that reaches outside Postgres (the worker dispatch, KMS, the task
// queue) is stubbed; every gate under test runs against the real queries.
func liveCampaignService(t *testing.T, handle *db.DB, sender EmailSender) *tasksService {
t.Helper()
pool := handle.Pool
enc, err := encrypt.NewEncrypter([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatalf("encrypter: %v", err)
}
taskRepo := repository.NewTaskRepository(pool)
warmupRepo := repository.NewWarmupRepository(pool)
progressRepo := repository.NewCampaignProgressRepository(pool)
emailRepo := repository.NewEmailRepostory(handle, enc)
campaignRepo := repository.NewCampaignRepostory(handle)
contactRepo := repository.NewContactRepostory(handle)
logRepo := repository.NewCampaignLogRepository(handle)
adv := advanced.NewService(
repository.NewAdvancedOutreachRepository(pool),
campaignRepo, emailRepo, taskRepo, contactRepo, progressRepo,
repository.NewCRMRepository(pool),
repository.NewGroupRepostory(handle, models.Categories),
repository.NewUniboxRepository(handle),
nil, nil,
)
return &tasksService{
emailSender: sender,
cipherService: noopCipher{},
tasksClient: noopTaskScheduler{},
scheduler: scheduler.NewSchedulerService(
taskRepo, warmupRepo, progressRepo, emailRepo, campaignRepo, contactRepo, logRepo,
),
advanced: adv,
taskRepo: taskRepo,
warmupRepo: warmupRepo,
campaignProgressRepo: progressRepo,
emailRepo: emailRepo,
campaignRepo: campaignRepo,
contactRepo: contactRepo,
campaignLogRepo: logRepo,
}
}
// The regression issue #168 asks for: a campaign with no organization, whose
// recipient is on the suppression list, must not send.
//
// Suppression is enforced twice, and the missing tenant defeats both: routing
// excludes a suppressed lead by joining suppressed_recipients on the campaign's
// organization_id, which matches nothing when that is NULL. The send gate is
// what has to stop it, and that is what this asserts.
func TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient(t *testing.T) {
handle := liveCampaignDB(t)
svc := liveCampaignService(t, handle, nil)
f := newCampaignSendFixture(t, handle.Pool)
f.suppress(t, "unsubscribed")
f.dropOrganization(t)
// Sender resolution is org-scoped too (issue #167), so an orgless campaign
// now finds no mailboxes before routing is even consulted. That is a second
// lock on the same door, not the one under test: the tick below still runs
// the send gate, which is what must refuse.
_, pair, _, err := svc.scheduler.CalculateNextCampaignTime(context.Background(), f.campaign)
if pair != nil || !errors.Is(err, scheduler.ErrNoEmailAccounts) {
t.Fatalf("expected an orgless campaign to resolve no senders, got pair=%v err=%v", pair, err)
}
taskID := f.queueTick(t, svc.taskRepo)
if xerr := svc.HandleCampaignTask(&proto.ProcessTask{TaskId: taskID.String()}); xerr != nil {
t.Fatalf("campaign tick returned an error: %v", xerr)
}
if n := f.sendsRecorded(t); n != 0 {
t.Fatalf("orgless campaign sent to a suppressed recipient: %d sends recorded", n)
}
if got := f.campaignStatus(t); got != "paused" {
t.Fatalf("campaign status = %q, want paused (fail closed, not silently skipped)", got)
}
if events := f.logEvents(t); len(events) == 0 {
t.Fatal("nothing recorded in the campaign feed: the skipped check has to be visible")
}
}
// The control: with the organization intact, the same suppressed lead is
// excluded by routing, so no send is even attempted. Without this the test
// above would pass on a build that simply never sends anything.
func TestLiveSuppressedRecipientIsSkipped(t *testing.T) {
handle := liveCampaignDB(t)
svc := liveCampaignService(t, handle, nil)
f := newCampaignSendFixture(t, handle.Pool)
// Before suppression the lead routes, so anything observed after it is
// suppression and not a broken fixture.
if _, pair, _, err := svc.scheduler.CalculateNextCampaignTime(context.Background(), f.campaign); err != nil || pair == nil {
t.Fatalf("fixture lead should route before suppression, got pair=%v err=%v", pair, err)
}
f.suppress(t, "hard bounce")
taskID := f.queueTick(t, svc.taskRepo)
if xerr := svc.HandleCampaignTask(&proto.ProcessTask{TaskId: taskID.String()}); xerr != nil {
t.Fatalf("campaign tick returned an error: %v", xerr)
}
if n := f.sendsRecorded(t); n != 0 {
t.Fatalf("suppressed recipient was mailed: %d sends recorded", n)
}
if got := f.campaignStatus(t); got != "completed" {
t.Fatalf("campaign status = %q, want completed (nothing left to send)", got)
}
}
// The state the fail-closed gate defends against is no longer reachable through
// the schema: migration 000092 backfilled organization_id and made it NOT NULL.
func TestLiveCampaignRequiresAnOrganization(t *testing.T) {
handle := liveCampaignDB(t)
f := newCampaignSendFixture(t, handle.Pool)
_, err := f.pool.Exec(context.Background(), `
INSERT INTO campaigns (id, user_id, organization_id, name, description, status,
daily_limit, timezone, days, start_time, end_time, updated_at, created_at)
VALUES (gen_random_uuid(), $1, NULL, 'Orgless', '', 'draft', 50, 'UTC', 127,
'00:00', '23:59', NOW(), NOW())`, f.user)
if err == nil {
t.Fatal("an orgless campaign was accepted: campaigns.organization_id must be NOT NULL")
}
}
// The other direction: an ordinary campaign, organization present and nothing
// suppressed, still sends. Without this the fail-closed gate could be blocking
// everything and the tests above would not notice.
func TestLiveHealthyCampaignStillSends(t *testing.T) {
handle := liveCampaignDB(t)
sender := &recordingSender{}
svc := liveCampaignService(t, handle, sender)
f := newCampaignSendFixture(t, handle.Pool)
taskID := f.queueTick(t, svc.taskRepo)
if xerr := svc.HandleCampaignTask(&proto.ProcessTask{TaskId: taskID.String()}); xerr != nil {
t.Fatalf("campaign tick returned an error: %v", xerr)
}
if sender.count() != 1 {
t.Fatalf("sender saw %d sends, want 1", sender.count())
}
if n := f.sendsRecorded(t); n != 1 {
t.Fatalf("%d sends stamped, want 1", n)
}
}