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

/**
 * Ads Phase R6 — Audience Sync + GDPR cascade test suite.
 *
 * Closes gate C-1 (customer cascade delete from ad audiences when subscriber
 * unsubscribes from email) via:
 *   1. SHA-256 lowercase-trimmed email hashing (matches Meta/Google/TikTok/
 *      LinkedIn spec)
 *   2. SyncAdAudience job — create remote audience + chunked upload +
 *      STATUS_SYNCED on success, STATUS_ERROR on fail
 *   3. SubscriberOptOutListener dispatches RemoveSubscriberFromAudiences on
 *      MailListUnsubscription
 *   4. Listener respects `ads.compliance.gdpr_remove_on_unsubscribe` flag
 *      (default ON; flag OFF skips dispatch entirely)
 *   5. EventServiceProvider has the listener wired
 *
 * Spy-on-chain approach: we swap `AdPlatformManager` in the container with a
 * subclass that captures `session()->chain->syncAudience()` calls.  Lets us
 * assert DTO content (operation, hashedEmails, idempotencyKey) without real
 * adapter side-effects.
 */

use App\Dto\Ads\SyncAudienceDto;
use App\Events\MailListUnsubscription;
use App\Jobs\Ads\RemoveSubscriberFromAudiences;
use App\Jobs\Ads\SyncAdAudience;
use App\Listeners\Ads\SubscriberOptOutListener;
use App\Model\AdAudience;
use App\Model\AdPlatform;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\Results\AudienceSyncResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;

uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);

beforeEach(function () {
    config(['ads.hardening.use_session_pattern' => true]);
    config(['ads.compliance.gdpr_remove_on_unsubscribe' => true]);
});

/**
 * Minimal customer + meta platform for R6 tests.  Bypasses CustomerFactory
 * (which nests User::factory() — doesn't work for App\Model\User).
 *
 * @return array{0:int, 1:AdPlatform}
 */
function seedR6Scenario(): array
{
    $customerId = (int) \DB::table('customers')->insertGetId([
        'uid' => 'r6cus_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $platform = AdPlatform::create([
        'uid' => 'r6plat_' . 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' => 'r6-user',
        'platform_user_name' => 'R6 Biz',
        'scopes' => ['ads_management'],
        'health_status' => AdPlatform::HEALTH_HEALTHY,
        'last_health_check_at' => now(),
    ]);
    return [$customerId, $platform];
}

/**
 * Build a Subscriber model instance with email + mailList relation stubbed in
 * memory.  Avoids persisting anything (Subscriber + MailList factory wiring is
 * not relevant to the listener logic we're testing).
 */
function makeR6Subscriber(string $email, int $customerId): \App\Model\Subscriber
{
    $subscriber = new \App\Model\Subscriber(['email' => $email]);
    $mailList = new \App\Model\MailList();
    $mailList->customer_id = $customerId;
    $subscriber->setRelation('mailList', $mailList);
    return $subscriber;
}

/**
 * Spy adapter that extends MockAdPlatform and overrides only syncAudience
 * to capture arguments.  Wrapping in AdapterSession is cheap this way
 * because the spy IS an AdPlatformAdapter.
 */
class R6SyncAudienceSpy extends \App\Services\Ads\MockAdPlatform
{
    /** @var array<int, array{accessToken: string, adAccountId: string, data: SyncAudienceDto, idempotencyKey: string}> */
    public array $calls = [];
    public ?string $nextPlatformAudienceId = null;

    public function syncAudience(string $accessToken, string $adAccountId, SyncAudienceDto $data, string $idempotencyKey): AudienceSyncResult
    {
        $this->calls[] = compact('accessToken', 'adAccountId', 'data', 'idempotencyKey');
        return new AudienceSyncResult(
            status: AudienceSyncResult::STATUS_SUCCESS,
            platformAudienceId: $data->platformAudienceId ?? $this->nextPlatformAudienceId,
            matchedCount: max(0, (int) floor(count($data->hashedEmails) * 0.9)),
            uploadedCount: count($data->hashedEmails),
        );
    }
}

/**
 * Swap AdPlatformManager with a stub that returns an AdapterSession wrapping
 * the spy.  Returns the spy so tests can assert on captured calls.
 */
function bindR6ChainSpy(): R6SyncAudienceSpy
{
    $spy = new R6SyncAudienceSpy();
    $spy->nextPlatformAudienceId = 'spy_aud_' . uniqid();

    $manager = new class($spy) extends AdPlatformManager {
        public function __construct(public R6SyncAudienceSpy $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. Hash correctness
// ============================================================

test('hashEmails — SHA-256 of lowercase trimmed email', function () {
    $hashed = SyncAdAudience::hashEmails(['  [email protected]  ', '[email protected]']);

    expect($hashed[0])->toBe(hash('sha256', '[email protected]'))
        ->and($hashed[1])->toBe(hash('sha256', '[email protected]'));
});

test('hashEmails — skips empty entries', function () {
    $hashed = SyncAdAudience::hashEmails(['', '   ', '[email protected]', null]);
    expect($hashed)->toHaveCount(1);
});

test('hashEmails — deterministic: same input → identical output', function () {
    $a = SyncAdAudience::hashEmails(['[email protected]', '[email protected]']);
    $b = SyncAdAudience::hashEmails(['[email protected]', '[email protected]']);
    expect($a)->toBe($b);
});

// ============================================================
// 2. SyncAdAudience job
// ============================================================

test('SyncAdAudience — creates remote audience when platform_audience_id is null', function () {
    [$customerId, $platform] = seedR6Scenario();
    $spy = bindR6ChainSpy();
    $spy->nextPlatformAudienceId = 'aud_new_r6';

    $audience = AdAudience::create([
        'uid' => 'r6_aud_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Test Audience',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);

    (new SyncAdAudience($audience->id))->handle(app(AdPlatformManager::class));

    // First call → create
    expect($spy->calls)->not->toBeEmpty();
    $createCall = $spy->calls[0];
    expect($createCall['data']->platformAudienceId)->toBeNull()
        ->and($createCall['idempotencyKey'])->toBe('create:' . $audience->uid);

    $audience->refresh();
    expect($audience->platform_audience_id)->toBe('aud_new_r6')
        ->and($audience->status)->toBe(AdAudience::STATUS_SYNCED);
});

test('SyncAdAudience — skips create when platform_audience_id already set', function () {
    [$customerId, $platform] = seedR6Scenario();
    $spy = bindR6ChainSpy();

    $audience = AdAudience::create([
        'uid' => 'r6_aud_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Pre-synced',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'platform_audience_id' => 'already_synced_id',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);

    (new SyncAdAudience($audience->id))->handle(app(AdPlatformManager::class));

    // No call should use `create:` idempotency key
    $createKeys = array_filter($spy->calls, fn ($c) => str_starts_with($c['idempotencyKey'], 'create:'));
    expect($createKeys)->toBeEmpty();
});

test('SyncAdAudience — marks audience ERROR when no platform connected', function () {
    [$customerId, $platform] = seedR6Scenario();
    bindR6ChainSpy();
    // Delete the platform so resolvePlatform returns null
    $platform->delete();

    $audience = AdAudience::create([
        'uid' => 'r6_orphan_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Orphan',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);

    (new SyncAdAudience($audience->id))->handle(app(AdPlatformManager::class));

    $audience->refresh();
    expect($audience->status)->toBe(AdAudience::STATUS_ERROR)
        ->and($audience->sync_status)->toBe(AdAudience::SYNC_FAILED)
        ->and($audience->sync_error)->toContain('No connected');
});

test('SyncAdAudience — idempotency keys are deterministic per chunk index', function () {
    [$customerId, $platform] = seedR6Scenario();
    $spy = bindR6ChainSpy();

    $audience = AdAudience::create([
        'uid' => 'r6_det_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Det',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL, // empty list → no ADD calls
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);

    (new SyncAdAudience($audience->id))->handle(app(AdPlatformManager::class));

    // With SOURCE_MANUAL + no subscribers, only the create call fires
    $addCalls = array_filter($spy->calls, fn ($c) => str_starts_with($c['idempotencyKey'], 'add:'));
    expect($addCalls)->toBeEmpty();
});

// ============================================================
// 3. RemoveSubscriberFromAudiences job
// ============================================================

test('RemoveSubscriberFromAudiences — REMOVE op with hashed email for every audience', function () {
    [$customerId, $platform] = seedR6Scenario();
    $spy = bindR6ChainSpy();

    // Create 2 audiences, both with platform_audience_id set
    $aud1 = AdAudience::create([
        'uid' => 'r6_rem_1_' . uniqid(), 'customer_id' => $customerId,
        'name' => 'A1', 'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL, 'platform' => 'meta',
        'platform_audience_id' => 'aud_existing_1',
        'status' => AdAudience::STATUS_SYNCED, 'sync_status' => AdAudience::SYNC_COMPLETED,
    ]);
    $aud2 = AdAudience::create([
        'uid' => 'r6_rem_2_' . uniqid(), 'customer_id' => $customerId,
        'name' => 'A2', 'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL, 'platform' => 'meta',
        'platform_audience_id' => 'aud_existing_2',
        'status' => AdAudience::STATUS_SYNCED, 'sync_status' => AdAudience::SYNC_COMPLETED,
    ]);

    $email = '[email protected]';
    (new RemoveSubscriberFromAudiences($customerId, $email))
        ->handle(app(AdPlatformManager::class));

    expect($spy->calls)->toHaveCount(2);
    $expectedHash = hash('sha256', '[email protected]');
    foreach ($spy->calls as $call) {
        expect($call['data']->operation)->toBe(SyncAudienceDto::OPERATION_REMOVE)
            ->and($call['data']->hashedEmails)->toBe([$expectedHash])
            ->and($call['idempotencyKey'])->toContain('remove:')
            ->and($call['idempotencyKey'])->toContain($expectedHash);
    }
});

test('RemoveSubscriberFromAudiences — skips audiences without platform_audience_id', function () {
    [$customerId, $platform] = seedR6Scenario();
    $spy = bindR6ChainSpy();

    // One un-synced, one synced
    AdAudience::create([
        'uid' => 'r6_rem_unsync_' . uniqid(), 'customer_id' => $customerId,
        'name' => 'Un', 'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL, 'platform' => 'meta',
        'platform_audience_id' => null,
        'status' => AdAudience::STATUS_DRAFT, 'sync_status' => AdAudience::SYNC_PENDING,
    ]);
    AdAudience::create([
        'uid' => 'r6_rem_sync_' . uniqid(), 'customer_id' => $customerId,
        'name' => 'Syn', 'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL, 'platform' => 'meta',
        'platform_audience_id' => 'aud_s',
        'status' => AdAudience::STATUS_SYNCED, 'sync_status' => AdAudience::SYNC_COMPLETED,
    ]);

    (new RemoveSubscriberFromAudiences($customerId, '[email protected]'))->handle(app(AdPlatformManager::class));

    expect($spy->calls)->toHaveCount(1);
});

// ============================================================
// 4. SubscriberOptOutListener — flag-gated dispatch
// ============================================================

test('listener dispatches RemoveSubscriberFromAudiences when flag ON', function () {
    config(['ads.compliance.gdpr_remove_on_unsubscribe' => true]);
    Queue::fake();

    $subscriber = makeR6Subscriber('[email protected]', 77);
    $event = new MailListUnsubscription($subscriber);

    (new SubscriberOptOutListener())->handle($event);

    Queue::assertPushed(RemoveSubscriberFromAudiences::class, function ($job) {
        return $job->targetCustomerId === 77 && $job->email === '[email protected]';
    });
});

test('listener does NOT dispatch when flag OFF', function () {
    config(['ads.compliance.gdpr_remove_on_unsubscribe' => false]);
    Queue::fake();

    $subscriber = makeR6Subscriber('[email protected]', 77);
    (new SubscriberOptOutListener())->handle(new MailListUnsubscription($subscriber));

    Queue::assertNothingPushed();
});

test('listener short-circuits when subscriber email is empty', function () {
    Queue::fake();
    $subscriber = makeR6Subscriber('', 77);
    (new SubscriberOptOutListener())->handle(new MailListUnsubscription($subscriber));
    Queue::assertNothingPushed();
});

// ============================================================
// 5. EventServiceProvider wiring
// ============================================================

test('EventServiceProvider registers SubscriberOptOutListener for MailListUnsubscription', function () {
    $provider = new \App\Providers\EventServiceProvider(app());
    $listens = $provider->listens();

    expect($listens)->toHaveKey(\App\Events\MailListUnsubscription::class);
    expect($listens[\App\Events\MailListUnsubscription::class])
        ->toContain(\App\Listeners\Ads\SubscriberOptOutListener::class);
});

test('MailListUnsubscription event fires registered SubscriberOptOutListener', function () {
    Event::fake([MailListUnsubscription::class]);

    // The Event::fake would intercept, so instead verify the listener is bound
    // by running the app's event dispatcher bootstrap.
    $listens = (new \App\Providers\EventServiceProvider(app()))->listens();
    expect($listens[MailListUnsubscription::class] ?? [])
        ->toContain(SubscriberOptOutListener::class);
});

// ============================================================
// 6. AdAudienceService::syncToPlatform dispatches the job
// ============================================================

test('AdAudienceService::syncToPlatform dispatches SyncAdAudience job when flag ON', function () {
    [$customerId, $platform] = seedR6Scenario();
    config(['ads.hardening.use_session_pattern' => true]);
    Queue::fake();

    $audience = AdAudience::create([
        'uid' => 'r6_svc_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Svc',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);

    app(\App\Services\Ads\AdAudienceService::class)->syncToPlatform($audience);

    Queue::assertPushed(SyncAdAudience::class, fn ($job) => $job->audienceId === $audience->id);
});