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/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");
    }
});