File: /home/xedaptot/ai.naniguide.com/tests/Unit/Sending/Lifecycle/CampaignSendLifecycleTest.php
<?php
/**
* CampaignSendLifecycleTest — exercises the Campaign-batch lifecycle.
*
* - onSent / onFailed / onUnknownException: explicit no-ops (verified to not throw).
* - onRateLimitExceeded / onWarmupQuotaExceeded: full batch-coordination strategy
* (delay-flag dance + Delay sibling job + per-rate notification dispatch).
*
* Earlier these batch coordination tests lived in ExceptionRouterTest, exercised
* via RateLimitExceededHandler / WarmupQuotaExceededHandler. After making those
* handlers thin dispatchers (always call lifecycle, no batch/standalone branching),
* the actual coordination logic moved here — tests follow the logic.
*/
use App\Jobs\Delay;
use App\Library\Exception\RateLimitExceeded;
use App\Library\RouletteWheel;
use App\Library\Sending\JobControl;
use App\Library\Sending\Lifecycle\CampaignSendLifecycle;
use App\Library\Sending\SendContext;
use App\Model\Campaign;
use App\Model\Subscriber;
use App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded;
use Illuminate\Bus\Batch;
use Tests\TestCase;
uses(TestCase::class);
class RecordingControlForCampaignLifecycle implements JobControl
{
public ?int $releasedAfter = null;
/** @var array<object> */
public array $batchAdditions = [];
public ?Batch $batch = null;
public function release(int $seconds): void { $this->releasedAfter = $seconds; }
public function batch(): ?Batch { return $this->batch; }
public function addToBatch(object $job): void { $this->batchAdditions[] = $job; }
}
function makeNoOpCtx(): SendContext
{
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
return new SendContext(
campaign: Mockery::mock(Campaign::class),
subscriber: $subscriber,
servers: null,
);
}
// ---------------------------------------------------------------------------
// onSent / onFailed — write tracking_logs row via Campaign::trackMessage
// ---------------------------------------------------------------------------
it('onSent writes a tracking_logs row with the SendResult', function () {
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('trackMessage')
->withArgs(function ($result) {
return $result instanceof \App\SendingServers\Drivers\SendResult
&& $result->getStatus() === \App\SendingServers\Drivers\DeliveryStatus::SENT;
})
->once();
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: null);
$ctx->result = new \App\SendingServers\Drivers\SendResult(
runtimeMessageId: 'rt-1',
status: \App\SendingServers\Drivers\DeliveryStatus::SENT,
);
(new CampaignSendLifecycle())->onSent($ctx);
});
it('onFailed writes a tracking_logs row with the FAILED SendResult', function () {
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('trackMessage')
->withArgs(function ($result) {
return $result->getStatus() === \App\SendingServers\Drivers\DeliveryStatus::FAILED;
})
->once();
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: null);
$ctx->result = \App\SendingServers\Drivers\SendResult::failedNotSubscribed('unsubscribed');
(new CampaignSendLifecycle())->onFailed($ctx);
});
// ---------------------------------------------------------------------------
// onUnknownException — Campaign decision matrix (stopOnError + ForceEndsCampaign + selectedServer)
// ---------------------------------------------------------------------------
it('onUnknownException re-throws when stopOnError is true (campaign aborts)', function () {
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(
campaign: Mockery::mock(Campaign::class),
subscriber: $subscriber,
servers: null,
);
$ctx->selectedServer = Mockery::mock(\App\Model\SendingServer::class);
expect(fn () => (new CampaignSendLifecycle(stopOnError: true))->onUnknownException(
$ctx,
new \RuntimeException('boom'),
Mockery::mock(JobControl::class),
))->toThrow(\Exception::class);
});
it('onUnknownException re-throws when exception implements ForceEndsCampaign', function () {
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(
campaign: Mockery::mock(Campaign::class),
subscriber: $subscriber,
servers: null,
);
$ctx->selectedServer = Mockery::mock(\App\Model\SendingServer::class);
// OutOfCredits implements ForceEndsCampaign — using a generic mock would also work
// but using the real exception keeps the assertion grounded in real production code.
expect(fn () => (new CampaignSendLifecycle())->onUnknownException(
$ctx,
new \App\Library\Exception\OutOfCredits('quota gone'),
Mockery::mock(JobControl::class),
))->toThrow(\Exception::class);
});
it('onUnknownException re-throws when no server was selected (cannot write tracking row)', function () {
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(
campaign: Mockery::mock(Campaign::class),
subscriber: $subscriber,
servers: null,
);
// selectedServer stays null
expect(fn () => (new CampaignSendLifecycle())->onUnknownException(
$ctx,
new \RuntimeException('pre-server-failure'),
Mockery::mock(JobControl::class),
))->toThrow(\Exception::class);
});
// ---------------------------------------------------------------------------
// onOutOfCredits — Campaign throws (ForceEndsCampaign semantics)
// ---------------------------------------------------------------------------
it('onOutOfCredits re-throws (campaign aborts; subsequent sends would all fail same way)', function () {
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(
campaign: Mockery::mock(Campaign::class),
subscriber: $subscriber,
servers: null,
);
$ex = new \App\Library\Exception\OutOfCredits('quota gone');
expect(fn () => (new CampaignSendLifecycle())->onOutOfCredits(
$ctx,
$ex,
Mockery::mock(JobControl::class),
))->toThrow(\App\Library\Exception\OutOfCredits::class, 'quota gone');
});
it('onUnknownException tracks failed and continues when nothing forces an abort', function () {
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('trackMessage')
->withArgs(function ($result) {
return $result instanceof \App\SendingServers\Drivers\SendResult
&& $result->getStatus() === \App\SendingServers\Drivers\DeliveryStatus::FAILED;
})
->once();
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(
campaign: $campaign,
subscriber: $subscriber,
servers: null,
);
$ctx->selectedServer = Mockery::mock(\App\Model\SendingServer::class);
expect(fn () => (new CampaignSendLifecycle(stopOnError: false))->onUnknownException(
$ctx,
new \RuntimeException('boom'),
Mockery::mock(JobControl::class),
))->not->toThrow(\Throwable::class);
});
// ---------------------------------------------------------------------------
// onRateLimitExceeded — full batch coordination
// ---------------------------------------------------------------------------
it('onRateLimitExceeded sets delay flag, resets servers, dispatches Delay sibling, fires notification', function () {
// Notifier is invoked by the lock-winner branch — stub it so dispatch is intercepted.
$notifier = Mockery::mock(\App\Services\Notifications\Notifier::class);
$notifier->shouldReceive('dispatch')->once();
$this->app->instance(\App\Services\Notifications\Notifier::class, $notifier);
$servers = Mockery::mock(RouletteWheel::class);
$servers->shouldReceive('reset')->once();
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('getAttribute')->with('customer')->andReturn(Mockery::mock(\App\Model\Customer::class));
$campaign->shouldReceive('checkDelayFlag')->andReturn(false)->once();
$campaign->shouldReceive('setDelayFlag')->with(true)->once();
$campaign->shouldReceive('debug')->once(); // delay_note write
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $servers);
$control = new RecordingControlForCampaignLifecycle();
(new CampaignSendLifecycle())->onRateLimitExceeded(
$ctx,
new RateLimitExceeded('speed'),
$control,
);
expect($control->batchAdditions)->toHaveCount(1);
expect($control->batchAdditions[0])->toBeInstanceOf(Delay::class);
});
it('onRateLimitExceeded quits silently if delay flag already set (concurrent-jobs race protection)', function () {
$notifier = Mockery::mock(\App\Services\Notifications\Notifier::class);
$notifier->shouldNotReceive('dispatch');
$this->app->instance(\App\Services\Notifications\Notifier::class, $notifier);
$servers = Mockery::mock(RouletteWheel::class);
$servers->shouldNotReceive('reset');
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('checkDelayFlag')->andReturn(true)->once(); // sibling already set
$campaign->shouldNotReceive('setDelayFlag');
$campaign->shouldNotReceive('debug');
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $servers);
$control = new RecordingControlForCampaignLifecycle();
(new CampaignSendLifecycle())->onRateLimitExceeded(
$ctx,
new RateLimitExceeded('speed'),
$control,
);
expect($control->batchAdditions)->toBeEmpty();
});
// ---------------------------------------------------------------------------
// onWarmupQuotaExceeded — full batch coordination with retry-at delay
// ---------------------------------------------------------------------------
it('onWarmupQuotaExceeded dispatches Delay scheduled for the warmup retry-at window', function () {
$servers = Mockery::mock(RouletteWheel::class);
$servers->shouldReceive('reset')->once();
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('getAttribute')->with('customer')->andReturn(Mockery::mock(\App\Model\Customer::class));
$campaign->shouldReceive('checkDelayFlag')->andReturn(false);
$campaign->shouldReceive('setDelayFlag')->with(true);
$campaign->shouldReceive('debug');
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $servers);
$control = new RecordingControlForCampaignLifecycle();
$ex = (new WarmupDailyQuotaExceeded('hit'))->setRetryAt(now()->addDay());
(new CampaignSendLifecycle())->onWarmupQuotaExceeded($ctx, $ex, $control);
expect($control->batchAdditions)->toHaveCount(1);
expect($control->batchAdditions[0])->toBeInstanceOf(Delay::class);
});
it('onWarmupQuotaExceeded quits silently if delay flag already set', function () {
$servers = Mockery::mock(RouletteWheel::class);
$servers->shouldNotReceive('reset');
$campaign = Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
$campaign->shouldReceive('checkDelayFlag')->andReturn(true);
$campaign->shouldNotReceive('setDelayFlag');
$campaign->shouldNotReceive('debug');
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $servers);
$control = new RecordingControlForCampaignLifecycle();
$ex = (new WarmupDailyQuotaExceeded('hit'))->setRetryAt(now()->addDay());
(new CampaignSendLifecycle())->onWarmupQuotaExceeded($ctx, $ex, $control);
expect($control->batchAdditions)->toBeEmpty();
});