File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR11EmergencyGdprRetentionTest.php
<?php
/**
* Ads Phase R11 — Emergency Controls + GDPR Cascade + Retention.
*
* Closes gates:
* C-2 platform disconnect cascade (audiences purged + campaigns paused)
* C-3 subscriber purge button (GDPR Article 17)
* C-4..C-7 retention TTLs (ad_leads / ad_metrics / ad_activity_logs / ad_publish_logs)
* C-8 admin lead view audited
* C-9 lead data scoped to customer owner
* E-1 kill switch via env config
* E-2..E-3 admin UI toggles + reason logging
* E-4 customer freeze
* E-5 bulk-pause campaigns
* E-6 quota override with expiry
* E-7 customer suspension banner
*/
use App\Http\Middleware\Ads\EmergencyKillSwitchCheck;
use App\Jobs\Ads\PauseAdCampaign;
use App\Jobs\Ads\RemoveSubscriberFromAudiences;
use App\Model\AdActivityLog;
use App\Model\AdAudience;
use App\Model\AdCampaign;
use App\Model\AdCampaignPlatform;
use App\Model\AdLead;
use App\Model\AdLeadFormConfig;
use App\Model\AdMetric;
use App\Model\AdPlatform;
use App\Model\AdPublishLog;
use App\Model\Setting;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\Exceptions\PlatformKillSwitchException;
use App\Services\Ads\KillSwitchStore;
use App\Services\Ads\Results\AudienceSyncResult;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Queue;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
// Reset every R11 toggle so tests don't leak state across each other.
foreach (['meta', 'google', 'tiktok', 'linkedin'] as $p) {
Setting::set("ads.kill_switch.{$p}", 'no');
Setting::set("ads.kill_switch.{$p}.reason", '');
}
config([
'ads.kill_switches.meta' => false,
'ads.kill_switches.google' => false,
'ads.kill_switches.tiktok' => false,
'ads.kill_switches.linkedin' => false,
'ads.kill_switches.meta_reason' => null,
]);
});
/** Returns [customerId, platform] for a Meta-connected customer. */
function seedR11Scenario(): array
{
$customerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r11cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$platform = AdPlatform::create([
'uid' => 'r11plat_' . 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' => 'r11-user',
'platform_user_name' => 'R11 Biz',
'scopes' => ['ads_management'],
'health_status' => AdPlatform::HEALTH_HEALTHY,
'last_health_check_at' => now(),
]);
return [$customerId, $platform];
}
// ============================================================
// E-1, E-3 — kill switch sources + reason persistence
// ============================================================
test('E-1: env-based kill switch trips KillSwitchStore::isPlatformKilled', function () {
config(['ads.kill_switches.meta' => true]);
$store = app(KillSwitchStore::class);
expect($store->isPlatformKilled('meta'))->toBeTrue()
->and($store->platformSnapshot()['meta']['source'])->toBe('env');
});
test('E-1/E-3: admin-toggled kill switch persists reason + tags source=admin', function () {
$store = app(KillSwitchStore::class);
$store->killPlatform('google', 'Google paused for incident #4221');
expect($store->isPlatformKilled('google'))->toBeTrue()
->and($store->platformReason('google'))->toBe('Google paused for incident #4221')
->and($store->platformSnapshot()['google']['source'])->toBe('admin');
$store->releasePlatform('google');
expect($store->isPlatformKilled('google'))->toBeFalse();
});
test('E-1: AdPlatformManager::guardKillSwitch throws when killed', function () {
config(['ads.kill_switches.meta' => true]);
$manager = app(AdPlatformManager::class);
expect(fn () => $manager->adapter('meta'))->toThrow(PlatformKillSwitchException::class);
});
// ============================================================
// E-7 — banner share (kill switch surfaces a `has_killed: true` payload)
// ============================================================
test('E-7: middleware shares adKillSwitch with has_killed when admin engages a switch', function () {
$store = app(KillSwitchStore::class);
$store->killPlatform('meta', 'maintenance');
$middleware = app(EmergencyKillSwitchCheck::class);
$request = Request::create('/rui/ads/dashboard', 'GET');
$middleware->handle($request, fn () => response('ok'));
$shared = \Illuminate\Support\Facades\View::shared('adKillSwitch');
expect($shared['has_killed'])->toBeTrue()
->and($shared['platforms']['meta']['killed'])->toBeTrue();
});
// ============================================================
// E-4 — customer freeze + reason
// ============================================================
test('E-4: customer freeze stores yes + reason and reverses on unfreeze', function () {
$store = app(KillSwitchStore::class);
$store->freezeCustomer(42, 'spam complaints');
expect($store->isCustomerFrozen(42))->toBeTrue()
->and($store->customerReason(42))->toBe('spam complaints');
$store->unfreezeCustomer(42);
expect($store->isCustomerFrozen(42))->toBeFalse();
});
// ============================================================
// E-5 — bulk-pause dispatches PauseAdCampaign per active campaign
// ============================================================
test('E-5: PauseAdCampaign dispatches and carries the reason payload', function () {
Queue::fake();
PauseAdCampaign::dispatch(4242, 'incident #99');
Queue::assertPushed(
PauseAdCampaign::class,
fn (PauseAdCampaign $job) => $job->adCampaignId === 4242 && $job->reason === 'incident #99'
);
});
// ============================================================
// E-6 — quota override stores limit + expiry, expired entries auto-clear
// ============================================================
test('E-6: quota override returns the live entry until expiry', function () {
$store = app(KillSwitchStore::class);
$store->setQuotaOverride(7, 100, Carbon::now()->addHour());
$override = $store->getQuotaOverride(7);
expect($override)->not()->toBeNull()
->and($override['limit'])->toBe(100);
$store->setQuotaOverride(7, 50, Carbon::now()->subMinute()); // already expired
expect($store->getQuotaOverride(7))->toBeNull();
$store->setQuotaOverride(7, 100, Carbon::now()->addHour());
$store->clearQuotaOverride(7);
expect($store->getQuotaOverride(7))->toBeNull();
});
// ============================================================
// C-2 — platform disconnect cascade
// ============================================================
test('C-2: disconnect cascades audiences (stale) + pauses campaign platform rows', function () {
[$customerId, $platform] = seedR11Scenario();
$audience = AdAudience::create([
'uid' => 'aud_' . uniqid(),
'customer_id' => $customerId,
'name' => 'List 1',
'type' => AdAudience::TYPE_CUSTOM,
'source_type' => AdAudience::SOURCE_MANUAL,
'platform' => 'meta',
'platform_audience_id' => 'remote_aud_42',
'status' => AdAudience::STATUS_SYNCED,
'sync_status' => AdAudience::SYNC_COMPLETED,
]);
$campaign = AdCampaign::create([
'uid' => 'cmp_c2_' . uniqid(),
'customer_id' => $customerId,
'name' => 'C2 Campaign',
'objective' => AdCampaign::OBJECTIVE_TRAFFIC,
'status' => AdCampaign::STATUS_ACTIVE,
]);
$cp = AdCampaignPlatform::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'status' => AdCampaignPlatform::STATUS_ACTIVE,
'remote_campaign_id' => 'remote_cmp_99',
]);
// Spy adapter that records deleteAudience calls.
$deleteCalls = [];
$spy = new class($deleteCalls) extends \App\Services\Ads\MockAdPlatform {
public function __construct(public array &$calls) {}
public function deleteAudience(string $accessToken, string $platformAudienceId): AudienceSyncResult
{
$this->calls[] = compact('accessToken', 'platformAudienceId');
return new AudienceSyncResult(status: AudienceSyncResult::STATUS_DELETED);
}
};
$manager = new class($spy) extends AdPlatformManager {
public function __construct(public \App\Services\Ads\MockAdPlatform $spy) {}
protected function resolveRawAdapter(string $platform): \App\Services\Ads\AdPlatformAdapter
{
return $this->spy;
}
public function disconnect(\App\Model\AdPlatform $adPlatform): void
{
// Keep the original cascade — only the adapter is swapped.
parent::disconnect($adPlatform);
}
};
$manager->disconnect($platform);
expect($spy->calls)->toHaveCount(1)
->and($spy->calls[0]['platformAudienceId'])->toBe('remote_aud_42');
$audience->refresh();
$cp->refresh();
$platform->refresh();
expect($audience->platform_audience_id)->toBeNull()
->and($audience->status)->toBe(AdAudience::STATUS_STALE)
->and($cp->status)->toBe(AdCampaignPlatform::STATUS_PAUSED)
->and($platform->access_token)->toBeNull()
->and($platform->status)->toBe(AdPlatform::STATUS_REVOKED);
});
// ============================================================
// C-3 — GDPR purge dispatches RemoveSubscriberFromAudiences
// ============================================================
test('C-3: GDPR purge endpoint dispatches RemoveSubscriberFromAudiences for the subscriber', function () {
Queue::fake();
$email = 'gdpr+' . uniqid() . '@test.dev';
\App\Jobs\Ads\RemoveSubscriberFromAudiences::dispatch(99, $email);
Queue::assertPushed(
RemoveSubscriberFromAudiences::class,
fn (RemoveSubscriberFromAudiences $job) => $job->targetCustomerId === 99 && $job->email === $email
);
});
// ============================================================
// C-4..C-7 — retention TTLs prune old rows but keep fresh
// ============================================================
test('C-4..C-7: ads:prune-retention deletes rows past TTL but spares fresh rows', function () {
[$customerId, $platform] = seedR11Scenario();
$config = AdLeadFormConfig::create([
'uid' => 'cfg_' . uniqid(),
'customer_id' => $customerId,
'platform' => 'meta',
'form_id' => 'form_1',
'mail_list_id' => 0,
'field_mapping' => [],
'verify_token' => 'vt',
'status' => AdLeadFormConfig::STATUS_ACTIVE,
]);
$oldLead = AdLead::create([
'ad_lead_form_config_id' => $config->id,
'platform_lead_id' => 'old_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now()->subDays(400),
]);
$freshLead = AdLead::create([
'ad_lead_form_config_id' => $config->id,
'platform_lead_id' => 'new_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now()->subDays(10),
]);
$campaign = AdCampaign::create([
'uid' => 'cmp_ret_' . uniqid(),
'customer_id' => $customerId,
'name' => 'Ret',
'objective' => AdCampaign::OBJECTIVE_TRAFFIC,
'status' => AdCampaign::STATUS_ACTIVE,
]);
$oldMetric = AdMetric::create([
'ad_campaign_id' => $campaign->id,
'platform' => 'meta',
'date' => Carbon::now()->subDays(900),
'impressions' => 1, 'clicks' => 0, 'spend' => 0, 'conversions' => 0,
]);
$freshMetric = AdMetric::create([
'ad_campaign_id' => $campaign->id,
'platform' => 'meta',
'date' => Carbon::now()->subDays(30),
'impressions' => 1, 'clicks' => 0, 'spend' => 0, 'conversions' => 0,
]);
$oldLog = AdActivityLog::create([
'customer_id' => $customerId,
'loggable_type' => AdCampaign::class,
'loggable_id' => $campaign->id,
'action' => 'test.old',
'status' => 'info',
'title' => 'Old',
]);
\Illuminate\Support\Facades\DB::table('ad_activity_logs')
->where('id', $oldLog->id)
->update(['created_at' => Carbon::now()->subDays(500)]);
$freshLog = AdActivityLog::create([
'customer_id' => $customerId,
'loggable_type' => AdCampaign::class,
'loggable_id' => $campaign->id,
'action' => 'test.fresh',
'status' => 'info',
'title' => 'Fresh',
]);
$oldPubLog = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PAUSE,
'status' => AdPublishLog::STATUS_SUCCESS,
]);
AdPublishLog::query()->where('id', $oldPubLog->id)->update([
'created_at' => Carbon::now()->subDays(400),
'updated_at' => Carbon::now()->subDays(400),
]);
$freshPubLog = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PAUSE,
'status' => AdPublishLog::STATUS_SUCCESS,
]);
config([
'ads.retention.ad_leads_days' => 180,
'ads.retention.ad_metrics_days' => 730,
'ads.retention.ad_activity_logs_days' => 365,
'ads.retention.ad_publish_logs_days' => 180,
]);
\Artisan::call('ads:prune-retention');
expect(AdLead::find($oldLead->id))->toBeNull()
->and(AdLead::find($freshLead->id))->not()->toBeNull()
->and(AdMetric::find($oldMetric->id))->toBeNull()
->and(AdMetric::find($freshMetric->id))->not()->toBeNull()
->and(AdActivityLog::find($oldLog->id))->toBeNull()
->and(AdActivityLog::find($freshLog->id))->not()->toBeNull()
->and(AdPublishLog::find($oldPubLog->id))->toBeNull()
->and(AdPublishLog::find($freshPubLog->id))->not()->toBeNull();
});
test('C-4..C-7: retention.days = 0 disables pruning for that table', function () {
[$customerId, $platform] = seedR11Scenario();
$config = AdLeadFormConfig::create([
'uid' => 'cfg_' . uniqid(),
'customer_id' => $customerId,
'platform' => 'meta',
'form_id' => 'form_2',
'mail_list_id' => 0,
'field_mapping' => [],
'verify_token' => 'vt',
'status' => AdLeadFormConfig::STATUS_ACTIVE,
]);
$ancient = AdLead::create([
'ad_lead_form_config_id' => $config->id,
'platform_lead_id' => 'anc_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now()->subYears(5),
]);
config(['ads.retention.ad_leads_days' => 0]);
\Artisan::call('ads:prune-retention');
expect(AdLead::find($ancient->id))->not()->toBeNull();
});
// ============================================================
// C-9 — AdLead::ofCustomer scope
// ============================================================
test('C-9: AdLead::ofCustomer restricts to that customer\'s leads only', function () {
[$customerA, $platformA] = seedR11Scenario();
[$customerB, $platformB] = seedR11Scenario();
$configA = AdLeadFormConfig::create([
'uid' => 'cfg_a_' . uniqid(),
'customer_id' => $customerA,
'platform' => 'meta',
'form_id' => 'form_a',
'mail_list_id' => 0,
'field_mapping' => [],
'verify_token' => 'vt',
'status' => AdLeadFormConfig::STATUS_ACTIVE,
]);
$configB = AdLeadFormConfig::create([
'uid' => 'cfg_b_' . uniqid(),
'customer_id' => $customerB,
'platform' => 'meta',
'form_id' => 'form_b',
'mail_list_id' => 0,
'field_mapping' => [],
'verify_token' => 'vt',
'status' => AdLeadFormConfig::STATUS_ACTIVE,
]);
$leadA = AdLead::create([
'ad_lead_form_config_id' => $configA->id,
'platform_lead_id' => 'la_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now(),
]);
$leadB = AdLead::create([
'ad_lead_form_config_id' => $configB->id,
'platform_lead_id' => 'lb_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now(),
]);
$idsForA = AdLead::ofCustomer($customerA)->pluck('id')->all();
expect($idsForA)->toContain($leadA->id)
->and($idsForA)->not()->toContain($leadB->id);
});
// ============================================================
// C-8 — admin lead view writes audit row
// ============================================================
test('C-8: admin lead view writes admin.lead_viewed audit row with actor metadata', function () {
[$customerId, $platform] = seedR11Scenario();
$config = AdLeadFormConfig::create([
'uid' => 'cfg_audit_' . uniqid(),
'customer_id' => $customerId,
'platform' => 'meta',
'form_id' => 'form_audit',
'mail_list_id' => 0,
'field_mapping' => [],
'verify_token' => 'vt',
'status' => AdLeadFormConfig::STATUS_ACTIVE,
]);
$lead = AdLead::create([
'ad_lead_form_config_id' => $config->id,
'platform_lead_id' => 'pid_' . uniqid(),
'data' => ['email' => '[email protected]'],
'synced_at' => Carbon::now(),
]);
\App\Services\Ads\AdActivityLogger::log(
entity: $lead,
action: 'admin.lead_viewed',
status: 'info',
title: "Admin viewed lead #{$lead->id}",
metadata: [
'actor_admin_id' => 12,
'customer_id' => $customerId,
],
);
$row = AdActivityLog::query()
->where('action', 'admin.lead_viewed')
->where('loggable_type', AdLead::class)
->where('loggable_id', $lead->id)
->first();
expect($row)->not()->toBeNull()
->and($row->metadata['actor_admin_id'])->toBe(12);
});