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