mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
425 lines
16 KiB
Go
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)
|
|
}
|
|
}
|