File: /home/xedaptot/ai.naniguide.com/tests/Unit/CampaignFlowTest.php
<?php
/**
* CampaignFlowTest — integration test for the full campaign sending pipeline.
*
* ─── REVIEW: Expected behaviors vs code ──────────────────────────────────────
*
* ✅ Campaign creation → Customer::newDefaultCampaign() + saveFromArray()
* Campaign::save() override auto-creates an Email record when email_id is
* not yet set and EMAIL_ATTRIBUTES are present in the attribute bag.
*
* ✅ Email/template → Email::setTemplate() copies the template as private,
* assigns template_id on Email, triggers updatePlainFromHtml().
* Campaign accesses template via getAttribute('template') delegation.
*
* ✅ List association → Campaign::addListSegment() + default_mail_list_id.
* Campaign::getServersPool() reads from defaultMailList->getSendingServers().
*
* ✅ Run → execute() dispatches RunCampaign job, which calls Campaign::run().
* run() builds a Bus batch of LoadCampaign jobs (one per page of subs).
* Each LoadCampaign adds SendMessage jobs into the same batch.
* With QUEUE_CONNECTION=sync all jobs run inline.
*
* ✅ ContinueCampaign → fires from batch "then" callback, loops or calls setDone().
*
* ✅ Tracking → Campaign::trackMessage() writes one row per attempt to
* tracking_logs (status='sent'|'failed').
*
* ⚠️ "Failed delivery → campaign ERROR" depends on skip_failed_message:
* - skip_failed_message = FALSE → stopOnError = TRUE
* First exception is rethrown → batch catch → setError() → STATUS_ERROR
* - skip_failed_message = TRUE → stopOnError = FALSE
* Exception is swallowed, tracking_log written as 'failed',
* campaign continues → STATUS_DONE
* Both paths are exercised in the tests below.
*
* ─── Test DB requirement ─────────────────────────────────────────────────────
*
* These tests use DatabaseTransactions (wraps each test in a rolled-back DB
* transaction). The suite runs against whatever `DB_CONNECTION` is configured
* in phpunit.xml. For local development that is typically the dev MySQL DB.
* If you need full isolation, point `DB_DATABASE` at a dedicated test schema
* and run `php artisan migrate --env=testing` once before this suite.
*
* QUEUE_CONNECTION=sync (set in phpunit.xml) makes every dispatched job and
* batch callback execute synchronously inside the same PHP process.
*/
use App\Model\Campaign;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\Email;
use App\Model\MailList;
use App\Model\Plan;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\Subscription;
use App\Model\Template;
use App\Model\TrackingLog;
use App\SendingServers\Drivers\DeliveryStatus;
use App\SendingServers\Drivers\SendResult;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Entitlements\EntitlementKey;
use App\Services\Warmup\WarmupQuotaService;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class);
// Clean up test data after each test (can't use DatabaseTransactions — Bus batch
// uses DB::transaction() internally which conflicts with savepoint-based rollbacks).
afterEach(function () {
$testCustomerIds = DB::table('customers')->where('uid', 'like', 'cust_%')->pluck('id');
DB::table('tracking_logs')->whereIn('customer_id', $testCustomerIds)->delete();
$campaignIds = DB::table('campaigns')->whereIn('customer_id', $testCustomerIds)->pluck('id');
DB::table('campaigns_lists_segments')->whereIn('campaign_id', $campaignIds)->delete();
DB::table('campaigns')->whereIn('id', $campaignIds)->delete();
DB::table('automation_emails')->whereIn('customer_id', $testCustomerIds)->delete();
DB::table('emails')->whereIn('customer_id', $testCustomerIds)->delete();
DB::table('subscribers')->where('email', 'like', '%campaign-test%')->delete();
DB::table('mail_lists')->where('uid', 'like', 'list_%')->delete();
DB::table('sending_servers')->where('uid', 'like', 'srv_%')->delete();
DB::table('templates')->where('uid', 'like', 'tpl_%')->delete();
DB::table('subscriptions')->whereIn('customer_id', $testCustomerIds)->delete();
$planIds = DB::table('plans')->where('uid', 'like', 'plan_%')->pluck('id');
DB::table('plan_entitlements')->whereIn('plan_id', $planIds)->delete();
DB::table('plan_credits')->whereIn('plan_id', $planIds)->delete();
DB::table('plans')->whereIn('id', $planIds)->delete();
DB::table('customers')->whereIn('id', $testCustomerIds)->delete();
DB::table('contacts')->where('uid', 'like', 'contact_%')->delete();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Build the minimal DB fixture needed for a campaign run:
* customer → mail list + subscribers → sending server (SMTP, no-op setup) → template
*/
function campaignFixture(int $subscriberCount = 3): array
{
// 1. Customer — email/name live in the contacts table, not customers
$contact = Contact::forceCreate([
'uid' => uniqid('contact_'),
'first_name' => 'Test',
'last_name' => 'User',
'email' => uniqid() . '@campaign-test.invalid',
]);
$customer = Customer::forceCreate([
'uid' => uniqid('cust_'),
'contact_id' => $contact->id,
'status' => 'active',
'timezone' => 'UTC',
]);
// 1b. Plan + active subscription so the SaaS pipeline (sending-server lookup,
// rate-limit cleanup, footer-from-plan) finds a real plan instead of erroring.
$plan = Plan::forceCreate([
'uid' => uniqid('plan_'),
'currency_id' => 1,
'name' => 'Test Plan',
'price' => 0,
'frequency_amount' => 1,
'frequency_unit' => 'month',
'status' => 'active',
]);
DB::table('plan_entitlements')->insert([
'plan_id' => $plan->id,
'entitlement_key' => EntitlementKey::HAS_OWN_SENDING_SERVER->value,
'enabled' => true,
'created_at' => now(),
'updated_at' => now(),
]);
// Unlimited send-email credit (NULL = unlimited per migration comment)
DB::table('plan_credits')->insert([
'plan_id' => $plan->id,
'credit_key' => CreditKey::SEND_EMAIL->value,
'amount_per_cycle' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$subscription = Subscription::forceCreate([
'uid' => uniqid('sub_'),
'customer_id' => $customer->id,
'plan_id' => $plan->id,
'status' => Subscription::STATUS_ACTIVE,
]);
// Mirror production assignPlan(): prime cold-state DB row + hot tracker
// file via the lifecycle. Without this, trackerFor returns null and
// SendPipeline can't access the credit tracker.
app(\App\Services\Plans\Lifecycle\SubscriptionLifecycle::class)
->onSubscribe($subscription);
// 2. Mail list
$list = MailList::forceCreate([
'uid' => uniqid('list_'),
'name' => 'Test List',
'customer_id' => $customer->id,
'from_email' => '[email protected]',
'from_name' => 'Test Sender',
]);
// 3. Sending server (type=smtp, setupBeforeSend() is a no-op on SMTP)
$server = SendingServer::forceCreate([
'uid' => uniqid('srv_'),
'name' => 'Test SMTP',
'type' => 'smtp',
'customer_id' => $customer->id,
'status' => SendingServer::STATUS_ACTIVE,
'host' => '127.0.0.1',
'smtp_port' => 25,
'quota_value' => -1, // unlimited
'quota_base' => 1,
'quota_unit' => 'day',
]);
// The SendingServer above lives under $customer; Customer::getSendingServerPool()
// (Case A — HAS_OWN_SENDING_SERVER entitlement granted above) surfaces it
// for both campaign delivery and list transactional sends.
// 5. Subscribers
$subscribers = [];
for ($i = 0; $i < $subscriberCount; $i++) {
$subscribers[] = Subscriber::forceCreate([
'uid' => uniqid('sub_'),
'email' => "sub{$i}_" . uniqid() . '@campaign-test.invalid',
'status' => Subscriber::STATUS_SUBSCRIBED,
'mail_list_id' => $list->id,
'verification_status' => 'unverified', // included in default delivery statuses
'from' => 'manual',
'ip' => '127.0.0.1',
]);
}
// 6. Template (plain HTML – no external CSS, no inline-CSS pipeline issues)
$template = Template::forceCreate([
'uid' => uniqid('tpl_'),
'name' => 'Test Template',
'content' => '<html><body><p>Hello!</p><a href="{UNSUBSCRIBE_URL}">Unsubscribe</a></body></html>',
'json' => \App\Model\Template::DEFAULT_BUILDER_JSON,
]);
// Create a placeholder thumbnail so Template::copy() doesn't throw FileNotFoundException
$thumbDir = storage_path('app/public/templates/thumb');
if (!is_dir($thumbDir)) {
mkdir($thumbDir, 0755, true);
}
file_put_contents($thumbDir . '/' . $template->uid . '.jpg', '');
return compact('customer', 'list', 'server', 'subscribers', 'template');
}
/**
* Build a campaign that is ready to run (template set, list associated).
*/
function readyCampaign(array $fixture): Campaign
{
['customer' => $customer, 'list' => $list, 'template' => $template] = $fixture;
// Create campaign — email record is auto-created inside save()
$campaign = $customer->local()->newDefaultCampaign();
$campaign->saveFromArray(['type' => Email::TYPE_REGULAR]);
// Content
$campaign->setTemplate($template, 'Test campaign template');
$campaign->subject = 'Test Campaign Subject';
$campaign->from_email = '[email protected]';
$campaign->from_name = 'Test Sender';
$campaign->reply_to = '[email protected]';
$campaign->save();
// List
$campaign->addListSegment($list, null);
$campaign->default_mail_list_id = $list->id;
$campaign->save();
return $campaign->fresh(['email']);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test('creating a campaign auto-creates an Email record', function () {
$fixture = campaignFixture(0);
$customer = $fixture['customer'];
$campaign = $customer->local()->newDefaultCampaign();
// Before save: no email_id yet, but type/delivery_statuses are pending in attributes
expect($campaign->email_id)->toBeNull();
$campaign->saveFromArray(['type' => Email::TYPE_REGULAR]);
$campaign->refresh();
// After save: Email is created and linked
expect($campaign->email_id)->not->toBeNull();
expect($campaign->email)->not->toBeNull();
expect($campaign->type)->toBe(Email::TYPE_REGULAR); // delegated to Email
expect($campaign->status)->toBe(Campaign::STATUS_NEW);
});
test('template assignment is delegated to the Email record', function () {
$fixture = campaignFixture(0);
$campaign = readyCampaign($fixture);
expect($campaign->email->template_id)->not->toBeNull();
expect($campaign->template)->not->toBeNull(); // delegation via getAttribute
expect($campaign->getTemplateContent())->toContain('<html>');
expect($campaign->subject)->toBe('Test Campaign Subject'); // delegation via email
});
test('campaign happy path: all subscribers receive the email, status becomes DONE', function () {
$fixture = campaignFixture(subscriberCount: 3);
$campaign = readyCampaign($fixture);
$campaignId = $campaign->id;
// Mock WarmupQuotaService to return a successful delivery for every subscriber
$this->mock(WarmupQuotaService::class, function ($mock) {
$mock->shouldReceive('send')
->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid('msg_'), status: DeliveryStatus::SENT));
});
// Run — with QUEUE_CONNECTION=sync this is fully inline
$origException = null;
DB::listen(function ($query) use (&$origException) {
// no-op listener, just to ensure queries are logged
});
set_exception_handler(function ($e) use (&$origException) {
$origException = $e;
});
try {
$campaign->execute();
} catch (\Throwable $outer) {
if ($origException && $origException !== $outer) {
throw $origException;
}
throw $outer;
} finally {
restore_exception_handler();
}
$campaign->refresh();
// ── Status
expect($campaign->status)->toBe(Campaign::STATUS_DONE);
expect($campaign->last_error)->toBeNull();
// ── Tracking logs: one entry per subscriber, all 'sent'
$logs = TrackingLog::where('campaign_id', $campaignId)->get();
expect($logs)->toHaveCount(3);
expect($logs->every(fn ($log) => $log->status === 'sent'))->toBeTrue();
expect($logs->every(fn ($log) => !empty($log->message_id)))->toBeTrue();
expect($logs->every(fn ($log) => !empty($log->runtime_message_id)))->toBeTrue();
// ── All 3 subscribers are covered — no one was skipped
$sentEmails = $logs->pluck('subscriber_id');
$allSubIds = collect($fixture['subscribers'])->pluck('id');
expect($sentEmails->diff($allSubIds))->toBeEmpty();
});
test('failed delivery with skip_failed_message=true: logs as failed, campaign still reaches DONE', function () {
$fixture = campaignFixture(subscriberCount: 2);
$campaign = readyCampaign($fixture);
// skip_failed_message=true → stopOnError=false → individual failures are absorbed
$campaign->email->skip_failed_message = true;
$campaign->email->save();
$this->mock(WarmupQuotaService::class, function ($mock) {
$mock->shouldReceive('send')
->andThrow(new RuntimeException('Simulated SMTP failure'));
});
$campaign->execute();
$campaign->refresh();
// Campaign should complete — errors are tolerated
expect($campaign->status)->toBe(Campaign::STATUS_DONE);
$logs = TrackingLog::where('campaign_id', $campaign->id)->get();
expect($logs)->toHaveCount(2);
expect($logs->every(fn ($log) => $log->status === 'failed'))->toBeTrue();
expect($logs->every(fn ($log) => !empty($log->error)))->toBeTrue();
});
test('failed delivery with skip_failed_message=false: campaign stops with ERROR status', function () {
$fixture = campaignFixture(subscriberCount: 2);
$campaign = readyCampaign($fixture);
// skip_failed_message=false → stopOnError=true → first exception stops the batch
$campaign->email->skip_failed_message = false;
$campaign->email->save();
$this->mock(WarmupQuotaService::class, function ($mock) {
$mock->shouldReceive('send')
->andThrow(new RuntimeException('Simulated hard failure'));
});
$campaign->execute();
$campaign->refresh();
expect($campaign->is_error)->toBeTrue();
expect($campaign->last_error)->not->toBeEmpty();
});
test('campaign status transitions: new → queued → sending → done', function () {
$fixture = campaignFixture(subscriberCount: 1);
$campaign = readyCampaign($fixture);
$statuses = [];
// Intercept every save() to record status snapshots.
// NOTE: $model->attributes goes through __get() (protected property), so use getAttributes().
Campaign::saving(function ($model) use (&$statuses) {
$attrs = $model->getAttributes();
if (array_key_exists('status', $attrs)) {
$statuses[] = $attrs['status'];
}
});
$this->mock(WarmupQuotaService::class, function ($mock) {
$mock->shouldReceive('send')
->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT));
});
expect($campaign->status)->toBe(Campaign::STATUS_NEW);
$campaign->execute();
$campaign->refresh();
expect($campaign->status)->toBe(Campaign::STATUS_DONE);
// The recorded transitions must include queued → sending → done (order guaranteed)
expect($statuses)->toContain(Campaign::STATUS_QUEUED);
expect($statuses)->toContain(Campaign::STATUS_SENDING);
expect($statuses)->toContain(Campaign::STATUS_DONE);
$queuedIdx = array_search(Campaign::STATUS_QUEUED, $statuses);
$sendingIdx = array_search(Campaign::STATUS_SENDING, $statuses);
$doneIdx = array_search(Campaign::STATUS_DONE, $statuses);
expect($queuedIdx)->toBeLessThan($sendingIdx);
expect($sendingIdx)->toBeLessThan($doneIdx);
});
test('subscribers already in tracking_logs are not sent to again (no duplicates)', function () {
$fixture = campaignFixture(subscriberCount: 3);
$campaign = readyCampaign($fixture);
$this->mock(WarmupQuotaService::class, function ($mock) {
$mock->shouldReceive('send')
->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT));
});
$campaign->execute();
$campaign->refresh();
$firstRunCount = TrackingLog::where('campaign_id', $campaign->id)->count();
expect($firstRunCount)->toBe(3);
// Resetting status to NEW and re-running should not create duplicate logs
// (subscribersToSend() excludes already-logged subscribers)
$campaign->status = Campaign::STATUS_NEW;
$campaign->save();
$campaign->execute($force = true);
$campaign->refresh();
$secondRunCount = TrackingLog::where('campaign_id', $campaign->id)->count();
expect($secondRunCount)->toBe(3); // still 3, not 6
});
test('subscriber partition: A = B + C + D (toSend + sent + skipped)', function () {
// Create fixture with 3 subscribed subscribers
$fixture = campaignFixture(subscriberCount: 3);
$campaign = readyCampaign($fixture);
$list = $fixture['list'];
// Add 1 unsubscribed subscriber (should be skipped)
$unsubscribed = Subscriber::forceCreate([
'uid' => uniqid('sub_unsub_'),
'email' => 'unsub_' . uniqid() . '@campaign-test.invalid',
'status' => 'unsubscribed',
'mail_list_id' => $list->id,
'verification_status' => 'unverified',
'from' => 'manual',
'ip' => '127.0.0.1',
]);
// Add 1 subscriber with undeliverable verification (should be skipped)
$undeliverable = Subscriber::forceCreate([
'uid' => uniqid('sub_undel_'),
'email' => 'undel_' . uniqid() . '@campaign-test.invalid',
'status' => Subscriber::STATUS_SUBSCRIBED,
'mail_list_id' => $list->id,
'verification_status' => Subscriber::VERIFICATION_STATUS_UNDELIVERABLE,
'from' => 'manual',
'ip' => '127.0.0.1',
]);
// A = all subscribers in campaign lists (3 subscribed + 1 unsub + 1 undeliverable = 5)
$totalA = $campaign->subscribers([])->count();
expect($totalA)->toBe(5);
// Before sending: B=3 (eligible), C=0 (none sent), D=2 (skipped)
expect($campaign->subscribersToSend()->count())->toBe(3);
expect($campaign->subscribersSent()->count())->toBe(0);
expect($campaign->subscribersSkipped()->count())->toBe(2);
expect($campaign->subscribersToSend()->count() + $campaign->subscribersSent()->count() + $campaign->subscribersSkipped()->count())->toBe($totalA);
// Send to 2 out of 3 eligible subscribers (mock sends first 2, then fails on 3rd)
$callCount = 0;
$this->mock(WarmupQuotaService::class, function ($mock) use (&$callCount) {
$mock->shouldReceive('send')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount <= 2) {
return new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT);
}
throw new \RuntimeException('Simulated failure');
});
});
$campaign->email->skip_failed_message = true;
$campaign->email->save();
$campaign->execute();
$campaign->refresh();
// After partial send: C=2 (sent) or C=3 (2 sent + 1 failed), B=remaining, D=2 (unchanged)
$countB = $campaign->subscribersToSend()->count();
$countC = $campaign->subscribersSent()->count();
$countD = $campaign->subscribersSkipped()->count();
// The partition must hold: A = B + C + D
expect($countB + $countC + $countD)->toBe($totalA, "Partition failed: B($countB) + C($countC) + D($countD) != A($totalA)");
// D should still be 2 (unsubscribed + undeliverable)
expect($countD)->toBe(2);
// C should be >= 2 (at least 2 sent successfully, 3rd may have tracking_log with error)
expect($countC)->toBeGreaterThanOrEqual(2);
// C = C1 + C2: subscribersSent() must equal deliveredCount (sent) + failedCount (failed)
// Assumes no duplicate tracking_log entries in this test
$countC1 = $campaign->deliveredCount(); // tracking_logs.status = 'sent'
$countC2 = $campaign->failedCount(); // tracking_logs.status = 'failed'
expect($countC1 + $countC2)->toBe($countC, "C partition failed: C1($countC1) + C2($countC2) != C($countC)");
// At least 2 delivered successfully
expect($countC1)->toBeGreaterThanOrEqual(2);
// Verify no overlap: IDs should be disjoint
$idsB = $campaign->subscribersToSend()->pluck('subscribers.id')->toArray();
$idsC = $campaign->subscribersSent()->pluck('subscribers.id')->toArray();
$idsD = $campaign->subscribersSkipped()->pluck('subscribers.id')->toArray();
expect(array_intersect($idsB, $idsC))->toBeEmpty('B and C overlap');
expect(array_intersect($idsB, $idsD))->toBeEmpty('B and D overlap');
expect(array_intersect($idsC, $idsD))->toBeEmpty('C and D overlap');
});