File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR15_4LinkedInDynamicAdsCatalogTest.php
<?php
/**
* Ads Phase R15.4 — LinkedIn dynamic-ads catalog adapter (real catalog calls).
*
* Verifies:
* 1-2. Flag-OFF createCatalog / syncCatalog delegate to MockAdPlatform.
* 3. Flag-ON createCatalog hits /rest/dataItemCatalogs, returns full
* `urn:li:dataItemCatalog:{id}` form; reads x-restli-id header.
* 4. Flag-ON createCatalog falls back to Location header when
* x-restli-id is missing.
* 5. Flag-ON createCatalog with neither header throws
* UnexpectedResponseException — never silently stores ''.
* 6. Flag-ON syncCatalog encodes products into LinkedIn dataItem schema
* (dataItemCatalog, externalId, title, landingPageUrl, imageUrl,
* price, currency, salePrice, brand, category, additionalFields).
* 7. Flag-ON syncCatalog chunks at LI_BATCH_LIMIT = 50.
* 8. Flag-ON syncCatalog parses per-element 422 errors tagged by
* external_id via input-order index mapping.
* 9. Flag-ON normalizeCatalogUrn accepts URN, bare numeric, and path
* forms; rejects malformed.
* 10. Flag-ON 403 maps to ValidationException with MDP onboarding hint.
* 11. Independent flag matrix — real_linkedin_enabled alone does NOT
* enable real catalog calls.
*/
use App\Services\Ads\Exceptions\PolicyViolationException;
use App\Services\Ads\Exceptions\UnexpectedResponseException;
use App\Services\Ads\LinkedInAdPlatform;
use App\Services\Ads\Results\CatalogSyncResult;
use Illuminate\Support\Facades\Http;
uses(Tests\TestCase::class);
beforeEach(function () {
config([
'ads.hardening.real_linkedin_catalog_enabled' => false,
'ads.hardening.real_linkedin_enabled' => false,
'ads.platforms.linkedin.api_version' => '202404',
]);
Http::preventStrayRequests();
});
// ---------------------------------------------------------------------------
// 1-2. Flag-OFF delegation
// ---------------------------------------------------------------------------
test('flag-OFF createCatalog delegates to MockAdPlatform without real HTTP', function () {
Http::fake();
$li = new LinkedInAdPlatform();
$id = $li->createCatalog('tok', '502840441', 'My Catalog');
expect($id)->toBeString()->and($id)->not->toBeEmpty();
Http::assertNothingSent();
});
test('flag-OFF syncCatalog delegates to MockAdPlatform without real HTTP', function () {
Http::fake();
$li = new LinkedInAdPlatform();
$result = $li->syncCatalog('tok', 'mock_cat', [['external_id' => '1']], 'idem_a');
expect($result)->toBeInstanceOf(CatalogSyncResult::class);
Http::assertNothingSent();
});
// ---------------------------------------------------------------------------
// 3. createCatalog success via x-restli-id header
// ---------------------------------------------------------------------------
test('flag-ON createCatalog hits /rest/dataItemCatalogs, reads x-restli-id, returns URN', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
$captured = [];
Http::fake([
'api.linkedin.com/rest/dataItemCatalogs' => function ($request) use (&$captured) {
$captured = $request->data();
return Http::response('', 201, ['x-restli-id' => '12345']);
},
]);
$li = new LinkedInAdPlatform();
$urn = $li->createCatalog('tok_xyz', '502840441', 'Spring Catalog');
expect($urn)->toBe('urn:li:dataItemCatalog:12345');
expect($captured['name'])->toBe('Spring Catalog');
expect($captured['account'])->toBe('urn:li:sponsoredAccount:502840441');
Http::assertSent(function ($request) {
$headers = $request->headers();
return $headers['LinkedIn-Version'][0] === '202404'
&& $headers['X-Restli-Protocol-Version'][0] === '2.0.0';
});
});
// ---------------------------------------------------------------------------
// 4. createCatalog falls back to Location header
// ---------------------------------------------------------------------------
test('flag-ON createCatalog falls back to Location header when x-restli-id is missing', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
Http::fake([
'api.linkedin.com/rest/dataItemCatalogs' => Http::response('', 201, [
'Location' => '/rest/dataItemCatalogs/67890',
]),
]);
$li = new LinkedInAdPlatform();
expect($li->createCatalog('tok', '502840441', 'cat'))->toBe('urn:li:dataItemCatalog:67890');
});
// ---------------------------------------------------------------------------
// 5. createCatalog with no id headers throws
// ---------------------------------------------------------------------------
test('flag-ON createCatalog without x-restli-id or Location throws UnexpectedResponseException', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
Http::fake([
'api.linkedin.com/rest/dataItemCatalogs' => Http::response('', 201),
]);
$li = new LinkedInAdPlatform();
expect(fn () => $li->createCatalog('tok', '502840441', 'cat'))
->toThrow(UnexpectedResponseException::class, 'missing x-restli-id and Location');
});
// ---------------------------------------------------------------------------
// 6. syncCatalog encoding
// ---------------------------------------------------------------------------
test('flag-ON syncCatalog encodes products into LinkedIn dataItem schema', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
$captured = [];
Http::fake([
'api.linkedin.com/rest/dataItems' => function ($request) use (&$captured) {
$captured = $request->data();
$elements = (array) ($captured['elements'] ?? []);
$resp = ['elements' => array_map(fn () => ['status' => 201], $elements)];
return Http::response($resp, 200);
},
]);
$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' => ['gender' => 'unisex'],
]];
$li = new LinkedInAdPlatform();
$result = $li->syncCatalog('tok_xyz', 'urn:li:dataItemCatalog:12345', $products, 'idem');
expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
expect($result->syncedCount)->toBe(1);
$element = $captured['elements'][0];
expect($element['dataItemCatalog'])->toBe('urn:li:dataItemCatalog:12345');
expect($element['externalId'])->toBe('sku-001');
expect($element['title'])->toBe('Red Sneakers');
expect($element['description'])->toBe('Bright red running shoes');
expect($element['landingPageUrl'])->toBe('https://shop.example.test/p/red');
expect($element['imageUrl'])->toBe('https://cdn.example.test/red.jpg');
expect($element['price'])->toBe('49.99');
expect($element['currency'])->toBe('USD');
expect($element['salePrice'])->toBe('39.99');
expect($element['brand'])->toBe('Acme');
expect($element['category'])->toBe('Apparel > Shoes');
expect($element['additionalFields'])->toBe(['gender' => 'unisex']);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& str_contains($request->url(), '/rest/dataItems')
&& $request->header('X-RestLi-Method')[0] === 'BATCH_CREATE';
});
});
// ---------------------------------------------------------------------------
// 7. Chunking at 50
// ---------------------------------------------------------------------------
test('flag-ON syncCatalog chunks at LinkedIn 50-elements limit', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
$callCount = 0;
Http::fake([
'api.linkedin.com/rest/dataItems' => function ($request) use (&$callCount) {
$callCount++;
$elements = (array) ($request->data()['elements'] ?? []);
$resp = ['elements' => array_map(fn () => ['status' => 201], $elements)];
return Http::response($resp, 200);
},
]);
// 130 products = 3 chunks (50 + 50 + 30).
$products = [];
for ($i = 0; $i < 130; $i++) {
$products[] = [
'external_id' => 'sku_' . $i,
'title' => 'Product ' . $i,
'price' => '10.00',
'currency' => 'USD',
'image_url' => 'https://cdn.example.test/' . $i . '.jpg',
'product_url' => 'https://shop.example.test/' . $i,
'availability' => 'in_stock',
];
}
$li = new LinkedInAdPlatform();
$result = $li->syncCatalog('tok', 'urn:li:dataItemCatalog:12345', $products, 'idem');
expect($callCount)->toBe(3);
expect($result->syncedCount)->toBe(130);
expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
});
// ---------------------------------------------------------------------------
// 8. Per-element 422 errors mapped via index
// ---------------------------------------------------------------------------
test('flag-ON syncCatalog returns STATUS_PARTIAL with per-element errors tagged by external_id', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
Http::fake([
'api.linkedin.com/rest/dataItems' => Http::response([
'elements' => [
['status' => 201],
['status' => 422, 'error' => ['message' => 'invalid landingPageUrl']],
['status' => 201],
],
], 200),
]);
$products = [
['external_id' => 'sku-A', 'title' => 'A', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/a.jpg', 'product_url' => 'https://x/a', 'availability' => 'in_stock'],
['external_id' => 'sku-B', 'title' => 'B', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/b.jpg', 'product_url' => 'invalid', 'availability' => 'in_stock'],
['external_id' => 'sku-C', 'title' => 'C', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/c.jpg', 'product_url' => 'https://x/c', 'availability' => 'in_stock'],
];
$li = new LinkedInAdPlatform();
$result = $li->syncCatalog('tok', 'urn:li:dataItemCatalog:12345', $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('http_422');
expect($result->errors[0]['error'])->toContain('invalid landingPageUrl');
});
// ---------------------------------------------------------------------------
// 9. URN normalization
// ---------------------------------------------------------------------------
test('flag-ON normalizeCatalogUrn accepts URN, bare numeric, and path forms', function () {
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
Http::fake([
'api.linkedin.com/rest/dataItems' => function ($request) {
$elements = (array) ($request->data()['elements'] ?? []);
return Http::response(['elements' => array_map(fn () => ['status' => 201], $elements)], 200);
},
]);
$product = [['external_id' => 'sku', 'title' => 't', 'price' => '1', 'currency' => 'USD', 'image_url' => 'https://c/x.jpg', 'product_url' => 'https://x', 'availability' => 'in_stock']];
$li = new LinkedInAdPlatform();
expect($li->syncCatalog('t', 'urn:li:dataItemCatalog:12345', $product, 'k')->status)
->toBe(CatalogSyncResult::STATUS_SUCCESS);
expect($li->syncCatalog('t', '12345', $product, 'k')->status)
->toBe(CatalogSyncResult::STATUS_SUCCESS);
expect($li->syncCatalog('t', '/rest/dataItemCatalogs/12345', $product, 'k')->status)
->toBe(CatalogSyncResult::STATUS_SUCCESS);
expect(fn () => $li->syncCatalog('t', 'totally-bogus', $product, 'k'))
->toThrow(\InvalidArgumentException::class);
});
// ---------------------------------------------------------------------------
// 10. 403 PERMISSION_DENIED → ValidationException
// ---------------------------------------------------------------------------
test('flag-ON 403 PERMISSION_DENIED maps to PolicyViolationException with MDP onboarding hint', function () {
// R12.3 already maps LinkedIn 403 → PolicyViolationException (the existing
// taxonomy treats permission/policy as a single non-retryable bucket).
// R15.4 inherits that mapping — the resolution hint mentions
// "Marketing Developer Platform tier", which is the right onboarding cue
// for a dynamic-ads-access denial.
config(['ads.hardening.real_linkedin_catalog_enabled' => true]);
Http::fake([
'api.linkedin.com/rest/dataItemCatalogs' => Http::response([
'message' => 'Caller is not authorized to access this resource.',
'serviceErrorCode' => 65603,
'status' => 403,
], 403),
]);
$li = new LinkedInAdPlatform();
$caught = null;
try {
$li->createCatalog('tok', '502840441', 'cat');
} catch (PolicyViolationException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(PolicyViolationException::class);
expect($caught?->getResolutionHint())->toContain('Marketing Developer Platform');
});
// ---------------------------------------------------------------------------
// 11. Independent flag matrix
// ---------------------------------------------------------------------------
test('real_linkedin_enabled alone does NOT enable real catalog calls', function () {
config([
'ads.hardening.real_linkedin_enabled' => true,
'ads.hardening.real_linkedin_catalog_enabled' => false,
]);
Http::fake();
$li = new LinkedInAdPlatform();
$id = $li->createCatalog('tok', '502840441', 'cat');
expect($id)->toBeString()->and($id)->not->toBeEmpty();
Http::assertNothingSent();
});