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