File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR12_2TikTokRealImplementationTest.php
<?php
/**
* Ads Phase R12.2 — TikTokAdPlatform real TikTok Business API v1.3 test suite.
*
* Uses Http::fake() to stub business-api.tiktok.com responses. No live API.
*
* Verifies:
* 1. Feature flag `ads.hardening.real_tiktok_enabled` defaults OFF
* 2. Flag OFF → every R1 method delegates to MockAdPlatform (mock_camp_ /
* mock_aud_ shape, no HTTP request made)
* 3. Flag ON → each of 10 methods hits the correct endpoint with expected
* payload shape and maps responses to the right Result VO
* 4. Error code taxonomy: code=40100/40101 → TokenExpired; "revoked"-keyword
* or 40103 → TokenRevoked; 40016/40914 → RateLimit + retry-after; 40004
* / "policy" → PolicyViolation; 40001-40003 / 400 → Validation; 40005 /
* 404 / "not found" → ResourceNotFound; 5xx / 50000+ → NetworkException;
* unknown shape → UnexpectedResponse
* 5. deleteAudience: code=40005 / "not found" → STATUS_DELETED (GDPR idempotent)
* 6. createCatalog / syncCatalog throw LogicException with R15 hint when
* flag ON (Catalog Manager is a separate API surface)
* 7. Identifier discipline: TikTok IDs are bare numeric strings (not resource
* paths like Google), normalizeAdvertiserId strips advertiser_ prefix
*/
use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\PolicyViolationException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\ResourceNotFoundException;
use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\Exceptions\TokenRevokedException;
use App\Services\Ads\Exceptions\UnexpectedResponseException;
use App\Services\Ads\Exceptions\ValidationException;
use App\Services\Ads\Results\AudienceSyncResult;
use App\Services\Ads\Results\LeadFetchResult;
use App\Services\Ads\Results\MetricsResult;
use App\Services\Ads\Results\PublishResult;
use App\Services\Ads\TikTokAdPlatform;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
uses(Tests\TestCase::class);
beforeEach(function () {
config(['ads.hardening.real_tiktok_enabled' => true]);
config(['ads.platforms.tiktok.default_advertiser_id' => '1234567890123456']);
Http::preventStrayRequests();
});
function tiktokDto(string $name = 'TikTok R12.2 Campaign'): PublishCampaignDto
{
return new PublishCampaignDto(
campaignId: 'c-' . uniqid(),
name: $name,
objective: 'traffic',
dailyBudget: 25.00,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: 'BID_TYPE_NO_BID',
scheduleStart: null,
scheduleEnd: null,
);
}
function tiktokOk(array $data = [])
{
return Http::response([
'code' => 0,
'message' => 'OK',
'data' => $data,
'request_id' => 'rq-' . uniqid(),
], 200);
}
function tiktokErr(int $code, string $message, int $status = 200, array $extraHeaders = [])
{
return Http::response([
'code' => $code,
'message' => $message,
'data' => [],
'request_id' => 'rq-err-' . uniqid(),
], $status, $extraHeaders);
}
// ============================================================
// 1. Flag default
// ============================================================
test('ads.hardening.real_tiktok_enabled defaults to false', function () {
$defaults = require base_path('config/ads.php');
expect($defaults['hardening']['real_tiktok_enabled'] ?? true)->toBeFalse();
});
// ============================================================
// 2. Flag OFF → mock delegation (no HTTP fired)
// ============================================================
test('flag OFF — publishCampaign delegates to MockAdPlatform (no HTTP)', function () {
config(['ads.hardening.real_tiktok_enabled' => false]);
Http::fake();
$result = app(TikTokAdPlatform::class)->publishCampaign('tok', '1234567890123456', tiktokDto(), 'idem-1');
expect($result->remoteCampaignId)->toStartWith('mock_camp_');
Http::assertNothingSent();
});
test('flag OFF — pullMetrics delegates to Mock', function () {
config(['ads.hardening.real_tiktok_enabled' => false]);
Http::fake();
$result = app(TikTokAdPlatform::class)->pullMetrics(
'tok',
'1234567890123456',
Carbon::parse('2026-04-01'),
Carbon::parse('2026-04-02'),
['camp_a'],
'UTC',
);
expect($result)->toBeInstanceOf(MetricsResult::class);
Http::assertNothingSent();
});
test('flag OFF — syncAudience delegates to Mock', function () {
config(['ads.hardening.real_tiktok_enabled' => false]);
Http::fake();
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List',
description: null,
hashedEmails: ['hash1', 'hash2'],
);
$result = app(TikTokAdPlatform::class)->syncAudience('tok', '1234567890123456', $dto, 'idem');
expect($result->platformAudienceId)->toStartWith('mock_aud_');
Http::assertNothingSent();
});
// ============================================================
// 3. Real API — happy paths (one per method)
// ============================================================
test('publishCampaign — POST /campaign/create/ with objective + budget_mode', function () {
Http::fake([
'business-api.tiktok.com/open_api/v1.3/campaign/create/' => tiktokOk(['campaign_id' => '7777'])
]);
$result = app(TikTokAdPlatform::class)->publishCampaign('tok', '1234567890123456', tiktokDto(), 'idem-pub');
expect($result)->toBeInstanceOf(PublishResult::class)
->and($result->status)->toBe(PublishResult::STATUS_PENDING)
->and($result->remoteCampaignId)->toBe('7777')
->and($result->metadata['real'])->toBeTrue()
->and($result->metadata['advertiser_id'])->toBe('1234567890123456');
Http::assertSent(function ($request) {
$payload = $request->data();
return str_contains($request->url(), '/campaign/create/')
&& ($payload['advertiser_id'] ?? null) === '1234567890123456'
&& ($payload['objective_type'] ?? null) === 'TRAFFIC'
&& ($payload['budget_mode'] ?? null) === 'BUDGET_MODE_DAY'
&& ($payload['budget'] ?? null) === 25.0
&& ($payload['operation_status'] ?? null) === 'DISABLE';
});
});
test('publishCampaign — sends Access-Token header (not Authorization Bearer)', function () {
Http::fake(['*' => tiktokOk(['campaign_id' => '1'])]);
app(TikTokAdPlatform::class)->publishCampaign('test_token_xyz', '1234567890123456', tiktokDto(), 'idem');
Http::assertSent(function ($request) {
return $request->hasHeader('Access-Token', 'test_token_xyz')
&& !$request->hasHeader('Authorization');
});
});
test('publishCampaign — strips advertiser_ prefix from id', function () {
Http::fake(['*' => tiktokOk(['campaign_id' => '1'])]);
app(TikTokAdPlatform::class)->publishCampaign('tok', 'advertiser_1234567890123456', tiktokDto(), 'idem');
Http::assertSent(function ($request) {
return ($request->data()['advertiser_id'] ?? null) === '1234567890123456';
});
});
test('publishCampaign — null daily budget → BUDGET_MODE_INFINITE without budget field', function () {
Http::fake(['*' => tiktokOk(['campaign_id' => '1'])]);
$dto = new PublishCampaignDto(
campaignId: 'c1',
name: 'No-budget',
objective: 'awareness',
dailyBudget: null,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: null,
scheduleStart: null,
scheduleEnd: null,
);
app(TikTokAdPlatform::class)->publishCampaign('tok', '1234567890123456', $dto, 'idem');
Http::assertSent(function ($request) {
$data = $request->data();
return ($data['budget_mode'] ?? null) === 'BUDGET_MODE_INFINITE'
&& !array_key_exists('budget', $data)
&& ($data['objective_type'] ?? null) === 'REACH';
});
});
test('pauseCampaign — POST /campaign/status/update/ with operation_status=DISABLE', function () {
Http::fake(['business-api.tiktok.com/open_api/v1.3/campaign/status/update/' => tiktokOk()]);
$result = app(TikTokAdPlatform::class)->pauseCampaign('tok', '7777', 'idem-p');
expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
->and($result->remoteCampaignId)->toBe('7777');
Http::assertSent(function ($r) {
$data = $r->data();
return ($data['advertiser_id'] ?? null) === '1234567890123456'
&& ($data['campaign_ids'] ?? null) === ['7777']
&& ($data['operation_status'] ?? null) === 'DISABLE';
});
});
test('resumeCampaign — operation_status=ENABLE → STATUS_ACTIVE', function () {
Http::fake(['*' => tiktokOk()]);
$result = app(TikTokAdPlatform::class)->resumeCampaign('tok', '7777', 'idem-r');
expect($result->status)->toBe(PublishResult::STATUS_ACTIVE);
Http::assertSent(fn ($r) => ($r->data()['operation_status'] ?? null) === 'ENABLE');
});
test('pullMetrics — GET /report/integrated/get/ with BASIC report + AUCTION_CAMPAIGN data level', function () {
Http::fake([
'business-api.tiktok.com/open_api/v1.3/report/integrated/get/*' => tiktokOk([
'list' => [
[
'dimensions' => ['campaign_id' => '7777', 'stat_time_day' => '2026-04-01'],
'metrics' => [
'spend' => '12.50',
'impressions' => '1000',
'clicks' => '50',
'conversions' => '3',
'ctr' => '5.0',
'cpc' => '0.25',
'cpm' => '12.50',
'reach' => '900',
],
],
[
'dimensions' => ['campaign_id' => '7777', 'stat_time_day' => '2026-04-02'],
'metrics' => [
'spend' => '14.0',
'impressions' => '1200',
'clicks' => '60',
'conversions' => '4',
],
],
],
]),
]);
$result = app(TikTokAdPlatform::class)->pullMetrics(
'tok',
'1234567890123456',
Carbon::parse('2026-04-01'),
Carbon::parse('2026-04-02'),
['7777'],
'UTC',
);
expect($result->count())->toBe(2);
$first = $result->rows[0];
expect($first->remoteCampaignId)->toBe('7777')
->and($first->impressions)->toBe(1000)
->and($first->clicks)->toBe(50)
->and($first->spend)->toBe(12.5)
->and($first->cpc)->toBe(0.25)
->and($first->reach)->toBe(900);
Http::assertSent(function ($r) {
$url = $r->url();
return str_contains($url, 'report_type=BASIC')
&& str_contains($url, 'data_level=AUCTION_CAMPAIGN')
&& str_contains($url, 'advertiser_id=1234567890123456');
});
});
test('pullMetrics — empty remoteCampaignIds short-circuits without HTTP', function () {
Http::fake();
$result = app(TikTokAdPlatform::class)->pullMetrics('tok', '1', Carbon::now(), Carbon::now(), [], 'UTC');
expect($result->count())->toBe(0);
Http::assertNothingSent();
});
test('syncAudience — creates custom audience when no platformAudienceId', function () {
Http::fake([
'business-api.tiktok.com/open_api/v1.3/dmp/custom_audience/create/' => tiktokOk(['custom_audience_id' => 'aud_555']),
]);
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List A',
description: 'Test',
hashedEmails: [],
);
$result = app(TikTokAdPlatform::class)->syncAudience('tok', '1234567890123456', $dto, 'idem-a');
expect($result)->toBeInstanceOf(AudienceSyncResult::class)
->and($result->platformAudienceId)->toBe('aud_555')
->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS);
Http::assertSent(function ($r) {
$data = $r->data();
return ($data['custom_audience_name'] ?? null) === 'List A'
&& ($data['calculate_type'] ?? null) === 'EMAIL_SHA256'
&& ($data['advertiser_id'] ?? null) === '1234567890123456';
});
});
test('syncAudience — STATUS_PARTIAL when hashed emails present (upload deferred)', function () {
Http::fake(['*' => tiktokOk(['custom_audience_id' => 'aud_777'])]);
$dto = new SyncAudienceDto(
platformAudienceId: null,
name: 'List',
description: null,
hashedEmails: ['h1', 'h2', 'h3'],
);
$result = app(TikTokAdPlatform::class)->syncAudience('tok', '1234567890123456', $dto, 'idem');
expect($result->status)->toBe(AudienceSyncResult::STATUS_PARTIAL)
->and($result->uploadedCount)->toBe(3);
});
test('syncAudience — reuses provided platformAudienceId, skips create', function () {
Http::fake();
$dto = new SyncAudienceDto(
platformAudienceId: 'existing_aud_999',
name: 'List',
description: null,
hashedEmails: [],
);
$result = app(TikTokAdPlatform::class)->syncAudience('tok', '1234567890123456', $dto, 'idem');
expect($result->platformAudienceId)->toBe('existing_aud_999');
Http::assertNothingSent();
});
test('deleteAudience — POST /dmp/custom_audience/delete/ with bare id', function () {
Http::fake(['business-api.tiktok.com/open_api/v1.3/dmp/custom_audience/delete/' => tiktokOk()]);
$result = app(TikTokAdPlatform::class)->deleteAudience('tok', 'aud_555');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
Http::assertSent(function ($r) {
$data = $r->data();
return ($data['custom_audience_ids'] ?? null) === ['aud_555']
&& ($data['advertiser_id'] ?? null) === '1234567890123456';
});
});
test('deleteAudience — code=40005 not-found treated as STATUS_DELETED (GDPR idempotent)', function () {
Http::fake(['*' => tiktokErr(40005, 'audience not found')]);
$result = app(TikTokAdPlatform::class)->deleteAudience('tok', 'aud_missing');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
});
test('deleteAudience — HTTP 404 treated as STATUS_DELETED', function () {
Http::fake(['*' => Http::response(['code' => 0, 'message' => 'gone', 'data' => []], 404)]);
$result = app(TikTokAdPlatform::class)->deleteAudience('tok', 'aud_404');
expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED);
});
test('getLeadForms — GET /pages/leads_form/list/ returns normalized form shape', function () {
Http::fake([
'business-api.tiktok.com/open_api/v1.3/pages/leads_form/list/*' => tiktokOk([
'list' => [
[
'form_id' => 'f1',
'form_name' => 'Contact Us',
'status' => 'ACTIVE',
'questions' => [['name' => 'email']],
],
[
'form_id' => 'f2',
'form_name' => 'Newsletter',
'status' => 'PAUSED',
'questions' => [],
],
],
]),
]);
$forms = app(TikTokAdPlatform::class)->getLeadForms('tok', '1234567890123456');
expect($forms)->toBeArray()->toHaveCount(2)
->and($forms[0]['id'])->toBe('f1')
->and($forms[0]['name'])->toBe('Contact Us')
->and($forms[1]['status'])->toBe('PAUSED');
});
test('fetchLeads — GET /pages/leads/get/ extracts platform_lead_id + fields', function () {
Http::fake([
'business-api.tiktok.com/open_api/v1.3/pages/leads/get/*' => tiktokOk([
'list' => [
[
'lead_id' => 'lead_1',
'create_time' => '2026-04-25 10:00:00',
'fields' => [
['name' => 'email', 'value' => '[email protected]'],
['name' => 'full_name', 'value' => 'Alice'],
],
],
],
'page_info' => ['page' => 1, 'total_page' => 1],
]),
]);
$result = app(TikTokAdPlatform::class)->fetchLeads('tok', 'f1', null);
expect($result)->toBeInstanceOf(LeadFetchResult::class);
expect($result->leads)->toHaveCount(1);
expect($result->leads[0]['platform_lead_id'])->toBe('lead_1');
expect($result->leads[0]['fields']['email'])->toBe('[email protected]');
expect($result->leads[0]['fields']['full_name'])->toBe('Alice');
expect($result->nextCursor)->toBeNull();
});
test('fetchLeads — multi-page response sets nextCursor to next page number', function () {
Http::fake([
'*' => tiktokOk([
'list' => [['lead_id' => 'lead_1', 'fields' => [], 'create_time' => '2026-04-25 00:00:00']],
'page_info' => ['page' => 1, 'total_page' => 3],
]),
]);
$result = app(TikTokAdPlatform::class)->fetchLeads('tok', 'f1', null);
expect($result->nextCursor)->toBe('2');
});
// R15.3 retargeted these two tests: TikTok catalog methods used to throw
// `LogicException` pointing at R15; that placeholder is now filled. With
// the catalog flag OFF (default for this test file) the methods delegate
// to MockAdPlatform; flag-ON behavior + error mapping is covered by
// AdsR15_3TikTokCatalogManagerTest. Keep two assertions here that the
// independent flag matrix R15.3 introduced holds: real_tiktok_enabled=true
// alone does NOT enable real catalog calls.
test('catalog flag OFF — createCatalog delegates to mock even when real_tiktok_enabled is ON', function () {
config([
'ads.hardening.real_tiktok_enabled' => true,
'ads.hardening.real_tiktok_catalog_enabled' => false,
]);
Http::fake();
$id = app(TikTokAdPlatform::class)->createCatalog('tok', '1234567890123456', 'cat');
expect($id)->toBeString()->and($id)->not->toBeEmpty();
Http::assertNothingSent();
});
test('catalog flag OFF — syncCatalog delegates to mock even when real_tiktok_enabled is ON', function () {
config([
'ads.hardening.real_tiktok_enabled' => true,
'ads.hardening.real_tiktok_catalog_enabled' => false,
]);
Http::fake();
$result = app(TikTokAdPlatform::class)->syncCatalog('tok', 'cat_1', [], 'idem');
expect($result)->toBeInstanceOf(\App\Services\Ads\Results\CatalogSyncResult::class);
Http::assertNothingSent();
});
test('flag OFF — createCatalog falls through to mock without throwing', function () {
config(['ads.hardening.real_tiktok_enabled' => false]);
Http::fake();
$catId = app(TikTokAdPlatform::class)->createCatalog('tok', '1234567890123456', 'cat');
expect($catId)->toBeString();
expect(strlen($catId))->toBeGreaterThan(0);
Http::assertNothingSent();
});
// ============================================================
// 4. Error mapping taxonomy
// ============================================================
test('error: code=40100 token expired → TokenExpiredException', function () {
Http::fake(['*' => tiktokErr(40100, 'access token expired')]);
expect(fn () => app(TikTokAdPlatform::class)->pullMetrics('tok', '1234567890123456', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(TokenExpiredException::class);
});
test('error: code=40103 + revoke keyword → TokenRevokedException', function () {
Http::fake(['*' => tiktokErr(40103, 'token revoked by user')]);
expect(fn () => app(TikTokAdPlatform::class)->pullMetrics('tok', '1234567890123456', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(TokenRevokedException::class);
});
test('error: code=40016 (QPS) → RateLimitException with retry-after', function () {
Http::fake(['*' => tiktokErr(40016, 'rate limit exceeded', 200, ['Retry-After' => '60'])]);
$caught = null;
try {
app(TikTokAdPlatform::class)->pullMetrics('tok', '1234567890123456', Carbon::now(), Carbon::now(), ['x'], 'UTC');
} catch (RateLimitException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(RateLimitException::class);
expect($caught?->getRetryAfter())->toBe(60);
});
test('error: code=40004 + policy keyword → PolicyViolationException', function () {
Http::fake(['*' => tiktokErr(40004, 'creative policy violation: prohibited content')]);
expect(fn () => app(TikTokAdPlatform::class)->publishCampaign('tok', '1234567890123456', tiktokDto(), 'idem'))
->toThrow(PolicyViolationException::class);
});
test('error: code=40001 invalid app → ValidationException', function () {
Http::fake(['*' => tiktokErr(40001, 'invalid request body')]);
expect(fn () => app(TikTokAdPlatform::class)->publishCampaign('tok', '1234567890123456', tiktokDto(), 'idem'))
->toThrow(ValidationException::class);
});
test('error: code=40005 with not-found message via getLeadForms → ResourceNotFoundException', function () {
Http::fake(['*' => tiktokErr(40005, 'advertiser not found')]);
expect(fn () => app(TikTokAdPlatform::class)->getLeadForms('tok', '1234567890123456'))
->toThrow(ResourceNotFoundException::class);
});
test('error: HTTP 503 → NetworkException', function () {
Http::fake(['*' => Http::response(['code' => 50001, 'message' => 'server down', 'data' => []], 503)]);
expect(fn () => app(TikTokAdPlatform::class)->pullMetrics('tok', '1234567890123456', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(NetworkException::class);
});
test('error: unknown code 99999 with HTTP 200 → UnexpectedResponseException', function () {
Http::fake(['*' => tiktokErr(99999, 'mystery error code')]);
expect(fn () => app(TikTokAdPlatform::class)->pullMetrics('tok', '1234567890123456', Carbon::now(), Carbon::now(), ['x'], 'UTC'))
->toThrow(UnexpectedResponseException::class);
});
// ============================================================
// 5. Method-override coverage (no R1 stub leakage)
// ============================================================
test('every R1-extended method is overridden on TikTokAdPlatform (no trait fallthrough at runtime)', function () {
$reflection = new ReflectionClass(TikTokAdPlatform::class);
$methods = [
'publishCampaign', 'pauseCampaign', 'resumeCampaign', 'pullMetrics',
'syncAudience', 'deleteAudience', 'getLeadForms', 'fetchLeads',
'createCatalog', 'syncCatalog',
];
foreach ($methods as $method) {
$declared = $reflection->getMethod($method)->getDeclaringClass()->getName();
expect($declared)->toBe(TikTokAdPlatform::class, "method {$method} not overridden");
}
});