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_3TikTokCatalogManagerTest.php
<?php

/**
 * Ads Phase R15.3 — TikTok Catalog Manager adapter (real catalog calls).
 *
 * Verifies:
 *   1-2. Flag-OFF createCatalog / syncCatalog delegate to MockAdPlatform.
 *   3. Flag-ON createCatalog hits /catalog/create/ with advertiser_id +
 *      name + business_type + region; returns data.catalog_id.
 *   4. Flag-ON createCatalog with missing data.catalog_id (despite code=0)
 *      throws UnexpectedResponseException — never silently stores ''.
 *   5. Flag-ON syncCatalog encodes products into TikTok schema
 *      (sku_id, title, description, availability, condition, price.value,
 *      price.currency, link, image_link, brand, google_product_category).
 *   6. Flag-ON syncCatalog chunks at TT_BATCH_LIMIT = 500.
 *   7. Flag-ON syncCatalog returns STATUS_SUCCESS on all-success.
 *   8. Flag-ON syncCatalog returns STATUS_PARTIAL with failed_list rows
 *      tagged by sku_id.
 *   9. Flag-ON syncCatalog gracefully handles legacy `success_skus` /
 *      `failed_skus` response shape.
 *  10. Flag-ON syncCatalog with neither list present assumes the chunk
 *      succeeded (since code=0 already passed guardResponse).
 *  11. Error: code 40100 → TokenExpiredException.
 *  12. Independent flag matrix: real_tiktok_enabled=true alone does NOT
 *      enable real catalog calls.
 */

use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\Exceptions\UnexpectedResponseException;
use App\Services\Ads\Results\CatalogSyncResult;
use App\Services\Ads\TikTokAdPlatform;
use Illuminate\Support\Facades\Http;

uses(Tests\TestCase::class);

beforeEach(function () {
    config([
        'ads.hardening.real_tiktok_catalog_enabled' => false,
        'ads.hardening.real_tiktok_enabled' => false,
        'ads.platforms.tiktok.catalog_business_type' => 'ECOMMERCE',
        'ads.platforms.tiktok.catalog_region' => 'US',
    ]);
    Http::preventStrayRequests();
});

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

test('flag-OFF createCatalog delegates to MockAdPlatform without real HTTP', function () {
    Http::fake();
    $tt = new TikTokAdPlatform();
    $id = $tt->createCatalog('tok', '1234567890', 'My Catalog');
    expect($id)->toBeString()->and($id)->not->toBeEmpty();
    Http::assertNothingSent();
});

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

// ---------------------------------------------------------------------------
// 3. createCatalog success path
// ---------------------------------------------------------------------------

test('flag-ON createCatalog hits /catalog/create/ and returns data.catalog_id', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);

    $captured = [];
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/create/' => function ($request) use (&$captured) {
            $captured = $request->data();
            return Http::response(['code' => 0, 'message' => 'OK', 'data' => ['catalog_id' => '987654321']], 200);
        },
    ]);

    $tt = new TikTokAdPlatform();
    $id = $tt->createCatalog('tok_xyz', '1234567890123456', 'Spring Catalog');

    expect($id)->toBe('987654321');
    expect($captured['advertiser_id'])->toBe('1234567890123456');
    expect($captured['name'])->toBe('Spring Catalog');
    expect($captured['business_type'])->toBe('ECOMMERCE');
    expect($captured['region'])->toBe('US');
});

// ---------------------------------------------------------------------------
// 4. createCatalog missing id
// ---------------------------------------------------------------------------

test('flag-ON createCatalog throws UnexpectedResponseException when data.catalog_id missing', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/create/' => Http::response(
            ['code' => 0, 'message' => 'OK', 'data' => null],
            200
        ),
    ]);

    $tt = new TikTokAdPlatform();
    expect(fn () => $tt->createCatalog('tok', '1234567890', 'Spring'))
        ->toThrow(UnexpectedResponseException::class, 'missing data.catalog_id');
});

// ---------------------------------------------------------------------------
// 5-7. syncCatalog success + schema
// ---------------------------------------------------------------------------

test('flag-ON syncCatalog encodes products into TikTok schema and returns STATUS_SUCCESS', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);

    $captured = [];
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/product/upload/' => function ($request) use (&$captured) {
            $captured = $request->data();
            $skus = array_map(fn ($p) => $p['sku_id'], (array) $captured['products']);
            return Http::response([
                'code' => 0,
                'data' => ['success_list' => $skus, 'failed_list' => []],
            ], 200);
        },
    ]);

    $tt = new TikTokAdPlatform();
    $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 = $tt->syncCatalog('tok_xyz', '987654321', $products, 'idem_xyz');

    expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
    expect($result->syncedCount)->toBe(1);
    expect($captured['catalog_id'])->toBe('987654321');
    $product = $captured['products'][0];
    expect($product['sku_id'])->toBe('sku-001');
    expect($product['title'])->toBe('Red Sneakers');
    expect($product['description'])->toBe('Bright red running shoes');
    expect($product['link'])->toBe('https://shop.example.test/p/red');
    expect($product['image_link'])->toBe('https://cdn.example.test/red.jpg');
    expect($product['availability'])->toBe('in_stock');
    expect($product['condition'])->toBe('new');
    expect($product['price'])->toBe(['value' => '49.99', 'currency' => 'USD']);
    expect($product['sale_price'])->toBe(['value' => '39.99', 'currency' => 'USD']);
    expect($product['brand'])->toBe('Acme');
    expect($product['google_product_category'])->toBe('Apparel > Shoes');
});

test('flag-ON syncCatalog chunks at TikTok 500-products limit', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);

    $callCount = 0;
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/product/upload/' => function ($request) use (&$callCount) {
            $callCount++;
            $skus = array_map(fn ($p) => $p['sku_id'], (array) $request->data()['products']);
            return Http::response(['code' => 0, 'data' => ['success_list' => $skus, 'failed_list' => []]], 200);
        },
    ]);

    // 1300 products = 3 chunks (500 + 500 + 300).
    $products = [];
    for ($i = 0; $i < 1300; $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',
        ];
    }

    $tt = new TikTokAdPlatform();
    $result = $tt->syncCatalog('tok', '987654321', $products, 'idem_big');
    expect($callCount)->toBe(3);
    expect($result->syncedCount)->toBe(1300);
    expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
});

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

test('flag-ON syncCatalog returns STATUS_PARTIAL with per-product errors tagged by sku_id', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/product/upload/' => Http::response([
            'code' => 0,
            'data' => [
                'success_list' => ['sku-A', 'sku-C'],
                'failed_list' => [
                    ['sku_id' => 'sku-B', 'reason' => 'invalid image_link: 404 not found'],
                ],
            ],
        ], 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' => 'https://x/b', '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'],
    ];

    $tt = new TikTokAdPlatform();
    $result = $tt->syncCatalog('tok', '987654321', $products, 'idem');
    expect($result->status)->toBe(CatalogSyncResult::STATUS_PARTIAL);
    expect($result->syncedCount)->toBe(2);
    expect($result->errorCount)->toBe(1);
    expect($result->errors[0])->toBe(['external_id' => 'sku-B', 'error' => 'invalid image_link: 404 not found']);
});

// ---------------------------------------------------------------------------
// 9. Legacy response shape
// ---------------------------------------------------------------------------

test('flag-ON syncCatalog handles legacy success_skus / failed_skus response shape', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/product/upload/' => Http::response([
            'code' => 0,
            'data' => [
                'success_skus' => ['sku-1'],
                'failed_skus' => [['sku_id' => 'sku-2', 'reason' => 'price_invalid']],
            ],
        ], 200),
    ]);

    $products = [
        ['external_id' => 'sku-1', 'title' => '1', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/1.jpg', 'product_url' => 'https://x/1', 'availability' => 'in_stock'],
        ['external_id' => 'sku-2', 'title' => '2', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/2.jpg', 'product_url' => 'https://x/2', 'availability' => 'in_stock'],
    ];
    $tt = new TikTokAdPlatform();
    $result = $tt->syncCatalog('tok', '987654321', $products, 'idem');
    expect($result->status)->toBe(CatalogSyncResult::STATUS_PARTIAL);
    expect($result->syncedCount)->toBe(1);
    expect($result->errorCount)->toBe(1);
    expect($result->errors[0]['external_id'])->toBe('sku-2');
});

// ---------------------------------------------------------------------------
// 10. No-list fallback
// ---------------------------------------------------------------------------

test('flag-ON syncCatalog without success/failed lists assumes chunk succeeded', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/product/upload/' => Http::response([
            'code' => 0,
            'data' => [],
        ], 200),
    ]);

    $products = [
        ['external_id' => 'sku-1', 'title' => '1', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/1.jpg', 'product_url' => 'https://x/1', 'availability' => 'in_stock'],
        ['external_id' => 'sku-2', 'title' => '2', 'price' => '5', 'currency' => 'USD', 'image_url' => 'https://c/2.jpg', 'product_url' => 'https://x/2', 'availability' => 'in_stock'],
    ];
    $tt = new TikTokAdPlatform();
    $result = $tt->syncCatalog('tok', '987654321', $products, 'idem');
    expect($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS);
    expect($result->syncedCount)->toBe(2);
});

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

test('flag-ON code 40100 maps to TokenExpiredException', function () {
    config(['ads.hardening.real_tiktok_catalog_enabled' => true]);
    Http::fake([
        'business-api.tiktok.com/open_api/v1.3/catalog/create/' => Http::response([
            'code' => 40100, 'message' => 'access_token expired', 'data' => null,
        ], 200),
    ]);

    $tt = new TikTokAdPlatform();
    expect(fn () => $tt->createCatalog('expired', '1234567890', 'cat'))
        ->toThrow(TokenExpiredException::class);
});

// ---------------------------------------------------------------------------
// 12. Independent flag matrix
// ---------------------------------------------------------------------------

test('real_tiktok_enabled alone does NOT enable real catalog calls', function () {
    config([
        'ads.hardening.real_tiktok_enabled' => true,
        'ads.hardening.real_tiktok_catalog_enabled' => false,
    ]);
    Http::fake();

    $tt = new TikTokAdPlatform();
    $id = $tt->createCatalog('tok', '1234567890', 'cat');
    expect($id)->toBeString()->and($id)->not->toBeEmpty();
    Http::assertNothingSent();
});