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/be.naniguide.com/tests/Feature/AdsR15_2GoogleMerchantCenterTest.php
<?php

/**
 * Ads Phase R15.2 — Google Merchant Center adapter (real catalog calls).
 *
 * Verifies:
 *   1. Flag-OFF (`real_google_catalog_enabled=false`) → createCatalog
 *      delegates to MockAdPlatform (no real HTTP issued).
 *   2. Flag-OFF → syncCatalog delegates to MockAdPlatform.
 *   3. Flag-ON without `merchant_id` config → ValidationException with
 *      `gmc:config:missing_merchant_id` platform code.
 *   4. Flag-ON createCatalog issues `accounts.get` pre-flight, returns
 *      `merchants/{id}/feedLabel:{slug}` round-trippable id.
 *   5. Flag-ON syncCatalog encodes products into GMC schema (offerId,
 *      title, description, link, imageLink, price.value+currency,
 *      availability, condition, targetCountry, contentLanguage, channel,
 *      feedLabel) inside `entries[].product`.
 *   6. Flag-ON syncCatalog chunks products at the 1000-entries GMC limit.
 *   7. Flag-ON syncCatalog returns STATUS_SUCCESS when all entries succeed.
 *   8. Flag-ON syncCatalog returns STATUS_PARTIAL with per-product errors
 *      tagged by `external_id` on partial failure.
 *   9. Flag-ON 401 → TokenExpiredException with `gmc:authError` code.
 *  10. Flag-ON 429 → RateLimitException with Retry-After parsed.
 *  11. Flag-ON 403 (insufficient scope) → ValidationException with
 *      reconnect hint.
 *  12. getDefaultScopes includes `auth/content` for new connections.
 *  13. parsePlatformCatalogId accepts both encoded and bare formats.
 *  14. Independent flag matrix — `real_google_enabled=true` alone does
 *      NOT enable real catalog calls.
 */

use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\Exceptions\ValidationException;
use App\Services\Ads\GoogleAdPlatform;
use App\Services\Ads\Results\CatalogSyncResult;
use Illuminate\Support\Facades\Http;

uses(Tests\TestCase::class);

beforeEach(function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => false,
        'ads.hardening.real_google_enabled' => false,
        'ads.platforms.google.merchant_id' => null,
        'ads.platforms.google.content_country' => 'US',
        'ads.platforms.google.content_language' => 'en',
    ]);
    Http::preventStrayRequests();
});

// ---------------------------------------------------------------------------
// 1-2. Flag-OFF delegation
// ---------------------------------------------------------------------------

test('flag-OFF createCatalog delegates to MockAdPlatform without real HTTP', function () {
    Http::fake(); // any unexpected call would explode preventStrayRequests
    $g = new GoogleAdPlatform();
    $id = $g->createCatalog('tok', 'cust_123', 'My Catalog');
    expect($id)->toBeString()->and($id)->not->toBeEmpty();
    Http::assertNothingSent();
});

test('flag-OFF syncCatalog delegates to MockAdPlatform without real HTTP', function () {
    Http::fake();
    $g = new GoogleAdPlatform();
    $result = $g->syncCatalog('tok', 'mock_cat', [['external_id' => '1']], 'idem_a');
    expect($result)->toBeInstanceOf(CatalogSyncResult::class);
    Http::assertNothingSent();
});

// ---------------------------------------------------------------------------
// 3. Flag-ON misconfig
// ---------------------------------------------------------------------------

test('flag-ON createCatalog without merchant_id throws ValidationException', function () {
    config(['ads.hardening.real_google_catalog_enabled' => true]);
    Http::fake();
    $g = new GoogleAdPlatform();
    expect(fn () => $g->createCatalog('tok', 'cust_123', 'My Catalog'))
        ->toThrow(ValidationException::class, 'merchant_id is not configured');
    Http::assertNothingSent();
});

// ---------------------------------------------------------------------------
// 4. createCatalog success path
// ---------------------------------------------------------------------------

test('flag-ON createCatalog pre-flights accounts.get and returns encoded id', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);

    Http::fake([
        'shoppingcontent.googleapis.com/content/v2.1/987654/accounts/987654' => Http::response([
            'kind' => 'content#account',
            'id' => '987654',
            'name' => 'Test Merchant',
        ], 200),
    ]);

    $g = new GoogleAdPlatform();
    $id = $g->createCatalog('access_tok_123', 'unused_ad_account', 'My Spring 2026 Catalog');

    // Slug truncates at 20 chars and lowercases.
    expect($id)->toBe('merchants/987654/feedLabel:my-spring-2026-catal');

    Http::assertSent(function ($request) {
        return $request->method() === 'GET'
            && str_contains($request->url(), '/987654/accounts/987654')
            && $request->header('Authorization')[0] === 'Bearer access_tok_123';
    });
});

// ---------------------------------------------------------------------------
// 5-7. syncCatalog success path
// ---------------------------------------------------------------------------

test('flag-ON syncCatalog encodes products into GMC schema and returns STATUS_SUCCESS', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);

    $captured = [];
    Http::fake([
        'shoppingcontent.googleapis.com/content/v2.1/products/batch' => function ($request) use (&$captured) {
            $captured = $request->data();
            $entries = (array) ($captured['entries'] ?? []);
            $responseEntries = [];
            foreach ($entries as $e) {
                $responseEntries[] = [
                    'kind' => 'content#productsCustomBatchResponseEntry',
                    'batchId' => $e['batchId'],
                    'product' => ['offerId' => $e['product']['offerId']],
                ];
            }
            return Http::response(['kind' => 'content#productsCustomBatchResponse', 'entries' => $responseEntries], 200);
        },
    ]);

    $g = new GoogleAdPlatform();
    $products = [
        [
            'external_id' => 'sku-001',
            'title' => 'Red Sneakers',
            'description' => 'Bright red running shoes',
            'price' => '49.99',
            'sale_price' => '39.99',
            'currency' => 'USD',
            'image_url' => 'https://cdn.example.test/red.jpg',
            'product_url' => 'https://shop.example.test/p/red',
            'availability' => 'in_stock',
            'category' => 'Apparel > Shoes',
            'brand' => 'Acme',
            'custom' => ['condition' => 'new'],
        ],
    ];
    $result = $g->syncCatalog('tok_xyz', 'merchants/987654/feedLabel:spring', $products, 'idem_xyz');

    expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
    expect($result->syncedCount)->toBe(1);
    expect($result->errorCount)->toBe(0);

    expect($captured)->not->toBeEmpty();
    expect($captured['entries'])->toHaveCount(1);
    $entry = $captured['entries'][0];
    expect($entry['method'])->toBe('insert');
    expect($entry['merchantId'])->toBe('987654');
    expect($entry['product']['offerId'])->toBe('sku-001');
    expect($entry['product']['title'])->toBe('Red Sneakers');
    expect($entry['product']['description'])->toBe('Bright red running shoes');
    expect($entry['product']['link'])->toBe('https://shop.example.test/p/red');
    expect($entry['product']['imageLink'])->toBe('https://cdn.example.test/red.jpg');
    expect($entry['product']['availability'])->toBe('in_stock');
    expect($entry['product']['condition'])->toBe('new');
    expect($entry['product']['price'])->toBe(['value' => '49.99', 'currency' => 'USD']);
    expect($entry['product']['salePrice'])->toBe(['value' => '39.99', 'currency' => 'USD']);
    expect($entry['product']['targetCountry'])->toBe('US');
    expect($entry['product']['contentLanguage'])->toBe('en');
    expect($entry['product']['channel'])->toBe('online');
    expect($entry['product']['feedLabel'])->toBe('spring');
    expect($entry['product']['brand'])->toBe('Acme');
    expect($entry['product']['googleProductCategory'])->toBe('Apparel > Shoes');
});

test('flag-ON syncCatalog chunks at GMC 1000-entries limit', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);

    $callCount = 0;
    Http::fake([
        'shoppingcontent.googleapis.com/content/v2.1/products/batch' => function ($request) use (&$callCount) {
            $callCount++;
            $entries = (array) ($request->data()['entries'] ?? []);
            $resp = [];
            foreach ($entries as $e) {
                $resp[] = ['batchId' => $e['batchId'], 'product' => ['offerId' => $e['product']['offerId']]];
            }
            return Http::response(['entries' => $resp], 200);
        },
    ]);

    // 2500 products = 3 chunks (1000 + 1000 + 500).
    $products = [];
    for ($i = 0; $i < 2500; $i++) {
        $products[] = [
            'external_id' => 'sku_' . $i,
            'title' => 'Product ' . $i,
            'description' => 'Desc ' . $i,
            'price' => '10.00',
            'currency' => 'USD',
            'image_url' => 'https://cdn.example.test/' . $i . '.jpg',
            'product_url' => 'https://shop.example.test/' . $i,
            'availability' => 'in_stock',
        ];
    }

    $g = new GoogleAdPlatform();
    $result = $g->syncCatalog('tok', 'merchants/987654/feedLabel:big', $products, 'idem_big');

    expect($callCount)->toBe(3);
    expect($result->syncedCount)->toBe(2500);
    expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
});

// ---------------------------------------------------------------------------
// 8. Partial failure
// ---------------------------------------------------------------------------

test('flag-ON syncCatalog returns STATUS_PARTIAL with per-product errors', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);

    Http::fake([
        'shoppingcontent.googleapis.com/content/v2.1/products/batch' => Http::response([
            'entries' => [
                ['batchId' => 0, 'product' => ['offerId' => 'sku-A']],
                [
                    'batchId' => 1,
                    'errors' => [
                        'errors' => [
                            ['reason' => 'invalid', 'message' => 'price must be greater than zero'],
                        ],
                    ],
                ],
                ['batchId' => 2, 'product' => ['offerId' => 'sku-C']],
            ],
        ], 200),
    ]);

    $products = [
        ['external_id' => 'sku-A', 'title' => 'Good A', 'price' => '5.00', 'currency' => 'USD', 'image_url' => 'https://c/a.jpg', 'product_url' => 'https://x/a', 'availability' => 'in_stock'],
        ['external_id' => 'sku-B', 'title' => 'Bad B', 'price' => '0', 'currency' => 'USD', 'image_url' => 'https://c/b.jpg', 'product_url' => 'https://x/b', 'availability' => 'in_stock'],
        ['external_id' => 'sku-C', 'title' => 'Good C', 'price' => '7.00', 'currency' => 'USD', 'image_url' => 'https://c/c.jpg', 'product_url' => 'https://x/c', 'availability' => 'in_stock'],
    ];
    $g = new GoogleAdPlatform();
    $result = $g->syncCatalog('tok', 'merchants/987654/feedLabel:p', $products, 'idem');

    expect($result->status)->toBe(CatalogSyncResult::STATUS_PARTIAL);
    expect($result->syncedCount)->toBe(2);
    expect($result->errorCount)->toBe(1);
    expect($result->errors[0]['external_id'])->toBe('sku-B');
    expect($result->errors[0]['error'])->toContain('invalid');
    expect($result->errors[0]['error'])->toContain('price must be greater than zero');
});

// ---------------------------------------------------------------------------
// 9-11. Error mapping
// ---------------------------------------------------------------------------

test('flag-ON 401 maps to TokenExpiredException', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);
    Http::fake([
        'shoppingcontent.googleapis.com/*' => Http::response([
            'error' => [
                'code' => 401,
                'message' => 'Authentication failed',
                'errors' => [['reason' => 'authError', 'message' => 'Token expired']],
            ],
        ], 401),
    ]);

    $g = new GoogleAdPlatform();
    expect(fn () => $g->createCatalog('expired_tok', 'cust', 'cat'))
        ->toThrow(TokenExpiredException::class);
});

test('flag-ON 429 maps to RateLimitException with Retry-After parsed', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);
    Http::fake([
        'shoppingcontent.googleapis.com/*' => Http::response([
            'error' => [
                'code' => 429,
                'message' => 'Too many requests',
                'errors' => [['reason' => 'rateLimitExceeded']],
            ],
        ], 429, ['Retry-After' => '120']),
    ]);

    $g = new GoogleAdPlatform();
    $caught = null;
    try {
        $g->createCatalog('tok', 'cust', 'cat');
    } catch (RateLimitException $e) {
        $caught = $e;
    }
    expect($caught)->toBeInstanceOf(RateLimitException::class);
    expect($caught?->getRetryAfter())->toBe(120);
});

test('flag-ON 403 (insufficient scope) maps to ValidationException with reconnect hint', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);
    Http::fake([
        'shoppingcontent.googleapis.com/*' => Http::response([
            'error' => [
                'code' => 403,
                'message' => 'Insufficient permissions',
                'errors' => [['reason' => 'insufficientPermissions', 'message' => 'auth/content scope required']],
            ],
        ], 403),
    ]);

    $g = new GoogleAdPlatform();
    $caught = null;
    try {
        $g->createCatalog('tok', 'cust', 'cat');
    } catch (ValidationException $e) {
        $caught = $e;
    }
    expect($caught)->toBeInstanceOf(ValidationException::class);
    expect($caught?->getResolutionHint())->toContain('Reconnect');
    expect($caught?->getPlatformCode())->toBe('gmc:insufficientPermissions');
});

// ---------------------------------------------------------------------------
// 12. Default scopes
// ---------------------------------------------------------------------------

test('getDefaultScopes includes auth/content for Merchant Center', function () {
    $g = new GoogleAdPlatform();
    $scopes = $g->getDefaultScopes();
    expect($scopes)->toContain('https://www.googleapis.com/auth/adwords');
    expect($scopes)->toContain('https://www.googleapis.com/auth/content');
});

// ---------------------------------------------------------------------------
// 13. Catalog id round-trip
// ---------------------------------------------------------------------------

test('parsePlatformCatalogId accepts encoded form, bare merchants/, and bare numeric', function () {
    config([
        'ads.hardening.real_google_catalog_enabled' => true,
        'ads.platforms.google.merchant_id' => '987654',
    ]);
    Http::fake([
        'shoppingcontent.googleapis.com/content/v2.1/products/batch' => function ($request) {
            $entries = (array) ($request->data()['entries'] ?? []);
            $resp = [];
            foreach ($entries as $e) {
                $resp[] = ['batchId' => $e['batchId'], 'product' => ['offerId' => $e['product']['offerId']]];
            }
            return Http::response(['entries' => $resp], 200);
        },
    ]);

    $product = [['external_id' => 'sku', 'title' => 't', 'price' => '1', 'currency' => 'USD', 'image_url' => 'https://c/x.jpg', 'product_url' => 'https://x', 'availability' => 'in_stock']];

    $g = new GoogleAdPlatform();
    expect($g->syncCatalog('t', 'merchants/987654/feedLabel:abc', $product, 'k')->status)
        ->toBe(CatalogSyncResult::STATUS_SUCCESS);
    expect($g->syncCatalog('t', 'merchants/987654', $product, 'k')->status)
        ->toBe(CatalogSyncResult::STATUS_SUCCESS);
    expect($g->syncCatalog('t', '987654', $product, 'k')->status)
        ->toBe(CatalogSyncResult::STATUS_SUCCESS);

    expect(fn () => $g->syncCatalog('t', 'totally-bogus', $product, 'k'))
        ->toThrow(\InvalidArgumentException::class);
});

// ---------------------------------------------------------------------------
// 14. Independent flag matrix
// ---------------------------------------------------------------------------

test('real_google_enabled alone does NOT enable real catalog calls', function () {
    config([
        'ads.hardening.real_google_enabled' => true,
        'ads.hardening.real_google_catalog_enabled' => false, // catalog flag still off
        'ads.platforms.google.merchant_id' => '987654',
    ]);
    Http::fake();

    $g = new GoogleAdPlatform();
    $id = $g->createCatalog('tok', 'cust', 'cat');
    expect($id)->toBeString()->and($id)->not->toBeEmpty();

    // No real call to Merchant Center.
    Http::assertNothingSent();
});