File: /home/xedaptot/be.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.)
// ============================================================