File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR12_1GoogleRealImplementationTest.php
<?php
/**
* Ads Phase R12.1 — GoogleAdPlatform real Google Ads REST API v17 test suite.
*
* Uses Http::fake() to stub googleads.googleapis.com responses. No live API.
*
* Verifies:
* 1. Feature flag `ads.hardening.real_google_enabled` defaults OFF
* 2. Flag OFF → every R1 method delegates to MockAdPlatform (mock_camp_ /
* mock_aud_ shape, no HTTP request made)
* 3. Flag ON → each of 10 methods hits the correct endpoint with expected
* payload shape and maps responses to the right Result VO
* 4. Error code taxonomy: authenticationError → TokenExpired/TokenRevoked
* depending on code; quotaError / 429 → RateLimit + retry-after; policy
* flag → PolicyViolation; fieldError / 400 → Validation; 404 →
* ResourceNotFound; 5xx → NetworkException; unknown shape →
* UnexpectedResponse
* 5. deleteAudience: 404 / "not found" body → STATUS_DELETED (GDPR idempotent)
* 6. createCatalog / syncCatalog throw LogicException with R15 hint when
* flag ON (Merchant Center is a separate API surface)
* 7. Resource-name discipline: pause/resume require full
* `customers/{cid}/campaigns/{id}` form; bare ID throws InvalidArgument
*/
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\GoogleAdPlatform;
use App\Services\Ads\Results\AudienceSyncResult;
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_google_enabled' => true]);
config(['ads.platforms.google.developer_token' => 'TEST_DEV_TOKEN']);
Http::preventStrayRequests();
});
function googleDto(string $name = 'Google R12.1 Campaign'): PublishCampaignDto
{
return new PublishCampaignDto(
campaignId: 'c-' . uniqid(),
name: $name,
objective: 'traffic',
dailyBudget: 25.00,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: 'MAXIMIZE_CLICKS',
scheduleStart: null,
scheduleEnd: null,
);
}
// ============================================================
// 1. Flag default
// ============================================================
test('ads.hardening.real_google_enabled defaults to false', function () {
$defaults = require base_path('config/ads.php');
expect($defaults['hardening']['real_google_enabled'] ?? true)->toBeFalse();
});
// ============================================================
// 2. Flag OFF → mock delegation (no HTTP fired)
// ============================================================
test('flag OFF — publishCampaign delegates to MockAdPlatform (no HTTP)', function () {
config(['ads.hardening.real_google_enabled' => false]);
Http::fake();
$result = app(GoogleAdPlatform::class)->publishCampaign('tok', '1234567890', googleDto(), 'idem-1');
expect($result->remoteCampaignId)->toStartWith('mock_camp_');
Http::assertNothingSent();
});
test('flag OFF — pullMetrics delegates to Mock', function () {
config(['ads.hardening.real_google_enabled' => false]);
Http::fake();
$result = app(GoogleAdPlatform::class)->pullMetrics(
'tok',
'1234567890',
Carbon::parse('2026-04-01'),
Carbon::parse('2026-04-02'),
['camp_a'],
'UTC',
);
expect($result)->toBeInstanceOf(MetricsResult::class);
Http::assertNothingSent();
});
test('flag OFF — syncAudience delegates to Mock', function () {
config(['ads.hardening.real_google_enabled' => false]);
Http::fake();
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List',
description: null,
hashedEmails: ['hash1', 'hash2'],
);
$result = app(GoogleAdPlatform::class)->syncAudience('tok', '1234567890', $dto, 'idem');
expect($result->platformAudienceId)->toStartWith('mock_aud_');
Http::assertNothingSent();
});
// ============================================================
// 3. Real API — happy paths (one per method)
// ============================================================
test('publishCampaign — POST /customers/{id}:mutate creates budget + campaign atomically', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1234567890:mutate' => Http::response([
'mutateOperationResponses' => [
['campaignBudgetResult' => ['resourceName' => 'customers/1234567890/campaignBudgets/777']],
['campaignResult' => ['resourceName' => 'customers/1234567890/campaigns/55555']],
],
], 200),
]);
$result = app(GoogleAdPlatform::class)->publishCampaign('tok', '1234567890', googleDto(), 'idem-pub');
expect($result)->toBeInstanceOf(PublishResult::class)
->and($result->status)->toBe(PublishResult::STATUS_PENDING)
->and($result->remoteCampaignId)->toBe('customers/1234567890/campaigns/55555')
->and($result->metadata['real'])->toBeTrue();
Http::assertSent(function ($request) {
$payload = $request->data();
$ops = $payload['mutateOperations'] ?? [];
return str_contains($request->url(), '/customers/1234567890:mutate')
&& count($ops) === 2
&& isset($ops[0]['campaignBudgetOperation']['create']['amountMicros'])
&& $ops[0]['campaignBudgetOperation']['create']['amountMicros'] === '25000000'
&& isset($ops[1]['campaignOperation']['create']['campaignBudget'])
&& $ops[1]['campaignOperation']['create']['advertisingChannelType'] === 'SEARCH'
&& $ops[1]['campaignOperation']['create']['status'] === 'PAUSED';
});
});
test('publishCampaign — sends developer-token + bearer headers', function () {
Http::fake(['*' => Http::response([
'mutateOperationResponses' => [
['campaignBudgetResult' => ['resourceName' => 'customers/1/campaignBudgets/1']],
['campaignResult' => ['resourceName' => 'customers/1/campaigns/2']],
],
], 200)]);
app(GoogleAdPlatform::class)->publishCampaign('test_token', '1', googleDto(), 'idem');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'Bearer test_token')
&& $request->hasHeader('developer-token', 'TEST_DEV_TOKEN');
});
});
test('publishCampaign — strips non-digits from customer id (handles 123-456-7890 form)', function () {
Http::fake(['*' => Http::response([
'mutateOperationResponses' => [
['campaignBudgetResult' => ['resourceName' => 'customers/1234567890/campaignBudgets/1']],
['campaignResult' => ['resourceName' => 'customers/1234567890/campaigns/2']],
],
], 200)]);
app(GoogleAdPlatform::class)->publishCampaign('tok', '123-456-7890', googleDto(), 'idem');
Http::assertSent(fn ($r) => str_contains($r->url(), '/customers/1234567890:mutate'));
});
test('pauseCampaign — update mutate with status=PAUSED + updateMask', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/campaigns:mutate' => Http::response([
'results' => [['resourceName' => 'customers/1/campaigns/99']],
], 200),
]);
$result = app(GoogleAdPlatform::class)->pauseCampaign('tok', 'customers/1/campaigns/99', 'idem-p');
expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
->and($result->remoteCampaignId)->toBe('customers/1/campaigns/99');
Http::assertSent(function ($r) {
$op = $r->data()['operations'][0] ?? [];
return ($op['update']['status'] ?? null) === 'PAUSED'
&& ($op['updateMask'] ?? null) === 'status'
&& ($op['update']['resourceName'] ?? null) === 'customers/1/campaigns/99';
});
});
test('resumeCampaign — update mutate with status=ENABLED', function () {
Http::fake(['*' => Http::response(['results' => [['resourceName' => 'customers/1/campaigns/99']]], 200)]);
$result = app(GoogleAdPlatform::class)->resumeCampaign('tok', 'customers/1/campaigns/99', 'idem-r');
expect($result->status)->toBe(PublishResult::STATUS_ACTIVE);
Http::assertSent(fn ($r) => ($r->data()['operations'][0]['update']['status'] ?? null) === 'ENABLED');
});
test('pause/resume — bare campaign id without resource path throws InvalidArgumentException', function () {
expect(fn () => app(GoogleAdPlatform::class)->pauseCampaign('tok', '99', 'idem'))
->toThrow(\InvalidArgumentException::class, 'full resource name');
});
test('pullMetrics — POST googleAds:search with GAQL', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/googleAds:search' => Http::response([
'results' => [
[
'campaign' => ['id' => '120100'],
'segments' => ['date' => '2026-04-01'],
'metrics' => [
'impressions' => '1000',
'clicks' => '50',
'costMicros' => '12500000',
'conversions' => '3',
'ctr' => '5.0',
'averageCpc' => '250000',
'averageCpm' => '12500000',
],
],
[
'campaign' => ['id' => '120100'],
'segments' => ['date' => '2026-04-02'],
'metrics' => [
'impressions' => '1200',
'clicks' => '60',
'costMicros' => '14000000',
'conversions' => '4',
],
],
],
], 200),
]);
$result = app(GoogleAdPlatform::class)->pullMetrics(
'tok',
'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) // 12_500_000 micros / 1M
->and($first->cpc)->toBe(0.25); // 250_000 micros / 1M
Http::assertSent(function ($r) {
$q = (string) ($r->data()['query'] ?? '');
return str_contains($q, 'FROM campaign')
&& str_contains($q, 'campaign.id IN (120100)')
&& str_contains($q, "BETWEEN '2026-04-01' AND '2026-04-02'");
});
});
test('pullMetrics — empty remoteCampaignIds short-circuits without HTTP', function () {
Http::fake();
$result = app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), [], 'UTC');
expect($result->count())->toBe(0);
Http::assertNothingSent();
});
test('syncAudience — creates user list when no platformAudienceId provided', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/userLists:mutate' => Http::response([
'results' => [['resourceName' => 'customers/1/userLists/aud_new_999']],
], 200),
]);
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List A',
description: 'Test',
hashedEmails: [],
);
$result = app(GoogleAdPlatform::class)->syncAudience('tok', '1', $dto, 'idem-a');
expect($result)->toBeInstanceOf(AudienceSyncResult::class)
->and($result->platformAudienceId)->toBe('customers/1/userLists/aud_new_999')
->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS);
Http::assertSent(fn ($r) => isset($r->data()['operations'][0]['create']['crmBasedUserList']));
});
test('syncAudience — STATUS_PARTIAL when hashed emails present (upload deferred)', function () {
Http::fake(['*' => Http::response([
'results' => [['resourceName' => 'customers/1/userLists/777']],
], 200)]);
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List',
description: null,
hashedEmails: ['h1', 'h2', 'h3'],
);
$result = app(GoogleAdPlatform::class)->syncAudience('tok', '1', $dto, 'idem');
expect($result->status)->toBe(AudienceSyncResult::STATUS_PARTIAL)
->and($result->uploadedCount)->toBe(3);
});
test('syncAudience — reuses provided platformAudienceId, skips create', function () {
Http::fake();
$dto = new SyncAudienceDto(
platformAudienceId: 'customers/1/userLists/existing',
name: 'List',
description: null,
hashedEmails: [],
);
$result = app(GoogleAdPlatform::class)->syncAudience('tok', '1', $dto, 'idem');
expect($result->platformAudienceId)->toBe('customers/1/userLists/existing');
Http::assertNothingSent();
});
test('deleteAudience — userLists:mutate remove with full resource name', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/userLists:mutate' => Http::response(['results' => []], 200),
]);
$result = app(GoogleAdPlatform::class)->deleteAudience('tok', 'customers/1/userLists/777');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
Http::assertSent(fn ($r) => ($r->data()['operations'][0]['remove'] ?? null) === 'customers/1/userLists/777');
});
test('deleteAudience — 404 treated as STATUS_DELETED (GDPR idempotent)', function () {
Http::fake([
'*' => Http::response(['error' => ['message' => 'not found']], 404),
]);
$result = app(GoogleAdPlatform::class)->deleteAudience('tok', 'customers/1/userLists/missing');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
});
test('getLeadForms — googleAds:search with FROM asset WHERE asset.type=LEAD_FORM', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/googleAds:search' => Http::response([
'results' => [
[
'asset' => [
'id' => '111',
'name' => 'Form A',
'leadFormAsset' => [
'fields' => [['inputType' => 'EMAIL']],
],
],
],
[
'asset' => [
'id' => '222',
'name' => 'Form B',
'leadFormAsset' => ['fields' => []],
],
],
],
], 200),
]);
$forms = app(GoogleAdPlatform::class)->getLeadForms('tok', '1');
expect($forms)->toBeArray()->toHaveCount(2)
->and($forms[0]['id'])->toBe('111')
->and($forms[0]['name'])->toBe('Form A');
Http::assertSent(function ($r) {
$q = (string) ($r->data()['query'] ?? '');
return str_contains($q, "asset.type = 'LEAD_FORM'");
});
});
test('fetchLeads — formId as full resource name + GAQL on lead_form_submission_data', function () {
Http::fake([
'googleads.googleapis.com/v17/customers/1/googleAds:search' => Http::response([
'results' => [
[
'leadFormSubmissionData' => [
'id' => 'sub_1',
'submissionDateTime' => '2026-04-25 10:00:00',
'leadFormSubmissionFields' => [
['fieldType' => 'EMAIL', 'fieldValue' => '[email protected]'],
['fieldType' => 'FULL_NAME', 'fieldValue' => 'Alice'],
],
],
],
],
], 200),
]);
$result = app(GoogleAdPlatform::class)->fetchLeads('tok', 'customers/1/assets/333', null);
expect($result)->toBeInstanceOf(LeadFetchResult::class);
expect($result->leads)->toHaveCount(1);
expect($result->leads[0]['platform_lead_id'])->toBe('sub_1');
expect($result->leads[0]['fields']['EMAIL'])->toBe('[email protected]');
expect($result->leads[0]['fields']['FULL_NAME'])->toBe('Alice');
});
test('fetchLeads — bare form id without resource path or cursor throws clear error', function () {
expect(fn () => app(GoogleAdPlatform::class)->fetchLeads('tok', '333', null))
->toThrow(\InvalidArgumentException::class, 'full resource name');
});
// R15.2 retargeted these two tests: the catalog methods used to throw
// `LogicException` pointing at R15; that placeholder is now filled. With the
// catalog flag OFF (default for this test file) the methods delegate to
// MockAdPlatform; flag-ON behavior + error mapping is covered by
// AdsR15_2GoogleMerchantCenterTest. Keep one assertion here that flag-OFF
// + real_google_enabled true still routes catalog calls through mock — the
// independent flag matrix that R15.2 introduced.
test('catalog flag OFF — createCatalog delegates to mock even when real_google_enabled is ON', function () {
config([
'ads.hardening.real_google_enabled' => true,
'ads.hardening.real_google_catalog_enabled' => false,
]);
Http::fake();
$id = app(GoogleAdPlatform::class)->createCatalog('tok', '1', 'cat');
expect($id)->toBeString()->and($id)->not->toBeEmpty();
Http::assertNothingSent();
});
test('catalog flag OFF — syncCatalog delegates to mock even when real_google_enabled is ON', function () {
config([
'ads.hardening.real_google_enabled' => true,
'ads.hardening.real_google_catalog_enabled' => false,
]);
Http::fake();
$result = app(GoogleAdPlatform::class)->syncCatalog('tok', 'cat_1', [], 'idem');
expect($result)->toBeInstanceOf(\App\Services\Ads\Results\CatalogSyncResult::class);
Http::assertNothingSent();
});
test('flag OFF — createCatalog falls through to mock without throwing', function () {
config(['ads.hardening.real_google_enabled' => false]);
Http::fake();
$catId = app(GoogleAdPlatform::class)->createCatalog('tok', '1', 'cat');
expect($catId)->toBeString();
expect(strlen($catId))->toBeGreaterThan(0);
Http::assertNothingSent();
});
// ============================================================
// 4. Error mapping taxonomy
// ============================================================
test('error: 401 + authenticationError + OAUTH_TOKEN_REVOKED → TokenRevokedException', function () {
Http::fake(['*' => Http::response([
'error' => [
'message' => 'token revoked',
'details' => [['errors' => [[
'errorCode' => ['authenticationError' => 'OAUTH_TOKEN_REVOKED'],
]]]],
],
], 401)]);
expect(fn () => app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(TokenRevokedException::class);
});
test('error: 401 + authenticationError + USER_PERMISSION_DENIED → TokenExpiredException', function () {
Http::fake(['*' => Http::response([
'error' => [
'message' => 'access denied',
'details' => [['errors' => [[
'errorCode' => ['authenticationError' => 'USER_PERMISSION_DENIED'],
]]]],
],
], 401)]);
expect(fn () => app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(TokenExpiredException::class);
});
test('error: 429 + quotaError → RateLimitException with retry-after', function () {
Http::fake(['*' => Http::response([
'error' => [
'message' => 'rate limited',
'details' => [['errors' => [['errorCode' => ['quotaError' => 'RESOURCE_EXHAUSTED']]]]],
],
], 429, ['Retry-After' => '42'])]);
$caught = null;
try {
app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), ['x'], 'UTC');
} catch (RateLimitException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(RateLimitException::class);
expect($caught?->getRetryAfter())->toBe(42);
});
test('error: policyViolationError → PolicyViolationException', function () {
Http::fake(['*' => Http::response([
'error' => [
'message' => 'policy block',
'details' => [['errors' => [['errorCode' => ['policyViolationError' => 'PROHIBITED_PRODUCT']]]]],
],
], 400)]);
expect(fn () => app(GoogleAdPlatform::class)->publishCampaign('tok', '1', googleDto(), 'idem'))
->toThrow(PolicyViolationException::class);
});
test('error: fieldError / 400 → ValidationException', function () {
Http::fake(['*' => Http::response([
'error' => [
'message' => 'bad field',
'details' => [['errors' => [['errorCode' => ['fieldError' => 'INVALID_VALUE']]]]],
],
], 400)]);
expect(fn () => app(GoogleAdPlatform::class)->publishCampaign('tok', '1', googleDto(), 'idem'))
->toThrow(ValidationException::class);
});
test('error: 404 → ResourceNotFoundException', function () {
Http::fake(['*' => Http::response(['error' => ['message' => 'not found']], 404)]);
expect(fn () => app(GoogleAdPlatform::class)->pauseCampaign('tok', 'customers/1/campaigns/9999', 'idem'))
->toThrow(ResourceNotFoundException::class);
});
test('error: 503 → NetworkException', function () {
Http::fake(['*' => Http::response(['error' => ['message' => 'server down']], 503)]);
expect(fn () => app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(NetworkException::class);
});
test('error: unknown body → UnexpectedResponseException', function () {
Http::fake(['*' => Http::response(['error' => ['message' => 'who knows']], 418)]);
expect(fn () => app(GoogleAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(UnexpectedResponseException::class);
});
// ============================================================
// 5. Method-override coverage (no R1 stub leakage)
// ============================================================
test('every R1-extended method is overridden on GoogleAdPlatform (no trait fallthrough at runtime)', function () {
$reflection = new ReflectionClass(GoogleAdPlatform::class);
$methods = [
'publishCampaign', 'pauseCampaign', 'resumeCampaign', 'pullMetrics',
'syncAudience', 'deleteAudience', 'getLeadForms', 'fetchLeads',
'createCatalog', 'syncCatalog',
];
foreach ($methods as $method) {
$declared = $reflection->getMethod($method)->getDeclaringClass()->getName();
expect($declared)->toBe(GoogleAdPlatform::class, "method {$method} not overridden");
}
});