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