File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR13AutomationRulesTest.php
<?php
/**
* Ads Phase R13 — Automation Rules execution path.
*
* Verifies:
* 1. Master flag `ads.automation.enabled=false` → listener skips dispatch
* 2. Throttle: 11th dispatch in 60s window is rejected with audit row
* 3. Idempotency: same (rule, subscriber, eventTimestamp) collapses to one execution
* 4. Concurrency lock: second job for same rule returns no-op while first is running
* 5. is_running flag toggles around execution + auto-clears on failure
* 6. Dry-run mode logs INFO audit + skips action dispatch
* 7. Action dispatch by type:
* - add_to_audience → adapter syncAudience(OPERATION_ADD)
* - remove_from_audience → adapter syncAudience(OPERATION_REMOVE)
* - start_campaign → AdPublishingService::resume()
* - pause_campaign → AdPublishingService::pause()
* 8. simulate() returns shape without dispatching the job
* 9. setDryRun() audits the toggle + leaves rule unchanged on no-op
*
* Uses DatabaseTransactions to keep dev DB intact; uses a spy adapter via
* AdPlatformManager swap to assert syncAudience was called with the right
* shape.
*/
use App\Dto\Ads\SyncAudienceDto;
use App\Events\MailListUnsubscription;
use App\Jobs\Ads\ExecuteAdAutomationAction;
use App\Listeners\Ads\RunAutomationOnUnsubscribe;
use App\Model\AdActivityLog;
use App\Model\AdAudience;
use App\Model\AdAutomationRule;
use App\Model\AdCampaign;
use App\Model\AdPlatform;
use App\Services\Ads\AdAutomationService;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\AdPublishingService;
use App\Services\Ads\Results\AudienceSyncResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
config(['ads.automation.enabled' => true]);
config(['ads.automation.throttle_per_minute' => 10]);
Cache::flush();
});
/**
* Build a fresh customer + Meta platform + audience all wired up so an
* automation rule with action_type=add_to_audience or remove_from_audience
* can find a real target.
*
* @return array{customerId:int, platform:AdPlatform, audience:AdAudience, subscriber:\App\Model\Subscriber, mailList:\App\Model\MailList}
*/
function r13Scenario(): array
{
$customerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r13cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$platform = AdPlatform::create([
'uid' => 'r13plat_' . uniqid(),
'customer_id' => $customerId,
'platform' => AdPlatform::PLATFORM_META,
'name' => 'Meta',
'access_token' => 'tok',
'refresh_token' => 'refresh',
'token_expires_at' => now()->addDays(30),
'platform_user_id' => 'r13-user',
'platform_user_name' => 'R13 Biz',
'scopes' => ['ads_management'],
'health_status' => AdPlatform::HEALTH_HEALTHY,
'last_health_check_at' => now(),
]);
$audience = AdAudience::create([
'uid' => 'r13aud_' . uniqid(),
'customer_id' => $customerId,
'name' => 'R13 Audience',
'type' => 'custom',
'platform' => AdPlatform::PLATFORM_META,
'platform_audience_id' => 'meta_aud_999',
'source_type' => 'list',
'source_id' => 1,
'size_estimate' => 100,
'status' => AdAudience::STATUS_SYNCED,
]);
$mailListId = (int) \DB::table('mail_lists')->insertGetId([
'uid' => 'r13ml_' . uniqid(),
'customer_id' => $customerId,
'name' => 'R13 List',
'from_email' => '[email protected]',
'from_name' => 'R13',
'created_at' => now(),
'updated_at' => now(),
]);
$mailList = \App\Model\MailList::find($mailListId);
$subscriberId = (int) \DB::table('subscribers')->insertGetId([
'uid' => 'r13sub_' . uniqid(),
'mail_list_id' => $mailListId,
'email' => 'r13test_' . uniqid() . '@example.com',
'status' => 'subscribed',
'created_at' => now(),
'updated_at' => now(),
]);
$subscriber = \App\Model\Subscriber::find($subscriberId);
return compact('customerId', 'platform', 'audience', 'subscriber', 'mailList');
}
/**
* Spy adapter that extends MockAdPlatform and captures syncAudience calls.
*/
class R13Spy extends \App\Services\Ads\MockAdPlatform
{
/** @var array<int, array{accessToken: string, adAccountId: string, data: SyncAudienceDto, idempotencyKey: string}> */
public array $syncCalls = [];
public function syncAudience(string $accessToken, string $adAccountId, SyncAudienceDto $data, string $idempotencyKey): AudienceSyncResult
{
$this->syncCalls[] = compact('accessToken', 'adAccountId', 'data', 'idempotencyKey');
return new AudienceSyncResult(
status: AudienceSyncResult::STATUS_SUCCESS,
platformAudienceId: $data->platformAudienceId,
uploadedCount: count($data->hashedEmails),
);
}
}
function bindR13Spy(): R13Spy
{
$spy = new R13Spy();
$manager = new class($spy) extends AdPlatformManager {
public function __construct(public R13Spy $spy) {}
public function session(AdPlatform $adPlatform): \App\Services\Ads\AdapterSession
{
return new \App\Services\Ads\AdapterSession($adPlatform, $this->spy);
}
public function useSessionPattern(): bool { return true; }
public function isMockMode(): bool { return true; }
};
app()->instance(AdPlatformManager::class, $manager);
return $spy;
}
// ============================================================
// 1. Master flag gating
// ============================================================
test('listener — master flag OFF skips dispatch entirely', function () {
Queue::fake();
config(['ads.automation.enabled' => false]);
$s = r13Scenario();
AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Skip-when-disabled',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
]);
(new RunAutomationOnUnsubscribe())->handle(new MailListUnsubscription($s['subscriber']));
Queue::assertNothingPushed();
});
test('listener — no rules matching customer + trigger_event → no dispatch', function () {
Queue::fake();
$s = r13Scenario();
(new RunAutomationOnUnsubscribe())->handle(new MailListUnsubscription($s['subscriber']));
Queue::assertNothingPushed();
});
test('listener — paused rule is NOT dispatched', function () {
Queue::fake();
$s = r13Scenario();
AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Paused rule',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'paused',
]);
(new RunAutomationOnUnsubscribe())->handle(new MailListUnsubscription($s['subscriber']));
Queue::assertNothingPushed();
});
// ============================================================
// 2. Listener fires the job
// ============================================================
test('listener — dispatches ExecuteAdAutomationAction for each matching active rule', function () {
Queue::fake();
$s = r13Scenario();
$r1 = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Rule A',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
]);
$r2 = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Rule B (dry)',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => true,
]);
(new RunAutomationOnUnsubscribe())->handle(new MailListUnsubscription($s['subscriber']));
Queue::assertPushed(ExecuteAdAutomationAction::class, 2);
Queue::assertPushed(ExecuteAdAutomationAction::class, fn ($job) =>
$job->ruleId === $r1->id && $job->dryRun === false
);
Queue::assertPushed(ExecuteAdAutomationAction::class, fn ($job) =>
$job->ruleId === $r2->id && $job->dryRun === true
);
});
// ============================================================
// 3. Throttle gate
// ============================================================
test('listener — 11th dispatch in 60s window is throttled + audited', function () {
Queue::fake();
config(['ads.automation.throttle_per_minute' => 3]); // tighter for test speed
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Throttled rule',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
]);
// Fire the listener 5 times — only 3 should dispatch (throttle = 3).
for ($i = 0; $i < 5; $i++) {
(new RunAutomationOnUnsubscribe())->handle(new MailListUnsubscription($s['subscriber']));
}
Queue::assertPushed(ExecuteAdAutomationAction::class, 3);
$throttleAudits = AdActivityLog::where('action', 'automation.throttled')
->where('loggable_id', $rule->id)
->count();
// One audit row for the rule (not one per rejected event — Cache::add suppresses dups).
expect($throttleAudits)->toBe(1);
});
// ============================================================
// 4. Idempotency
// ============================================================
test('job — idempotency key dedupes same (rule, subscriber, eventTs) on retry', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Idempotent',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
]);
$spy = bindR13Spy();
$eventTs = now()->getTimestamp();
// Run the same job parameters twice.
$job1 = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, $eventTs);
$job1->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
$job2 = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, $eventTs);
$job2->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect($spy->syncCalls)->toHaveCount(1); // second call deduped
});
// ============================================================
// 5. Dry-run mode
// ============================================================
test('job — dry_run=true logs INFO audit, skips adapter call', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Dry-run',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => true,
]);
$spy = bindR13Spy();
$job = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, now()->getTimestamp(), dryRun: true);
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect($spy->syncCalls)->toBeEmpty();
$audit = AdActivityLog::where('action', 'automation.dry_run')
->where('loggable_id', $rule->id)
->first();
expect($audit)->not->toBeNull();
expect($audit->status)->toBe(AdActivityLog::STATUS_INFO);
expect($audit->title)->toContain('[DRY RUN]');
expect($audit->metadata['dry_run'])->toBeTrue();
});
// ============================================================
// 6. is_running flag lifecycle
// ============================================================
test('job — is_running toggles to false after successful execution + executions_count increments', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'is_running test',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
'executions_count' => 5,
]);
bindR13Spy();
$job = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, now()->getTimestamp());
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
$rule->refresh();
expect($rule->is_running)->toBeFalse();
expect($rule->executions_count)->toBe(6);
expect($rule->last_triggered_at)->not->toBeNull();
});
// ============================================================
// 7. Action dispatch by type
// ============================================================
test('job — action_type=add_to_audience calls adapter with OPERATION_ADD', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Add',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
]);
$spy = bindR13Spy();
$job = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, now()->getTimestamp());
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect($spy->syncCalls)->toHaveCount(1);
expect($spy->syncCalls[0]['data']->operation)->toBe(SyncAudienceDto::OPERATION_ADD);
expect($spy->syncCalls[0]['data']->hashedEmails[0])
->toBe(hash('sha256', strtolower(trim($s['subscriber']->email))));
});
test('job — action_type=remove_from_audience calls adapter with OPERATION_REMOVE', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Remove',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
]);
$spy = bindR13Spy();
$job = new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, now()->getTimestamp());
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect($spy->syncCalls)->toHaveCount(1);
expect($spy->syncCalls[0]['data']->operation)->toBe(SyncAudienceDto::OPERATION_REMOVE);
});
test('job — cross-tenant audience target_id throws InvalidArgumentException + audits ERROR', function () {
$s = r13Scenario();
// Create an audience that belongs to a DIFFERENT customer.
$otherCustomerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r13other_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$otherAudience = AdAudience::create([
'uid' => 'r13aud_' . uniqid(),
'customer_id' => $otherCustomerId,
'name' => 'Cross-tenant',
'type' => 'custom',
'platform' => AdPlatform::PLATFORM_META,
'platform_audience_id' => 'cross_tenant',
'source_type' => 'list',
'source_id' => 1,
'size_estimate' => 0,
'status' => AdAudience::STATUS_SYNCED,
]);
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Cross-tenant attempt',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $otherAudience->id, // ← belongs to OTHER customer
'status' => 'active',
'dry_run' => false,
]);
bindR13Spy();
expect(fn () => (new ExecuteAdAutomationAction($rule->id, $s['subscriber']->id, now()->getTimestamp()))
->handle(app(AdPlatformManager::class), app(AdPublishingService::class)))
->toThrow(\InvalidArgumentException::class);
$errorAudit = AdActivityLog::where('action', 'automation.failed')
->where('loggable_id', $rule->id)
->first();
expect($errorAudit)->not->toBeNull();
expect($errorAudit->status)->toBe(AdActivityLog::STATUS_ERROR);
});
// ============================================================
// 8. Service.simulate() preview
// ============================================================
test('service — simulate() returns target audience info without dispatching job', function () {
Queue::fake();
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Simulate me',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
]);
$sim = app(AdAutomationService::class)->simulate($rule, $s['subscriber']);
expect($sim['action'])->toBe(AdAutomationRule::ACTION_ADD_TO_AUDIENCE);
expect($sim['target']['type'])->toBe('audience');
expect($sim['target']['id'])->toBe((int) $s['audience']->id);
expect($sim['would_skip'])->toBeFalse();
expect($sim['reason'])->toBeNull();
Queue::assertNothingPushed();
});
test('service — simulate() flags would_skip when audience not synced', function () {
$s = r13Scenario();
$s['audience']->platform_audience_id = null;
$s['audience']->save();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Unsynced target',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_ADD_TO_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
]);
$sim = app(AdAutomationService::class)->simulate($rule, $s['subscriber']);
expect($sim['would_skip'])->toBeTrue();
expect($sim['reason'])->toBe('audience_not_synced_to_platform');
});
// ============================================================
// 9. setDryRun toggle audit
// ============================================================
test('service — setDryRun flips flag + writes audit row', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Toggle me',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => true,
]);
app(AdAutomationService::class)->setDryRun($rule, false);
$rule->refresh();
expect($rule->dry_run)->toBeFalse();
$audit = AdActivityLog::where('action', 'automation.dry_run_disabled')
->where('loggable_id', $rule->id)
->first();
expect($audit)->not->toBeNull();
});
test('service — setDryRun no-op when value unchanged → no audit row', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'Already dry',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => true,
]);
app(AdAutomationService::class)->setDryRun($rule, true); // same value
$audits = AdActivityLog::where('action', 'like', 'automation.dry_run_%')
->where('loggable_id', $rule->id)
->count();
expect($audits)->toBe(0);
});
// ============================================================
// 10. Job tolerates missing rule / subscriber
// ============================================================
test('job — missing rule (deleted between dispatch + execute) → silent no-op', function () {
bindR13Spy();
$job = new ExecuteAdAutomationAction(
ruleId: 9999999,
subscriberId: 1,
eventTimestamp: now()->getTimestamp(),
);
// Should not throw.
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect(true)->toBeTrue(); // reached without exception
});
test('job — missing subscriber → INFO audit, no exception, no adapter call', function () {
$s = r13Scenario();
$rule = AdAutomationRule::create([
'uid' => 'r13r_' . uniqid(),
'customer_id' => $s['customerId'],
'name' => 'No subscriber',
'trigger_event' => AdAutomationRule::TRIGGER_EMAIL_UNSUBSCRIBE,
'action_type' => AdAutomationRule::ACTION_REMOVE_FROM_AUDIENCE,
'target_id' => $s['audience']->id,
'status' => 'active',
'dry_run' => false,
]);
$spy = bindR13Spy();
$job = new ExecuteAdAutomationAction($rule->id, 9999999, now()->getTimestamp());
$job->handle(app(AdPlatformManager::class), app(AdPublishingService::class));
expect($spy->syncCalls)->toBeEmpty();
$audit = AdActivityLog::where('action', 'automation.skipped_no_subscriber')
->where('loggable_id', $rule->id)
->first();
expect($audit)->not->toBeNull();
});