File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR2MockPlatformTest.php
<?php
/**
* Ads Phase R2 — MockAdPlatform implementation test suite.
*
* Verifies:
* 1. All 10 R1-stubbed methods now return typed Result VOs (no more LogicException)
* 2. Name-based error simulation throws correct exception subclasses
* (MOCK_REJECT, MOCK_PENDING, MOCK_RATE_LIMIT, MOCK_AUTH_EXPIRED)
* 3. pullMetrics is deterministic — same input → same output across calls
* 4. pullMetrics row cardinality = campaigns × days
* 5. syncAudience 85% match rate + platformAudienceId reuse behavior
* 6. fetchLeads pagination — first page has nextCursor, follow-up cursor empties
* 7. createCatalog / syncCatalog — IDs + synced counts
* 8. N-th-call config-driven transient error simulation (R2.2)
*/
use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\TokenExpiredException;
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\PublishResult;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
uses(Tests\TestCase::class);
beforeEach(function () {
Cache::flush();
config(['ads.mock.error_simulation' => null]);
});
function mockPublishDto(string $name = 'Normal Campaign'): PublishCampaignDto
{
return new PublishCampaignDto(
campaignId: 'c-' . uniqid(),
name: $name,
objective: 'CONVERSIONS',
dailyBudget: 100.0,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: 'LOWEST_COST',
scheduleStart: Carbon::parse('2026-05-01'),
scheduleEnd: null,
targeting: ['geo' => ['countries' => ['US']]],
);
}
// ============================================================
// Publishing
// ============================================================
test('publishCampaign — happy path returns ACTIVE with mock IDs', function () {
$mock = new MockAdPlatform();
$result = $mock->publishCampaign('tok', 'act_1', mockPublishDto(), 'idem-1');
expect($result)->toBeInstanceOf(PublishResult::class)
->and($result->status)->toBe(PublishResult::STATUS_ACTIVE)
->and($result->isSuccess())->toBeTrue()
->and($result->remoteCampaignId)->toStartWith('mock_camp_')
->and($result->remoteAdsetId)->toStartWith('mock_adset_')
->and($result->remoteAdId)->toStartWith('mock_ad_')
->and($result->metadata['idempotency_key'])->toBe('idem-1');
});
test('publishCampaign — name contains MOCK_REJECT → STATUS_REJECTED with reason', function () {
$mock = new MockAdPlatform();
$result = $mock->publishCampaign('tok', 'act_1', mockPublishDto('Summer MOCK_REJECT Sale'), 'idem-x');
expect($result->status)->toBe(PublishResult::STATUS_REJECTED)
->and($result->isRejected())->toBeTrue()
->and($result->rejectionReason)->toContain('MOCK_REJECT')
->and($result->remoteCampaignId)->toBeNull();
});
test('publishCampaign — name contains MOCK_PENDING → STATUS_PENDING with ID', function () {
$mock = new MockAdPlatform();
$result = $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_PENDING Review'), 'idem-p');
expect($result->status)->toBe(PublishResult::STATUS_PENDING)
->and($result->isSuccess())->toBeTrue()
->and($result->remoteCampaignId)->toStartWith('mock_camp_');
});
test('publishCampaign — MOCK_RATE_LIMIT throws RateLimitException with retry-after', function () {
$mock = new MockAdPlatform();
try {
$mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_RATE_LIMIT hit'), 'idem-r');
expect(false)->toBeTrue('expected RateLimitException');
} catch (RateLimitException $e) {
expect($e->getRetryAfter())->toBe(60)
->and($e->isRetryable())->toBeTrue()
->and($e->getPlatformCode())->toBe('MOCK_RATE_LIMIT');
}
});
test('publishCampaign — MOCK_AUTH_EXPIRED throws TokenExpiredException', function () {
$mock = new MockAdPlatform();
expect(fn () => $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_AUTH_EXPIRED'), 'idem-a'))
->toThrow(TokenExpiredException::class);
});
test('publishCampaign — MOCK_NETWORK_ERROR throws NetworkException', function () {
$mock = new MockAdPlatform();
expect(fn () => $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_NETWORK_ERROR'), 'idem-n'))
->toThrow(NetworkException::class);
});
test('pauseCampaign — returns STATUS_PAUSED with same remoteCampaignId', function () {
$mock = new MockAdPlatform();
$result = $mock->pauseCampaign('tok', 'camp_123', 'idem-p');
expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
->and($result->remoteCampaignId)->toBe('camp_123')
->and($result->metadata['idempotency_key'])->toBe('idem-p');
});
test('resumeCampaign — returns STATUS_ACTIVE with same remoteCampaignId', function () {
$mock = new MockAdPlatform();
$result = $mock->resumeCampaign('tok', 'camp_456', 'idem-r');
expect($result->status)->toBe(PublishResult::STATUS_ACTIVE)
->and($result->remoteCampaignId)->toBe('camp_456');
});
// ============================================================
// Metrics — determinism is the critical property
// ============================================================
test('pullMetrics — returns MetricsResult with rows[]', function () {
$mock = new MockAdPlatform();
$result = $mock->pullMetrics(
'tok',
'act_1',
Carbon::parse('2026-04-01'),
Carbon::parse('2026-04-03'),
['camp_a', 'camp_b'],
'UTC',
);
expect($result)->toBeInstanceOf(MetricsResult::class)
->and($result->status)->toBe(MetricsResult::STATUS_SUCCESS)
->and($result->count())->toBe(6); // 2 campaigns × 3 days
});
test('pullMetrics — deterministic: same input produces identical rows', function () {
$mock = new MockAdPlatform();
$args = ['tok', 'act_1', Carbon::parse('2026-04-01'), Carbon::parse('2026-04-05'), ['stable_camp'], 'UTC'];
$r1 = $mock->pullMetrics(...$args);
$r2 = $mock->pullMetrics(...$args);
expect($r1->count())->toBe($r2->count());
foreach ($r1->rows as $i => $row1) {
$row2 = $r2->rows[$i];
expect($row1->remoteCampaignId)->toBe($row2->remoteCampaignId)
->and($row1->date)->toBe($row2->date)
->and($row1->impressions)->toBe($row2->impressions)
->and($row1->clicks)->toBe($row2->clicks)
->and($row1->spend)->toBe($row2->spend)
->and($row1->conversions)->toBe($row2->conversions);
}
});
test('pullMetrics — empty campaigns list → zero rows, still STATUS_SUCCESS', function () {
$mock = new MockAdPlatform();
$result = $mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), [], 'UTC');
expect($result->status)->toBe(MetricsResult::STATUS_SUCCESS)
->and($result->count())->toBe(0);
});
test('pullMetrics — row values within realistic ranges', function () {
$mock = new MockAdPlatform();
$result = $mock->pullMetrics(
'tok',
'act_1',
Carbon::parse('2026-04-01'),
Carbon::parse('2026-04-01'),
['camp_x'],
'UTC',
);
$row = $result->rows[0];
expect($row->impressions)->toBeGreaterThanOrEqual(500)->toBeLessThan(10000)
->and($row->clicks)->toBeLessThanOrEqual($row->impressions)
->and($row->conversions)->toBeLessThanOrEqual($row->clicks)
->and($row->spend)->toBeGreaterThanOrEqual(0.0)
->and($row->ctr)->toBeLessThan(5.0); // realistic CTR ceiling
});
// ============================================================
// Audiences
// ============================================================
test('syncAudience — generates new platformAudienceId when none provided', function () {
$mock = new MockAdPlatform();
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'Test List',
description: null,
hashedEmails: array_fill(0, 1000, str_repeat('a', 64)),
);
$result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-a');
expect($result)->toBeInstanceOf(AudienceSyncResult::class)
->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS)
->and($result->platformAudienceId)->toStartWith('mock_aud_')
->and($result->uploadedCount)->toBe(1000)
->and($result->matchedCount)->toBe(850) // 85% match rate
->and($result->invalidCount)->toBe(150);
});
test('syncAudience — reuses platformAudienceId when provided', function () {
$mock = new MockAdPlatform();
$dto = new SyncAudienceDto(
platformAudienceId: 'aud_existing_xyz',
name: 'Test List',
description: null,
hashedEmails: [],
operation: SyncAudienceDto::OPERATION_REMOVE,
);
$result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-b');
expect($result->platformAudienceId)->toBe('aud_existing_xyz')
->and($result->uploadedCount)->toBe(0)
->and($result->matchedCount)->toBe(0);
});
test('syncAudience — MOCK_REJECT in name is NOT a rejection (audience has no rejection semantics)', function () {
// syncAudience doesn't have a "rejected" state in AudienceSyncResult, so
// name-triggered RateLimit still fires but plain MOCK_REJECT passes through.
$mock = new MockAdPlatform();
$dto = new SyncAudienceDto(
platformAudienceId: null, name: 'MOCK_REJECT Audience',
description: null, hashedEmails: ['h1', 'h2'],
);
$result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-c');
expect($result->isSuccess())->toBeTrue();
});
test('syncAudience — MOCK_RATE_LIMIT in name throws', function () {
$mock = new MockAdPlatform();
$dto = new SyncAudienceDto(
platformAudienceId: null, name: 'MOCK_RATE_LIMIT Audience',
description: null, hashedEmails: [],
);
expect(fn () => $mock->syncAudience('tok', 'act_1', $dto, 'idem-x'))
->toThrow(RateLimitException::class);
});
test('deleteAudience — returns STATUS_DELETED with same ID', function () {
$mock = new MockAdPlatform();
$result = $mock->deleteAudience('tok', 'aud_to_delete_123');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED)
->and($result->platformAudienceId)->toBe('aud_to_delete_123')
->and($result->isSuccess())->toBeTrue();
});
// ============================================================
// Leads
// ============================================================
test('getLeadForms — returns 3 realistic form schemas', function () {
$mock = new MockAdPlatform();
$forms = $mock->getLeadForms('tok', 'act_1');
expect($forms)->toHaveCount(3);
foreach ($forms as $form) {
expect($form)->toHaveKeys(['id', 'name', 'status', 'questions', 'created_time'])
->and($form['questions'])->not->toBeEmpty();
foreach ($form['questions'] as $q) {
expect($q)->toHaveKeys(['key', 'type', 'label']);
}
}
// At least one form MUST have an email field — that's the minimum useful schema
$emailForms = array_filter($forms, fn ($f) => in_array('email', array_column($f['questions'], 'key'), true));
expect($emailForms)->not->toBeEmpty();
});
test('fetchLeads — first page returns 5 leads and a next cursor', function () {
$mock = new MockAdPlatform();
$page1 = $mock->fetchLeads('tok', 'form_123', null);
expect($page1)->toBeInstanceOf(LeadFetchResult::class)
->and($page1->status)->toBe(LeadFetchResult::STATUS_SUCCESS)
->and($page1->count())->toBe(5)
->and($page1->hasMore())->toBeTrue()
->and($page1->nextCursor)->toBe('mock_cursor_end');
foreach ($page1->leads as $lead) {
expect($lead)->toHaveKeys(['platform_lead_id', 'fields', 'created_at'])
->and($lead['fields'])->toHaveKey('email')
->and($lead['created_at'])->toBeInt();
}
});
test('fetchLeads — following nextCursor returns empty last page', function () {
$mock = new MockAdPlatform();
$page2 = $mock->fetchLeads('tok', 'form_123', null, 100, 'mock_cursor_end');
expect($page2->count())->toBe(0)
->and($page2->hasMore())->toBeFalse()
->and($page2->nextCursor)->toBeNull();
});
test('fetchLeads — respects since filter (excludes older leads)', function () {
$mock = new MockAdPlatform();
// Since = 1 hour in the future → no fake lead should pass
$page = $mock->fetchLeads('tok', 'form_1', Carbon::now()->addHour());
expect($page->count())->toBe(0);
});
// ============================================================
// Catalog
// ============================================================
test('createCatalog — returns mock_cat_* ID', function () {
$mock = new MockAdPlatform();
$id = $mock->createCatalog('tok', 'act_1', 'My Product Catalog');
expect($id)->toBeString()->toStartWith('mock_cat_');
});
test('syncCatalog — syncedCount matches product count, 0 errors', function () {
$mock = new MockAdPlatform();
$products = [
['external_id' => 'sku-001', 'name' => 'P1'],
['external_id' => 'sku-002', 'name' => 'P2'],
['external_id' => 'sku-003', 'name' => 'P3'],
];
$result = $mock->syncCatalog('tok', 'cat_xyz', $products, 'idem-cat');
expect($result)->toBeInstanceOf(CatalogSyncResult::class)
->and($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS)
->and($result->syncedCount)->toBe(3)
->and($result->errorCount)->toBe(0);
});
test('createCatalog — MOCK_RATE_LIMIT in name triggers RateLimitException', function () {
$mock = new MockAdPlatform();
expect(fn () => $mock->createCatalog('tok', 'act_1', 'Catalog MOCK_RATE_LIMIT'))
->toThrow(RateLimitException::class);
});
// ============================================================
// N-th-call transient error simulation (R2.2)
// ============================================================
test('config-driven error simulation throws every N-th call (rate_limit)', function () {
config(['ads.mock.error_simulation' => ['every' => 3, 'exception' => 'rate_limit']]);
$mock = new MockAdPlatform();
// Calls 1, 2 pass; call 3 throws; call 4, 5 pass; call 6 throws
$mock->pauseCampaign('tok', 'c1', 'k1');
$mock->pauseCampaign('tok', 'c1', 'k2');
expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k3'))->toThrow(RateLimitException::class);
$mock->pauseCampaign('tok', 'c1', 'k4');
$mock->pauseCampaign('tok', 'c1', 'k5');
expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k6'))->toThrow(RateLimitException::class);
});
test('config-driven error simulation can throw NetworkException', function () {
config(['ads.mock.error_simulation' => ['every' => 1, 'exception' => 'network']]);
$mock = new MockAdPlatform();
expect(fn () => $mock->resumeCampaign('tok', 'c1', 'k1'))->toThrow(NetworkException::class);
});
test('config-driven error simulation is per-method (counters independent)', function () {
config(['ads.mock.error_simulation' => ['every' => 2, 'exception' => 'rate_limit']]);
$mock = new MockAdPlatform();
// pauseCampaign call 1 OK, pullMetrics call 1 OK — independent counters
$mock->pauseCampaign('tok', 'c1', 'k1');
$mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), ['c1'], 'UTC');
// Each at their own call 2 now throws
expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k2'))->toThrow(RateLimitException::class);
expect(fn () => $mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), ['c1'], 'UTC'))->toThrow(RateLimitException::class);
});
test('error simulation disabled by default — no throws', function () {
// beforeEach already nulls config; confirm 10 successive calls all pass
$mock = new MockAdPlatform();
for ($i = 0; $i < 10; $i++) {
$result = $mock->pauseCampaign('tok', 'c1', "k{$i}");
expect($result->isSuccess())->toBeTrue();
}
});
// ============================================================
// No more LogicException — every R1 stub now overridden
// ============================================================
test('all 10 R1-stubbed methods now return real Result VOs on MockAdPlatform', function () {
$mock = new MockAdPlatform();
$dto = mockPublishDto();
$audDto = new SyncAudienceDto(
platformAudienceId: null, name: 'list', description: null, hashedEmails: ['h1'],
);
// Every one of these MUST return a VO / string / array — never throw LogicException
expect($mock->publishCampaign('t', 'a', $dto, 'k'))->toBeInstanceOf(PublishResult::class);
expect($mock->pauseCampaign('t', 'rc', 'k'))->toBeInstanceOf(PublishResult::class);
expect($mock->resumeCampaign('t', 'rc', 'k'))->toBeInstanceOf(PublishResult::class);
expect($mock->pullMetrics('t', 'a', Carbon::now(), Carbon::now(), ['c'], 'UTC'))->toBeInstanceOf(MetricsResult::class);
expect($mock->syncAudience('t', 'a', $audDto, 'k'))->toBeInstanceOf(AudienceSyncResult::class);
expect($mock->deleteAudience('t', 'aud_x'))->toBeInstanceOf(AudienceSyncResult::class);
expect($mock->getLeadForms('t', 'a'))->toBeArray();
expect($mock->fetchLeads('t', 'f1', null))->toBeInstanceOf(LeadFetchResult::class);
expect($mock->createCatalog('t', 'a', 'name'))->toBeString();
expect($mock->syncCatalog('t', 'cat', [], 'k'))->toBeInstanceOf(CatalogSyncResult::class);
});