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();
});