HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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.