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