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/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();
});