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