File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR4MetaRealImplementationTest.php
<?php
/**
* Ads Phase R4 — MetaAdPlatform real Facebook Graph API test suite.
*
* Uses Http::fake() to stub Graph responses. No live API calls.
*
* Verifies:
* 1. Feature flag `ads.hardening.real_meta_enabled` defaults OFF
* 2. Flag OFF → every new method delegates to MockAdPlatform (returns
* mock_camp_ + mock_aud_ shape, no HTTP request made)
* 3. Flag ON → each of 10 methods hits the correct Graph endpoint with
* expected params and maps responses to the right Result VO
* 4. Error code taxonomy: 190/460 → TokenExpired; 190 → TokenRevoked;
* 4/17/32 → RateLimit with retry-after; 1487734 → PolicyViolation;
* 100 → Validation; 803 / 404 → ResourceNotFound; 5xx → NetworkException;
* unknown → UnexpectedResponse
* 5. deleteAudience treats 404 / error-803 as STATUS_DELETED (GDPR idempotent)
*/
use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\PolicyViolationException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\ResourceNotFoundException;
use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\Exceptions\TokenRevokedException;
use App\Services\Ads\Exceptions\UnexpectedResponseException;
use App\Services\Ads\Exceptions\ValidationException;
use App\Services\Ads\MetaAdPlatform;
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\Http;
uses(Tests\TestCase::class);
beforeEach(function () {
config(['ads.hardening.real_meta_enabled' => true]);
Http::preventStrayRequests();
});
function metaDto(string $name = 'Meta R4 Campaign'): PublishCampaignDto
{
return new PublishCampaignDto(
campaignId: 'c-' . uniqid(),
name: $name,
objective: 'traffic',
dailyBudget: 25.00,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: 'LOWEST_COST',
scheduleStart: null,
scheduleEnd: null,
);
}
// ============================================================
// 1-2. Flag default + delegation to mock
// ============================================================
test('ads.hardening.real_meta_enabled defaults to false', function () {
$default = require base_path('config/ads.php');
expect($default['hardening']['real_meta_enabled'] ?? true)->toBeFalse();
});
test('flag OFF — publishCampaign delegates to MockAdPlatform (no HTTP calls)', function () {
config(['ads.hardening.real_meta_enabled' => false]);
Http::fake(); // any unexpected hit would be caught
$result = app(MetaAdPlatform::class)->publishCampaign('tok', 'act_1', metaDto(), 'idem-1');
expect($result->remoteCampaignId)->toStartWith('mock_camp_'); // Mock shape
Http::assertNothingSent();
});
test('flag OFF — pullMetrics delegates to Mock', function () {
config(['ads.hardening.real_meta_enabled' => false]);
Http::fake();
$result = app(MetaAdPlatform::class)->pullMetrics(
'tok', 'act_1',
Carbon::parse('2026-04-01'), Carbon::parse('2026-04-02'),
['camp_a'], 'UTC',
);
expect($result)->toBeInstanceOf(MetricsResult::class)
->and($result->count())->toBe(2); // Mock returns campaigns × days = 1×2
Http::assertNothingSent();
});
// ============================================================
// 3. Real Graph API calls — happy paths
// ============================================================
test('publishCampaign — POST to /act_{id}/campaigns with mapped objective', function () {
Http::fake([
'graph.facebook.com/v18.0/act_1234/campaigns' => Http::response(['id' => '120300123', 'success' => true], 200),
]);
$result = app(MetaAdPlatform::class)->publishCampaign('tok', 'act_1234', metaDto(), 'idem-pub');
expect($result)->toBeInstanceOf(PublishResult::class)
->and($result->status)->toBe(PublishResult::STATUS_PENDING)
->and($result->remoteCampaignId)->toBe('120300123')
->and($result->metadata['real'])->toBeTrue();
Http::assertSent(function ($request) {
return str_contains($request->url(), '/act_1234/campaigns')
&& $request['objective'] === 'OUTCOME_TRAFFIC'
&& $request['daily_budget'] === 2500 // cents
&& $request['status'] === 'PAUSED'
&& $request['client_user_token'] === 'idem-pub';
});
});
test('publishCampaign — strips act_ prefix from account id in path', function () {
Http::fake(['*' => Http::response(['id' => '42'], 200)]);
app(MetaAdPlatform::class)->publishCampaign('tok', 'act_555', metaDto(), 'idem');
Http::assertSent(fn ($request) => str_contains($request->url(), '/act_555/campaigns'));
});
test('pauseCampaign — POST status=PAUSED to {campaign_id}', function () {
Http::fake([
'graph.facebook.com/v18.0/cmp_77' => Http::response(['success' => true], 200),
]);
$result = app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp_77', 'idem-p');
expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
->and($result->remoteCampaignId)->toBe('cmp_77');
Http::assertSent(fn ($r) => $r['status'] === 'PAUSED' && str_ends_with($r->url(), '/cmp_77'));
});
test('resumeCampaign — POST status=ACTIVE', function () {
Http::fake(['*' => Http::response(['success' => true], 200)]);
$result = app(MetaAdPlatform::class)->resumeCampaign('tok', 'cmp_88', 'idem-r');
expect($result->status)->toBe(PublishResult::STATUS_ACTIVE);
Http::assertSent(fn ($r) => $r['status'] === 'ACTIVE');
});
test('pullMetrics — GET /act_{id}/insights with filtering + time_range', function () {
Http::fake([
'graph.facebook.com/v18.0/act_1/insights*' => Http::response([
'data' => [
['campaign_id' => '120100', 'date_start' => '2026-04-01', 'impressions' => '1000', 'clicks' => '50', 'spend' => '12.5', 'conversions' => '3', 'ctr' => '5.0', 'cpc' => '0.25', 'cpm' => '12.5', 'reach' => '800'],
['campaign_id' => '120100', 'date_start' => '2026-04-02', 'impressions' => '1200', 'clicks' => '60', 'spend' => '14.0', 'conversions' => '4'],
],
], 200),
]);
$result = app(MetaAdPlatform::class)->pullMetrics(
'tok', 'act_1',
Carbon::parse('2026-04-01'), Carbon::parse('2026-04-02'),
['120100'], 'UTC',
);
expect($result->count())->toBe(2);
$first = $result->rows[0];
expect($first->impressions)->toBe(1000)
->and($first->clicks)->toBe(50)
->and($first->spend)->toBe(12.5)
->and($first->ctr)->toBe(5.0)
->and($first->reach)->toBe(800);
Http::assertSent(function ($r) {
parse_str(parse_url($r->url(), PHP_URL_QUERY) ?? '', $qs);
return str_contains($r->url(), '/act_1/insights')
&& ($qs['level'] ?? null) === 'campaign'
&& str_contains($qs['filtering'] ?? '', '120100');
});
});
test('pullMetrics — empty remoteCampaignIds short-circuits (no HTTP)', function () {
Http::fake();
$result = app(MetaAdPlatform::class)->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), [], 'UTC');
expect($result->count())->toBe(0);
Http::assertNothingSent();
});
test('syncAudience — creates customaudience when no platformAudienceId provided', function () {
Http::fake([
'graph.facebook.com/v18.0/act_9/customaudiences' => Http::response(['id' => 'aud_new_999'], 200),
]);
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List A',
description: 'Test',
hashedEmails: [],
);
$result = app(MetaAdPlatform::class)->syncAudience('tok', 'act_9', $dto, 'idem-a');
expect($result)->toBeInstanceOf(AudienceSyncResult::class)
->and($result->platformAudienceId)->toBe('aud_new_999')
->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS);
Http::assertSent(fn ($r) => $r['subtype'] === 'CUSTOM');
});
test('syncAudience — reuses provided platformAudienceId, skips create', function () {
Http::fake(); // any request would fail preventStrayRequests
$dto = new SyncAudienceDto(
platformAudienceId: 'aud_existing',
name: 'List', description: null, hashedEmails: [],
);
$result = app(MetaAdPlatform::class)->syncAudience('tok', 'act_1', $dto, 'idem');
expect($result->platformAudienceId)->toBe('aud_existing');
Http::assertNothingSent();
});
test('deleteAudience — DELETE /{id} returns STATUS_DELETED', function () {
Http::fake([
'graph.facebook.com/v18.0/aud_delete_me' => Http::response(['success' => true], 200),
]);
$result = app(MetaAdPlatform::class)->deleteAudience('tok', 'aud_delete_me');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED)
->and($result->platformAudienceId)->toBe('aud_delete_me');
});
test('deleteAudience — 404 still returns STATUS_DELETED (GDPR idempotent)', function () {
Http::fake([
'graph.facebook.com/v18.0/aud_gone' => Http::response(['error' => ['code' => 803, 'message' => 'not found']], 404),
]);
$result = app(MetaAdPlatform::class)->deleteAudience('tok', 'aud_gone');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
});
test('getLeadForms — GET /act_{id}/leadgen_forms returns list', function () {
Http::fake([
'graph.facebook.com/v18.0/act_1/leadgen_forms*' => Http::response([
'data' => [
['id' => 'f1', 'name' => 'Demo', 'status' => 'ACTIVE', 'questions' => [['key' => 'email']], 'created_time' => '2026-04-10T00:00:00+0000'],
],
], 200),
]);
$forms = app(MetaAdPlatform::class)->getLeadForms('tok', 'act_1');
expect($forms)->toHaveCount(1)
->and($forms[0]['id'])->toBe('f1')
->and($forms[0]['status'])->toBe('ACTIVE');
});
test('fetchLeads — GET /{form_id}/leads, maps field_data → fields dict', function () {
Http::fake([
'graph.facebook.com/v18.0/f1/leads*' => Http::response([
'data' => [
['id' => 'ld_1', 'created_time' => '2026-04-15T12:00:00+0000', 'field_data' => [
['name' => 'email', 'values' => ['[email protected]']],
['name' => 'full_name', 'values' => ['Alice X']],
]],
],
'paging' => ['cursors' => ['after' => 'page2_cursor']],
], 200),
]);
$result = app(MetaAdPlatform::class)->fetchLeads('tok', 'f1', null);
expect($result)->toBeInstanceOf(LeadFetchResult::class)
->and($result->count())->toBe(1)
->and($result->nextCursor)->toBe('page2_cursor')
->and($result->leads[0]['fields']['email'])->toBe('[email protected]')
->and($result->leads[0]['fields']['full_name'])->toBe('Alice X');
});
test('fetchLeads — passes since filter as filtering param', function () {
Http::fake(['*' => Http::response(['data' => []], 200)]);
$since = Carbon::parse('2026-04-10');
app(MetaAdPlatform::class)->fetchLeads('tok', 'f1', $since, 50, 'cur_x');
Http::assertSent(function ($r) use ($since) {
parse_str(parse_url($r->url(), PHP_URL_QUERY) ?? '', $qs);
return ($qs['limit'] ?? null) === '50'
&& ($qs['after'] ?? null) === 'cur_x'
&& str_contains($qs['filtering'] ?? '', (string) $since->unix());
});
});
test('createCatalog — POST /act_{id}/owned_product_catalogs returns id', function () {
Http::fake([
'graph.facebook.com/v18.0/act_42/owned_product_catalogs' => Http::response(['id' => 'cat_new'], 200),
]);
$id = app(MetaAdPlatform::class)->createCatalog('tok', 'act_42', 'My Catalog');
expect($id)->toBe('cat_new');
});
test('syncCatalog — POST /{catalog_id}/batch with CREATE requests', function () {
Http::fake([
'graph.facebook.com/v18.0/cat_99/batch' => Http::response(['handles' => ['h1', 'h2']], 200),
]);
$products = [
['external_id' => 'sku-1', 'name' => 'P1', 'price' => '10.00 USD'],
['external_id' => 'sku-2', 'name' => 'P2', 'price' => '20.00 USD'],
];
$result = app(MetaAdPlatform::class)->syncCatalog('tok', 'cat_99', $products, 'idem');
expect($result)->toBeInstanceOf(CatalogSyncResult::class)
->and($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS)
->and($result->syncedCount)->toBe(2)
->and($result->errorCount)->toBe(0);
Http::assertSent(function ($r) {
$body = $r['requests'] ?? '';
$decoded = json_decode((string) $body, true);
return is_array($decoded)
&& count($decoded) === 2
&& $decoded[0]['method'] === 'CREATE'
&& $decoded[0]['retailer_id'] === 'sku-1';
});
});
// ============================================================
// 4. Error taxonomy mapping
// ============================================================
test('error 190 / subcode 460 → TokenExpiredException', function () {
Http::fake(['*' => Http::response([
'error' => ['code' => 190, 'error_subcode' => 460, 'message' => 'Token expired'],
], 401)]);
expect(fn () => app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem'))
->toThrow(TokenExpiredException::class);
});
test('error 190 (no subcode) → TokenRevokedException', function () {
Http::fake(['*' => Http::response([
'error' => ['code' => 190, 'message' => 'Token was revoked'],
], 401)]);
expect(fn () => app(MetaAdPlatform::class)->resumeCampaign('tok', 'cmp', 'idem'))
->toThrow(TokenRevokedException::class);
});
test('error 4 → RateLimitException with retry-after header', function () {
Http::fake(['*' => Http::response(
['error' => ['code' => 4, 'message' => 'Rate limit']],
429,
['Retry-After' => '120'],
)]);
try {
app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem');
expect(false)->toBeTrue('expected RateLimitException');
} catch (RateLimitException $e) {
expect($e->getRetryAfter())->toBe(120)
->and($e->isRetryable())->toBeTrue();
}
});
test('error 17 → RateLimitException', function () {
Http::fake(['*' => Http::response(['error' => ['code' => 17, 'message' => 'User quota']], 429)]);
expect(fn () => app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem'))
->toThrow(RateLimitException::class);
});
test('error 1487734 → PolicyViolationException', function () {
Http::fake(['*' => Http::response([
'error' => ['code' => 1487734, 'message' => 'Ad rejected for policy violation'],
], 400)]);
expect(fn () => app(MetaAdPlatform::class)->publishCampaign('tok', 'act_1', metaDto(), 'idem'))
->toThrow(PolicyViolationException::class);
});
test('message containing "policy" → PolicyViolationException even when code unrecognized', function () {
Http::fake(['*' => Http::response([
'error' => ['code' => 999999, 'message' => 'Policy check failed'],
], 400)]);
expect(fn () => app(MetaAdPlatform::class)->publishCampaign('tok', 'act_1', metaDto(), 'idem'))
->toThrow(PolicyViolationException::class);
});
test('error 100 → ValidationException', function () {
Http::fake(['*' => Http::response(['error' => ['code' => 100, 'message' => 'Invalid parameter']], 400)]);
expect(fn () => app(MetaAdPlatform::class)->publishCampaign('tok', 'act_1', metaDto(), 'idem'))
->toThrow(ValidationException::class);
});
test('error 803 → ResourceNotFoundException (non-delete context)', function () {
Http::fake(['*' => Http::response(['error' => ['code' => 803, 'message' => 'Resource not found']], 404)]);
expect(fn () => app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem'))
->toThrow(ResourceNotFoundException::class);
});
test('5xx → NetworkException', function () {
Http::fake(['*' => Http::response(['error' => ['code' => 2, 'message' => 'Server error']], 503)]);
expect(fn () => app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem'))
->toThrow(NetworkException::class);
});
test('unknown error → UnexpectedResponseException', function () {
Http::fake(['*' => Http::response(['error' => ['code' => 77777, 'message' => 'Weird thing']], 400)]);
expect(fn () => app(MetaAdPlatform::class)->pauseCampaign('tok', 'cmp', 'idem'))
->toThrow(UnexpectedResponseException::class);
});