File: /home/xedaptot/be.naniguide.com/tests/Unit/Sending/ExceptionRouterTest.php
<?php
/**
* ExceptionRouterTest — highest-value test surface for the send-pipeline
* refactor. Covers:
*
* - Routing: each known exception class lands at the right handler, and an
* unknown Throwable falls through to GenericFailureHandler (no swallow).
* - Inheritance ordering: WarmupDailyQuotaExceeded must match BEFORE
* RateLimitExceeded (warmup extends rate-limit).
* - Per-handler behavior: release amounts, batch-vs-no-batch sub-paths for
* rate-limit / warmup-quota, ForceEndsCampaign re-throw for OutOfCredits,
* GenericFailureHandler decision matrix (lifecycle handled / stopOnError /
* force-end / track-failed).
*
* Uses RecordingJobControl + RecordingLifecycle stubs so handlers can be
* exercised without Laravel's queue infrastructure. The Cache facade is the
* default array driver in tests, so \with_cache_lock just runs the closure
* inline.
*/
use App\Jobs\CacheAttachments;
use App\Jobs\Delay;
use App\Library\Exception\DistributedModeWaitForMaster;
use App\Library\Exception\OutOfCredits;
use App\Library\Exception\RateLimitExceeded;
use App\Library\Exception\RateLimitReservedByAnotherFileSystem;
use App\Library\RouletteWheel;
use App\Library\Sending\Exceptions\ExceptionRouter;
use App\Library\Sending\Exceptions\Handlers\DistributedMasterWaitHandler;
use App\Library\Sending\Exceptions\Handlers\GenericFailureHandler;
use App\Library\Sending\Exceptions\Handlers\OutOfCreditsHandler;
use App\Library\Sending\Exceptions\Handlers\RateLimitExceededHandler;
use App\Library\Sending\Exceptions\Handlers\RateLimitReservedByOtherFsHandler;
use App\Library\Sending\Exceptions\Handlers\WarmupQuotaExceededHandler;
use App\Library\Sending\JobControl;
use App\Library\Sending\Lifecycle\JobLifecycle;
use App\Library\Sending\SendContext;
use App\Model\Campaign;
use App\Model\Subscriber;
use App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded;
use App\SendingServers\Drivers\DeliveryStatus;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
uses(TestCase::class);
// ---------------------------------------------------------------------------
// Test stubs
// ---------------------------------------------------------------------------
class RecordingJobControl 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;
}
}
class RecordingLifecycle implements JobLifecycle
{
public int $sentCalls = 0;
public int $failedCalls = 0;
public ?RateLimitExceeded $rateLimitExceededSeen = null;
public ?Throwable $unknownExceptionSeen = null;
public function triggerId(): mixed
{
return null;
}
public function rateTrackers(): array
{
return [];
}
public function creditTrackers(): array
{
return [];
}
public function variantId(): ?int
{
return null;
}
public function prepareMessage(SendContext $ctx, \App\Model\SendingServer $server): array
{
return [new \Symfony\Component\Mime\Email(), 'msg-id-stub'];
}
public function onSent(SendContext $ctx): void
{
$this->sentCalls++;
}
public function onFailed(SendContext $ctx): void
{
$this->failedCalls++;
}
public function onRateLimitExceeded(SendContext $ctx, RateLimitExceeded $ex, JobControl $control): void
{
$this->rateLimitExceededSeen = $ex;
}
public ?\App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded $warmupQuotaExceededSeen = null;
public function onWarmupQuotaExceeded(SendContext $ctx, \App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded $ex, JobControl $control): void
{
$this->warmupQuotaExceededSeen = $ex;
}
public ?OutOfCredits $outOfCreditsSeen = null;
public function onOutOfCredits(SendContext $ctx, OutOfCredits $ex, JobControl $control): void
{
$this->outOfCreditsSeen = $ex;
}
public function onUnknownException(SendContext $ctx, Throwable $ex, JobControl $control): void
{
$this->unknownExceptionSeen = $ex;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCtx(?Campaign $campaign = null, ?RouletteWheel $servers = null): SendContext
{
$campaign ??= Mockery::mock(Campaign::class);
$campaign->shouldReceive('getAttribute')->with('uid')->andReturn('campaign-uid')->byDefault();
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]')->byDefault();
$subscriber->shouldReceive('getAttribute')->with('email')->andReturn('[email protected]')->byDefault();
return new SendContext(
campaign: $campaign,
subscriber: $subscriber,
servers: $servers,
);
}
function makeRouter(): ExceptionRouter
{
return new ExceptionRouter(
masterWait: new DistributedMasterWaitHandler(),
fsReserved: new RateLimitReservedByOtherFsHandler(),
warmupQuota: new WarmupQuotaExceededHandler(),
rateLimit: new RateLimitExceededHandler(),
outOfCredits: new OutOfCreditsHandler(),
generic: new GenericFailureHandler(),
);
}
// ---------------------------------------------------------------------------
// Routing — each exception class lands at the right handler
// ---------------------------------------------------------------------------
it('routes DistributedModeWaitForMaster to its handler (releases 60s)', function () {
Bus::fake(); // intercept CacheAttachments::dispatch — handler runs the dispatch but we don't assert on it.
$campaign = new Campaign();
$campaign->setAttribute('uid', 'campaign-uid');
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
$ctx = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: null);
$control = new RecordingJobControl();
makeRouter()->route(new DistributedModeWaitForMaster('cache busy'), $ctx, $control, new RecordingLifecycle());
expect($control->releasedAfter)->toBe(60);
});
it('routes RateLimitReservedByAnotherFileSystem to its handler (release 1s)', function () {
$ctx = makeCtx();
$control = new RecordingJobControl();
makeRouter()->route(new RateLimitReservedByAnotherFileSystem('reserved'), $ctx, $control, new RecordingLifecycle());
expect($control->releasedAfter)->toBe(1);
});
it('routes OutOfCredits to its handler hook (lifecycle owns throw vs graceful)', function () {
$ctx = makeCtx();
$control = new RecordingJobControl();
$lifecycle = new RecordingLifecycle();
$ex = new OutOfCredits('quota gone');
makeRouter()->route($ex, $ctx, $control, $lifecycle);
// Hook fired; lifecycle decides throw vs graceful (Campaign throws, Automation
// marks Phase::Failed). Lifecycle-specific throw behavior is tested in
// CampaignSendLifecycleTest. RecordingLifecycle just records.
expect($lifecycle->outOfCreditsSeen)->toBe($ex);
});
it('routes WarmupDailyQuotaExceeded to the warmup hook BEFORE rate-limit (inheritance ordering)', function () {
$ctx = makeCtx();
$control = new RecordingJobControl();
$lifecycle = new RecordingLifecycle();
$ex = (new WarmupDailyQuotaExceeded('warmup hit'))->setRetryAt(now()->addDay());
makeRouter()->route($ex, $ctx, $control, $lifecycle);
// Critical: warmup arm matched BEFORE rate-limit arm in match (since
// WarmupDailyQuotaExceeded extends RateLimitExceeded). Verify by hook
// identity — only onWarmupQuotaExceeded was called, NOT onRateLimitExceeded.
expect($lifecycle->warmupQuotaExceededSeen)->toBe($ex);
expect($lifecycle->rateLimitExceededSeen)->toBeNull();
});
it('routes RateLimitExceeded (non-warmup) to the rate-limit hook unconditionally', function () {
$ctx = makeCtx();
$control = new RecordingJobControl();
$lifecycle = new RecordingLifecycle();
$ex = new RateLimitExceeded('too fast');
makeRouter()->route($ex, $ctx, $control, $lifecycle);
expect($lifecycle->rateLimitExceededSeen)->toBe($ex);
expect($lifecycle->warmupQuotaExceededSeen)->toBeNull();
});
it('routes unknown Throwable to GenericFailureHandler hook (no swallow)', function () {
$ctx = makeCtx();
$control = new RecordingJobControl();
$lifecycle = new RecordingLifecycle();
makeRouter()->route(new \RuntimeException('weird'), $ctx, $control, $lifecycle);
// Handler is now a thin dispatcher — verify the hook fired.
// Throw / trackFailed decision matrix is owned by the lifecycle (Campaign-shaped
// concerns: stopOnError, ForceEndsCampaign, selectedServer null) and is tested
// in CampaignSendLifecycleTest.
expect($lifecycle->unknownExceptionSeen)->toBeInstanceOf(\RuntimeException::class);
});
// Note: per-handler batch/standalone behavior + decision matrix tests live in:
// - CampaignSendLifecycleTest (batch coordination + unknown-exception decision matrix)
// - AutomationSendLifecycleTest (standalone release + unknown-exception state mutations)
// Handlers themselves are thin dispatchers verified by routing tests above.