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/ai.naniguide.com/tests/Feature/AdsR1InterfaceExtensionTest.php
<?php

/**
 * Ads Phase R1 — Value Objects + Interface Extension test suite.
 *
 * Verifies:
 *   1. 6 Result VOs — readonly, isSuccess(), toArray() shape
 *   2. 3 DTOs — readonly, toArray() redacts PII (hashes + field values)
 *   3. Adapter interface surface — 18 methods total (8 pre-R1 + 10 R1)
 *   4. All 5 adapters implement all 18 methods (via trait or direct)
 *   5. Every stubbed method in each adapter throws LogicException
 *      with phase hint pointing to R2/R4/R12
 *   6. IdempotencyDecorator wraps 5 write methods via remember():
 *      - 2nd call with same key skips inner
 *      - different key calls inner again
 *   7. Reads (pullMetrics/getLeadForms/fetchLeads/deleteAudience) are NOT
 *      wrapped — inner called every time
 */

use App\Dto\Ads\ProcessAdLeadDto;
use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\AdPlatformAdapter;
use App\Services\Ads\Decorators\IdempotencyDecorator;
use App\Services\Ads\GoogleAdPlatform;
use App\Services\Ads\LinkedInAdPlatform;
use App\Services\Ads\MetaAdPlatform;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\Results\AudienceSyncResult;
use App\Services\Ads\Results\CatalogSyncResult;
use App\Services\Ads\Results\LeadFetchResult;
use App\Services\Ads\Results\MetricsResult;
use App\Services\Ads\Results\MetricsRow;
use App\Services\Ads\Results\PublishResult;
use App\Services\Ads\TikTokAdPlatform;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;

uses(Tests\TestCase::class);

// ============================================================
// 1. Result Value Objects
// ============================================================

test('PublishResult — readonly + status helpers + toArray', function () {
    $r = new PublishResult(
        status: PublishResult::STATUS_ACTIVE,
        remoteCampaignId: 'camp_123',
        remoteAdsetId: 'adset_456',
        remoteAdId: 'ad_789',
    );
    expect($r->isSuccess())->toBeTrue();
    expect($r->isRejected())->toBeFalse();
    expect($r->toArray())->toHaveKeys(['status', 'remote_campaign_id', 'remote_adset_id', 'remote_ad_id', 'message', 'rejection_reason', 'metadata']);

    $rejected = new PublishResult(status: PublishResult::STATUS_REJECTED, rejectionReason: 'ad policy');
    expect($rejected->isSuccess())->toBeFalse();
    expect($rejected->isRejected())->toBeTrue();

    // Readonly — reassignment should fail at runtime
    expect(fn () => $r->status = 'paused')->toThrow(\Error::class);
});

test('MetricsRow + MetricsResult — aggregate 0-row empty case', function () {
    $row = new MetricsRow(remoteCampaignId: 'c1', date: '2026-04-17', impressions: 1000, clicks: 50, spend: 12.5);
    expect($row->toArray())->toHaveKeys(['remote_campaign_id', 'date', 'impressions', 'clicks', 'spend', 'ctr', 'cpm']);

    $empty = new MetricsResult(status: MetricsResult::STATUS_SUCCESS, rows: []);
    expect($empty->isSuccess())->toBeTrue();
    expect($empty->count())->toBe(0);

    $populated = new MetricsResult(status: MetricsResult::STATUS_SUCCESS, rows: [$row]);
    expect($populated->count())->toBe(1);
    expect($populated->toArray()['rows'])->toHaveCount(1);
});

test('AudienceSyncResult — status helpers', function () {
    $r = new AudienceSyncResult(
        status: AudienceSyncResult::STATUS_SUCCESS,
        platformAudienceId: 'aud_abc',
        matchedCount: 14238,
        uploadedCount: 15000,
        invalidCount: 762,
    );
    expect($r->isSuccess())->toBeTrue();
    expect($r->toArray())->toMatchArray([
        'platform_audience_id' => 'aud_abc',
        'matched_count' => 14238,
    ]);
});

test('LeadFetchResult — pagination semantics', function () {
    $page = new LeadFetchResult(
        status: LeadFetchResult::STATUS_SUCCESS,
        leads: [['platform_lead_id' => 'lead1', 'fields' => ['email' => '[email protected]'], 'created_at' => time()]],
        nextCursor: 'abc123',
    );
    expect($page->hasMore())->toBeTrue();
    expect($page->count())->toBe(1);

    $lastPage = new LeadFetchResult(status: LeadFetchResult::STATUS_SUCCESS, leads: [], nextCursor: null);
    expect($lastPage->hasMore())->toBeFalse();
});

test('CatalogSyncResult — partial failure shape', function () {
    $r = new CatalogSyncResult(
        status: CatalogSyncResult::STATUS_PARTIAL,
        syncedCount: 998,
        errorCount: 2,
        errors: [
            ['external_id' => 'prod1', 'error' => 'invalid image'],
            ['external_id' => 'prod2', 'error' => 'banned keyword'],
        ],
    );
    expect($r->isSuccess())->toBeTrue();   // partial still counts as "not fatal"
    expect($r->errorCount)->toBe(2);
    expect($r->errors)->toHaveCount(2);
});

// ============================================================
// 2. DTOs
// ============================================================

test('PublishCampaignDto — readonly + toArray redacts creativePayload internals', function () {
    $dto = new PublishCampaignDto(
        campaignId: 'c-1',
        name: 'Test Campaign',
        objective: 'CONVERSIONS',
        dailyBudget: 100.0,
        lifetimeBudget: null,
        budgetCurrency: 'USD',
        bidStrategy: 'LOWEST_COST',
        scheduleStart: Carbon::parse('2026-05-01'),
        scheduleEnd: null,
        targeting: ['geo' => ['countries' => ['US']]],
        creativePayload: ['headline' => 'Secret text', 'body' => 'content'],
    );

    // Readonly enforcement
    expect(fn () => $dto->name = 'Hacked')->toThrow(\Error::class);

    // toArray includes only creative_payload_keys (not the values — may contain PII)
    $arr = $dto->toArray();
    expect($arr)->toHaveKey('creative_payload_keys');
    expect($arr['creative_payload_keys'])->toBe(['headline', 'body']);
    expect($arr)->not->toHaveKey('creative_payload');
});

test('SyncAudienceDto — toArray redacts hashes', function () {
    $dto = new SyncAudienceDto(
        platformAudienceId: null,
        name: 'List A',
        description: 'desc',
        hashedEmails: [hash('sha256', '[email protected]'), hash('sha256', '[email protected]')],
        operation: SyncAudienceDto::OPERATION_ADD,
    );
    $arr = $dto->toArray();
    expect($arr)->toHaveKey('hashed_emails_count');
    expect($arr['hashed_emails_count'])->toBe(2);
    expect($arr)->not->toHaveKey('hashed_emails');
    expect($arr)->not->toHaveKey('hashedEmails');
});

test('ProcessAdLeadDto — toArray redacts field values (PII)', function () {
    $dto = new ProcessAdLeadDto(
        formConfigId: 1,
        platformLeadId: 'lead_abc',
        fields: ['email' => '[email protected]', 'full_name' => 'Jane Doe'],
        createdAt: Carbon::now(),
    );
    $arr = $dto->toArray();
    expect($arr)->toHaveKey('field_keys');
    expect($arr['field_keys'])->toBe(['email', 'full_name']);
    expect($arr)->not->toHaveKey('fields');
    // PII MUST NOT appear anywhere
    expect(json_encode($arr))->not->toContain('[email protected]');
    expect(json_encode($arr))->not->toContain('Jane Doe');
});

// ============================================================
// 3-4. Interface surface + adapter coverage
// ============================================================

test('AdPlatformAdapter interface declares 18 methods', function () {
    $ref = new ReflectionClass(AdPlatformAdapter::class);
    $methods = $ref->getMethods();
    expect($methods)->toHaveCount(18);
});

test('all 5 adapter classes implement all 18 methods', function () {
    $adapters = [MockAdPlatform::class, MetaAdPlatform::class, GoogleAdPlatform::class, TikTokAdPlatform::class, LinkedInAdPlatform::class];
    $interfaceMethods = array_map(fn ($m) => $m->name, (new ReflectionClass(AdPlatformAdapter::class))->getMethods());

    foreach ($adapters as $adapterClass) {
        $ref = new ReflectionClass($adapterClass);
        foreach ($interfaceMethods as $method) {
            expect($ref->hasMethod($method))
                ->toBeTrue("Adapter {$adapterClass} missing interface method {$method}");
        }
    }
});

test('all 5 adapter classes use ProvidesR1StubImplementations trait', function () {
    $adapters = [MockAdPlatform::class, MetaAdPlatform::class, GoogleAdPlatform::class, TikTokAdPlatform::class, LinkedInAdPlatform::class];
    $traitName = \App\Services\Ads\ProvidesR1StubImplementations::class;
    foreach ($adapters as $adapterClass) {
        $traits = class_uses($adapterClass);
        // class_uses returns assoc array with trait FQCN as both key and value
        expect(array_key_exists($traitName, $traits))->toBeTrue("{$adapterClass} missing trait {$traitName}");
    }
});

// ============================================================
// 5. Stubs throw LogicException with phase hint
//
// (Section retired in R12.3 — every adapter now overrides every R1 method
//  at runtime, so the trait's LogicException default is unreachable in
//  production.  The three former pin-tests are deleted; the invariant they
//  guarded is no longer load-bearing.  Reflection-based override coverage
//  lives in each adapter's R12.x test file instead.)
// ============================================================

// ============================================================
// 6-7. IdempotencyDecorator wrapping behavior
// ============================================================

test('IdempotencyDecorator::publishCampaign caches result by idempotency key', function () {
    Cache::flush();

    // Fake inner adapter that counts calls
    $fakeInner = new class extends \App\Services\Ads\MockAdPlatform {
        public int $publishCalls = 0;
        public function publishCampaign(string $at, string $acct, PublishCampaignDto $d, string $k): PublishResult {
            $this->publishCalls++;
            return new PublishResult(status: PublishResult::STATUS_ACTIVE, remoteCampaignId: 'camp_' . $this->publishCalls);
        }
    };
    $dec = new IdempotencyDecorator($fakeInner);
    $dto = new PublishCampaignDto(
        campaignId: 'c-1', name: 'Test', objective: 'CONVERSIONS',
        dailyBudget: 100.0, lifetimeBudget: null,
        budgetCurrency: 'USD', bidStrategy: null,
        scheduleStart: null, scheduleEnd: null,
    );

    $r1 = $dec->publishCampaign('tok', 'acct', $dto, 'idem-key-A');
    $r2 = $dec->publishCampaign('tok', 'acct', $dto, 'idem-key-A');

    expect($fakeInner->publishCalls)->toBe(1);
    expect($r1->remoteCampaignId)->toBe('camp_1');
    expect($r2->remoteCampaignId)->toBe('camp_1');   // cached result returned
});

test('IdempotencyDecorator — different keys invoke inner twice', function () {
    Cache::flush();
    $fakeInner = new class extends \App\Services\Ads\MockAdPlatform {
        public int $publishCalls = 0;
        public function publishCampaign(string $at, string $acct, PublishCampaignDto $d, string $k): PublishResult {
            $this->publishCalls++;
            return new PublishResult(status: PublishResult::STATUS_ACTIVE, remoteCampaignId: 'camp_' . $this->publishCalls);
        }
    };
    $dec = new IdempotencyDecorator($fakeInner);
    $dto = new PublishCampaignDto(
        campaignId: 'c-1', name: 'Test', objective: 'CONVERSIONS',
        dailyBudget: 100.0, lifetimeBudget: null,
        budgetCurrency: 'USD', bidStrategy: null,
        scheduleStart: null, scheduleEnd: null,
    );

    $dec->publishCampaign('tok', 'acct', $dto, 'idem-key-A');
    $dec->publishCampaign('tok', 'acct', $dto, 'idem-key-B');   // different key

    expect($fakeInner->publishCalls)->toBe(2);
});

test('IdempotencyDecorator — syncAudience + syncCatalog + pauseCampaign + resumeCampaign all wrapped', function () {
    Cache::flush();
    $fakeInner = new class extends \App\Services\Ads\MockAdPlatform {
        public array $calls = ['pause' => 0, 'resume' => 0, 'audience' => 0, 'catalog' => 0];
        public function pauseCampaign(string $at, string $rcId, string $k): PublishResult {
            $this->calls['pause']++;
            return new PublishResult(status: PublishResult::STATUS_PAUSED, remoteCampaignId: $rcId);
        }
        public function resumeCampaign(string $at, string $rcId, string $k): PublishResult {
            $this->calls['resume']++;
            return new PublishResult(status: PublishResult::STATUS_ACTIVE, remoteCampaignId: $rcId);
        }
        public function syncAudience(string $at, string $acct, SyncAudienceDto $d, string $k): AudienceSyncResult {
            $this->calls['audience']++;
            return new AudienceSyncResult(status: AudienceSyncResult::STATUS_SUCCESS, platformAudienceId: 'aud_x');
        }
        public function syncCatalog(string $at, string $cat, array $prods, string $k): CatalogSyncResult {
            $this->calls['catalog']++;
            return new CatalogSyncResult(status: CatalogSyncResult::STATUS_SUCCESS, syncedCount: count($prods));
        }
    };
    $dec = new IdempotencyDecorator($fakeInner);
    $aud = new SyncAudienceDto(platformAudienceId: null, name: 'a', description: null, hashedEmails: ['h1']);

    // Call each twice with same key — each inner fires once
    $dec->pauseCampaign('tok', 'rc-1', 'k-pause');
    $dec->pauseCampaign('tok', 'rc-1', 'k-pause');
    $dec->resumeCampaign('tok', 'rc-1', 'k-resume');
    $dec->resumeCampaign('tok', 'rc-1', 'k-resume');
    $dec->syncAudience('tok', 'acct', $aud, 'k-aud');
    $dec->syncAudience('tok', 'acct', $aud, 'k-aud');
    $dec->syncCatalog('tok', 'cat-1', ['prod1'], 'k-cat');
    $dec->syncCatalog('tok', 'cat-1', ['prod1'], 'k-cat');

    expect($fakeInner->calls)->toBe(['pause' => 1, 'resume' => 1, 'audience' => 1, 'catalog' => 1]);
});

test('IdempotencyDecorator does NOT wrap reads (pullMetrics, getLeadForms, fetchLeads, deleteAudience, createCatalog)', function () {
    Cache::flush();
    $fakeInner = new class extends \App\Services\Ads\MockAdPlatform {
        public int $pullCalls = 0;
        public int $formsCalls = 0;
        public int $fetchCalls = 0;
        public int $deleteCalls = 0;
        public int $createCalls = 0;
        public function pullMetrics(string $at, string $acct, Carbon $f, Carbon $t, array $ids, string $tz): MetricsResult {
            $this->pullCalls++;
            return new MetricsResult(status: MetricsResult::STATUS_SUCCESS);
        }
        public function getLeadForms(string $at, string $acct): array {
            $this->formsCalls++;
            return [];
        }
        public function fetchLeads(string $at, string $fid, ?Carbon $since, int $l = 100, ?string $c = null): LeadFetchResult {
            $this->fetchCalls++;
            return new LeadFetchResult(status: LeadFetchResult::STATUS_SUCCESS);
        }
        public function deleteAudience(string $at, string $paid): AudienceSyncResult {
            $this->deleteCalls++;
            return new AudienceSyncResult(status: AudienceSyncResult::STATUS_DELETED);
        }
        public function createCatalog(string $at, string $acct, string $n): string {
            $this->createCalls++;
            return 'cat_new';
        }
    };
    $dec = new IdempotencyDecorator($fakeInner);

    // Each call twice — each inner fires each time
    $dec->pullMetrics('tok', 'acct', Carbon::now(), Carbon::now(), [], 'UTC');
    $dec->pullMetrics('tok', 'acct', Carbon::now(), Carbon::now(), [], 'UTC');
    $dec->getLeadForms('tok', 'acct');
    $dec->getLeadForms('tok', 'acct');
    $dec->fetchLeads('tok', 'f1', null);
    $dec->fetchLeads('tok', 'f1', null);
    $dec->deleteAudience('tok', 'aud1');
    $dec->deleteAudience('tok', 'aud1');
    $dec->createCatalog('tok', 'acct', 'My Catalog');
    $dec->createCatalog('tok', 'acct', 'My Catalog');

    expect($fakeInner->pullCalls)->toBe(2);
    expect($fakeInner->formsCalls)->toBe(2);
    expect($fakeInner->fetchCalls)->toBe(2);
    expect($fakeInner->deleteCalls)->toBe(2);
    expect($fakeInner->createCalls)->toBe(2);
});

test('IdempotencyDecorator::forget clears cached entry', function () {
    Cache::flush();
    $fakeInner = new class extends \App\Services\Ads\MockAdPlatform {
        public int $calls = 0;
        public function pauseCampaign(string $at, string $rcId, string $k): PublishResult {
            $this->calls++;
            return new PublishResult(status: PublishResult::STATUS_PAUSED);
        }
    };
    $dec = new IdempotencyDecorator($fakeInner);

    $dec->pauseCampaign('tok', 'rc', 'k');
    $dec->forget('pauseCampaign', 'k');
    $dec->pauseCampaign('tok', 'rc', 'k');

    expect($fakeInner->calls)->toBe(2);
});

// ============================================================
// Full chain integration
//
// (R12.3 retired the "session chain forwards to inner stub" test along
//  with the other R1 stub-pin tests — every adapter overrides every
//  method at runtime now, so there is no stub adapter to pin against.
//  Chain integration is exercised by the per-adapter R12.x test suites.)
// ============================================================