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