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/Lifecycle/AutomationSendLifecycleTest.php
<?php

/**
 * AutomationSendLifecycleTest — verifies the v2 (FlowExecutor-aware) hooks
 * mutate the AutoTrigger row correctly:
 *
 *  - onSent: state.sent_at set, scheduled_at cleared, phase=Running,
 *    appendHistory called, FlowExecutor->advance() drained.
 *  - onRateLimitExceeded: state.delay_note set, $control->release(600) called.
 *  - onUnknownException: state.error set, phase=Failed, appendHistory called,
 *    returns true (suppress re-throw).
 *  - Both onSent + onUnknownException SHORT-CIRCUIT when trigger->held_at
 *    is non-null (admin override semantics).
 *  - onSent SHORT-CIRCUITs when trigger has advanced past this node
 *    (current_node_id != actionId — stale callback).
 */

use App\Domain\Automation\Runtime\FlowExecutor;
use App\Domain\Automation\Runtime\Phase;
use App\Library\Exception\RateLimitExceeded;
use App\Library\Sending\JobControl;
use App\Library\Sending\Lifecycle\AutomationSendLifecycle;
use App\Library\Sending\SendContext;
use App\Model\AutoTrigger;
use App\Model\Campaign;
use App\Model\Subscriber;
use Tests\TestCase;

uses(TestCase::class);

class RecordingControlForLifecycle implements JobControl
{
    public ?int $releasedAfter = null;
    public function release(int $seconds): void { $this->releasedAfter = $seconds; }
    public function batch(): ?\Illuminate\Bus\Batch { return null; }
    public function addToBatch(object $job): void {}
}

function makeAutomationCtx(): SendContext
{
    $campaign = Mockery::mock(Campaign::class);
    $campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
    // Lifecycle's onSent / onFailed write a tracking_logs row via trackMessage
    // — accept it broadly here so tests can focus on state mutation assertions.
    $campaign->shouldReceive('trackMessage')->byDefault();

    $subscriber = Mockery::mock(Subscriber::class);
    $subscriber->shouldReceive('getEmail')->andReturn('[email protected]');

    $ctx = new SendContext(
        campaign: $campaign,
        subscriber: $subscriber,
        servers: null,
    );
    // Default result so trackMessage's first arg type-check passes; tests that
    // care about specific result status override.
    $ctx->result = new \App\SendingServers\Drivers\SendResult(
        runtimeMessageId: 'rt-default',
        status: \App\SendingServers\Drivers\DeliveryStatus::SENT,
    );

    return $ctx;
}

/**
 * AutoTrigger stub with public attribute writes (Mockery's setAttribute
 * intercept makes tests painful; v2 lifecycle writes attributes directly).
 */
function makeTriggerStub(string $currentNodeId = 'node-1', ?array $state = []): AutoTrigger
{
    $trigger = new AutoTrigger();
    $trigger->id = 42;
    $trigger->current_node_id = $currentNodeId;
    $trigger->state = $state;
    $trigger->phase = Phase::Running->value;
    $trigger->held_at = null;
    $trigger->scheduled_at = null;
    $trigger->error = null;

    return $trigger;
}

it('onSent records sent_at, drains the FlowExecutor, and saves', function () {
    $trigger = makeTriggerStub('node-send-1', state: []);

    // FlowExecutor is `final` so Mockery::mock(FlowExecutor::class) fails.
    // Bind an anonymous duck-typed stub on the container — the lifecycle's
    // `app(FlowExecutor::class)` call returns mixed, so PHP doesn't enforce
    // the declared return type at the binding point. Stub returns Done so
    // the bounded drain loop exits on the first iteration.
    $this->app->instance(FlowExecutor::class, new class {
        public int $calls = 0;
        public function advance($trigger): Phase
        {
            $this->calls++;
            return Phase::Completed;
        }
        public function drain($trigger, int $maxSteps = 32): Phase
        {
            return $this->advance($trigger);
        }
    });

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('appendHistory')->once();
    $trigger->shouldReceive('save')->once();

    (new AutomationSendLifecycle($trigger, 'node-send-1'))->onSent(makeAutomationCtx());

    expect($trigger->state)->toHaveKey('sent_at');
    expect($trigger->scheduled_at)->toBeNull();
    expect($trigger->phase)->toBe(Phase::Running->value);
});

it('onSent drops the callback when the trigger is held by an admin', function () {
    $trigger = makeTriggerStub('node-send-1');
    $trigger->held_at = now();

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldNotReceive('save');
    $trigger->shouldNotReceive('appendHistory');

    (new AutomationSendLifecycle($trigger, 'node-send-1'))->onSent(makeAutomationCtx());
});

it('onSent drops a stale callback (trigger advanced past this node)', function () {
    $trigger = makeTriggerStub('node-2');     // already moved past node-1

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldNotReceive('save');
    $trigger->shouldNotReceive('appendHistory');

    (new AutomationSendLifecycle($trigger, 'node-1'))->onSent(makeAutomationCtx());
});

it('onRateLimitExceeded sets delay_note in state and releases for 600s', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('save')->once();

    $control = new RecordingControlForLifecycle();

    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onRateLimitExceeded(makeAutomationCtx(), new RateLimitExceeded('throttled'), $control);

    expect($control->releasedAfter)->toBe(600);
    expect($trigger->state)->toHaveKey('delay_note');
    expect($trigger->state['delay_note'])->toContain('Delayed for 600 seconds');
});

it('onWarmupQuotaExceeded sets delay_note in state and releases for the warmup retry-at delay', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('save')->once();

    $control = new RecordingControlForLifecycle();

    $ex = (new \App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded('quota'))
        ->setRetryAt(now()->addSeconds(300));

    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onWarmupQuotaExceeded(makeAutomationCtx(), $ex, $control);

    expect($control->releasedAfter)->toBeGreaterThanOrEqual(60);    // floor enforced by getDelayInSeconds
    expect($trigger->state)->toHaveKey('delay_note');
    expect($trigger->state['delay_note'])->toContain('Warmup daily quota hit');
});

it('onUnknownException records error and transitions trigger to Phase::Failed', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('appendHistory')->once();
    $trigger->shouldReceive('save')->once();

    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onUnknownException(makeAutomationCtx(), new \RuntimeException('boom'), new RecordingControlForLifecycle());

    expect($trigger->state)->toHaveKey('error');
    expect($trigger->state['error'])->toBe('boom');
    expect($trigger->phase)->toBe(Phase::Failed->value);
    expect($trigger->error)->toBe('boom');
});

it('onUnknownException drops the callback when the trigger is held (no save)', function () {
    $trigger = makeTriggerStub('node-send-1');
    $trigger->held_at = now();

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldNotReceive('save');

    // No exception, no save — admin's hold takes precedence.
    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onUnknownException(makeAutomationCtx(), new \RuntimeException('boom'), new RecordingControlForLifecycle());
});

it('onUnknownException ignores stale node mismatch (no save)', function () {
    $trigger = makeTriggerStub('node-2');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldNotReceive('save');

    (new AutomationSendLifecycle($trigger, 'node-1'))
        ->onUnknownException(makeAutomationCtx(), new \RuntimeException('boom'), new RecordingControlForLifecycle());
});

it('onUnknownException writes a tracking_logs row when a server was selected (analytics parity)', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('appendHistory')->once();
    $trigger->shouldReceive('save')->once();

    // Build context WITH a selected server — lifecycle should call trackMessage.
    $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 \App\Library\Sending\SendContext(
        campaign: $campaign,
        subscriber: $subscriber,
        servers: null,
    );
    $ctx->selectedServer = Mockery::mock(\App\Model\SendingServer::class);

    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onUnknownException($ctx, new \RuntimeException('boom'), new RecordingControlForLifecycle());
});

it('onOutOfCredits marks Phase::Failed with credit error and never throws', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('appendHistory')
        ->withArgs(function ($history) {
            return $history['outcome'] === 'out_of_credits';
        })
        ->once();
    $trigger->shouldReceive('save')->once();

    $ex = new \App\Library\Exception\OutOfCredits('customer ran out');

    // No throw — automation never throws on OutOfCredits (queue retry would loop).
    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onOutOfCredits(makeAutomationCtx(), $ex, new RecordingControlForLifecycle());

    expect($trigger->phase)->toBe(Phase::Failed->value);
    expect($trigger->state)->toHaveKey('error');
    expect($trigger->state['error'])->toContain('Customer out of send credits');
});

it('onOutOfCredits drops the callback when the trigger is held', function () {
    $trigger = makeTriggerStub('node-send-1');
    $trigger->held_at = now();

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldNotReceive('save');

    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onOutOfCredits(makeAutomationCtx(), new \App\Library\Exception\OutOfCredits('quota'), new RecordingControlForLifecycle());
});

it('onUnknownException skips tracking_logs when no server was selected (no row to write)', function () {
    $trigger = makeTriggerStub('node-send-1');

    $trigger = Mockery::mock($trigger)->makePartial();
    $trigger->shouldReceive('appendHistory')->once();
    $trigger->shouldReceive('save')->once();

    $campaign = Mockery::mock(Campaign::class);
    $campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
    $campaign->shouldNotReceive('trackMessage');    // selectedServer null → no tracking row

    $subscriber = Mockery::mock(Subscriber::class);
    $subscriber->shouldReceive('getEmail')->andReturn('[email protected]');

    $ctx = new \App\Library\Sending\SendContext(
        campaign: $campaign,
        subscriber: $subscriber,
        servers: null,
    );
    // selectedServer stays null

    // No throw — Automation never throws (Phase::Failed is canonical failure record).
    (new AutomationSendLifecycle($trigger, 'node-send-1'))
        ->onUnknownException($ctx, new \RuntimeException('pre-server-boom'), new RecordingControlForLifecycle());
});