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