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/AdsR2MockPlatformTest.php
<?php

/**
 * Ads Phase R2 — MockAdPlatform implementation test suite.
 *
 * Verifies:
 *   1. All 10 R1-stubbed methods now return typed Result VOs (no more LogicException)
 *   2. Name-based error simulation throws correct exception subclasses
 *      (MOCK_REJECT, MOCK_PENDING, MOCK_RATE_LIMIT, MOCK_AUTH_EXPIRED)
 *   3. pullMetrics is deterministic — same input → same output across calls
 *   4. pullMetrics row cardinality = campaigns × days
 *   5. syncAudience 85% match rate + platformAudienceId reuse behavior
 *   6. fetchLeads pagination — first page has nextCursor, follow-up cursor empties
 *   7. createCatalog / syncCatalog — IDs + synced counts
 *   8. N-th-call config-driven transient error simulation (R2.2)
 */

use App\Dto\Ads\PublishCampaignDto;
use App\Dto\Ads\SyncAudienceDto;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\TokenExpiredException;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\Results\AudienceSyncResult;
use App\Services\Ads\Results\CatalogSyncResult;
use App\Services\Ads\Results\LeadFetchResult;
use App\Services\Ads\Results\MetricsResult;
use App\Services\Ads\Results\PublishResult;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;

uses(Tests\TestCase::class);

beforeEach(function () {
    Cache::flush();
    config(['ads.mock.error_simulation' => null]);
});

function mockPublishDto(string $name = 'Normal Campaign'): PublishCampaignDto
{
    return new PublishCampaignDto(
        campaignId: 'c-' . uniqid(),
        name: $name,
        objective: 'CONVERSIONS',
        dailyBudget: 100.0,
        lifetimeBudget: null,
        budgetCurrency: 'USD',
        bidStrategy: 'LOWEST_COST',
        scheduleStart: Carbon::parse('2026-05-01'),
        scheduleEnd: null,
        targeting: ['geo' => ['countries' => ['US']]],
    );
}

// ============================================================
// Publishing
// ============================================================

test('publishCampaign — happy path returns ACTIVE with mock IDs', function () {
    $mock = new MockAdPlatform();
    $result = $mock->publishCampaign('tok', 'act_1', mockPublishDto(), 'idem-1');

    expect($result)->toBeInstanceOf(PublishResult::class)
        ->and($result->status)->toBe(PublishResult::STATUS_ACTIVE)
        ->and($result->isSuccess())->toBeTrue()
        ->and($result->remoteCampaignId)->toStartWith('mock_camp_')
        ->and($result->remoteAdsetId)->toStartWith('mock_adset_')
        ->and($result->remoteAdId)->toStartWith('mock_ad_')
        ->and($result->metadata['idempotency_key'])->toBe('idem-1');
});

test('publishCampaign — name contains MOCK_REJECT → STATUS_REJECTED with reason', function () {
    $mock = new MockAdPlatform();
    $result = $mock->publishCampaign('tok', 'act_1', mockPublishDto('Summer MOCK_REJECT Sale'), 'idem-x');

    expect($result->status)->toBe(PublishResult::STATUS_REJECTED)
        ->and($result->isRejected())->toBeTrue()
        ->and($result->rejectionReason)->toContain('MOCK_REJECT')
        ->and($result->remoteCampaignId)->toBeNull();
});

test('publishCampaign — name contains MOCK_PENDING → STATUS_PENDING with ID', function () {
    $mock = new MockAdPlatform();
    $result = $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_PENDING Review'), 'idem-p');

    expect($result->status)->toBe(PublishResult::STATUS_PENDING)
        ->and($result->isSuccess())->toBeTrue()
        ->and($result->remoteCampaignId)->toStartWith('mock_camp_');
});

test('publishCampaign — MOCK_RATE_LIMIT throws RateLimitException with retry-after', function () {
    $mock = new MockAdPlatform();

    try {
        $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_RATE_LIMIT hit'), 'idem-r');
        expect(false)->toBeTrue('expected RateLimitException');
    } catch (RateLimitException $e) {
        expect($e->getRetryAfter())->toBe(60)
            ->and($e->isRetryable())->toBeTrue()
            ->and($e->getPlatformCode())->toBe('MOCK_RATE_LIMIT');
    }
});

test('publishCampaign — MOCK_AUTH_EXPIRED throws TokenExpiredException', function () {
    $mock = new MockAdPlatform();

    expect(fn () => $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_AUTH_EXPIRED'), 'idem-a'))
        ->toThrow(TokenExpiredException::class);
});

test('publishCampaign — MOCK_NETWORK_ERROR throws NetworkException', function () {
    $mock = new MockAdPlatform();

    expect(fn () => $mock->publishCampaign('tok', 'act_1', mockPublishDto('MOCK_NETWORK_ERROR'), 'idem-n'))
        ->toThrow(NetworkException::class);
});

test('pauseCampaign — returns STATUS_PAUSED with same remoteCampaignId', function () {
    $mock = new MockAdPlatform();
    $result = $mock->pauseCampaign('tok', 'camp_123', 'idem-p');

    expect($result->status)->toBe(PublishResult::STATUS_PAUSED)
        ->and($result->remoteCampaignId)->toBe('camp_123')
        ->and($result->metadata['idempotency_key'])->toBe('idem-p');
});

test('resumeCampaign — returns STATUS_ACTIVE with same remoteCampaignId', function () {
    $mock = new MockAdPlatform();
    $result = $mock->resumeCampaign('tok', 'camp_456', 'idem-r');

    expect($result->status)->toBe(PublishResult::STATUS_ACTIVE)
        ->and($result->remoteCampaignId)->toBe('camp_456');
});

// ============================================================
// Metrics — determinism is the critical property
// ============================================================

test('pullMetrics — returns MetricsResult with rows[]', function () {
    $mock = new MockAdPlatform();
    $result = $mock->pullMetrics(
        'tok',
        'act_1',
        Carbon::parse('2026-04-01'),
        Carbon::parse('2026-04-03'),
        ['camp_a', 'camp_b'],
        'UTC',
    );

    expect($result)->toBeInstanceOf(MetricsResult::class)
        ->and($result->status)->toBe(MetricsResult::STATUS_SUCCESS)
        ->and($result->count())->toBe(6); // 2 campaigns × 3 days
});

test('pullMetrics — deterministic: same input produces identical rows', function () {
    $mock = new MockAdPlatform();
    $args = ['tok', 'act_1', Carbon::parse('2026-04-01'), Carbon::parse('2026-04-05'), ['stable_camp'], 'UTC'];

    $r1 = $mock->pullMetrics(...$args);
    $r2 = $mock->pullMetrics(...$args);

    expect($r1->count())->toBe($r2->count());
    foreach ($r1->rows as $i => $row1) {
        $row2 = $r2->rows[$i];
        expect($row1->remoteCampaignId)->toBe($row2->remoteCampaignId)
            ->and($row1->date)->toBe($row2->date)
            ->and($row1->impressions)->toBe($row2->impressions)
            ->and($row1->clicks)->toBe($row2->clicks)
            ->and($row1->spend)->toBe($row2->spend)
            ->and($row1->conversions)->toBe($row2->conversions);
    }
});

test('pullMetrics — empty campaigns list → zero rows, still STATUS_SUCCESS', function () {
    $mock = new MockAdPlatform();
    $result = $mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), [], 'UTC');

    expect($result->status)->toBe(MetricsResult::STATUS_SUCCESS)
        ->and($result->count())->toBe(0);
});

test('pullMetrics — row values within realistic ranges', function () {
    $mock = new MockAdPlatform();
    $result = $mock->pullMetrics(
        'tok',
        'act_1',
        Carbon::parse('2026-04-01'),
        Carbon::parse('2026-04-01'),
        ['camp_x'],
        'UTC',
    );

    $row = $result->rows[0];
    expect($row->impressions)->toBeGreaterThanOrEqual(500)->toBeLessThan(10000)
        ->and($row->clicks)->toBeLessThanOrEqual($row->impressions)
        ->and($row->conversions)->toBeLessThanOrEqual($row->clicks)
        ->and($row->spend)->toBeGreaterThanOrEqual(0.0)
        ->and($row->ctr)->toBeLessThan(5.0); // realistic CTR ceiling
});

// ============================================================
// Audiences
// ============================================================

test('syncAudience — generates new platformAudienceId when none provided', function () {
    $mock = new MockAdPlatform();
    $dto = new SyncAudienceDto(
        platformAudienceId: null,
        name: 'Test List',
        description: null,
        hashedEmails: array_fill(0, 1000, str_repeat('a', 64)),
    );
    $result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-a');

    expect($result)->toBeInstanceOf(AudienceSyncResult::class)
        ->and($result->status)->toBe(AudienceSyncResult::STATUS_SUCCESS)
        ->and($result->platformAudienceId)->toStartWith('mock_aud_')
        ->and($result->uploadedCount)->toBe(1000)
        ->and($result->matchedCount)->toBe(850) // 85% match rate
        ->and($result->invalidCount)->toBe(150);
});

test('syncAudience — reuses platformAudienceId when provided', function () {
    $mock = new MockAdPlatform();
    $dto = new SyncAudienceDto(
        platformAudienceId: 'aud_existing_xyz',
        name: 'Test List',
        description: null,
        hashedEmails: [],
        operation: SyncAudienceDto::OPERATION_REMOVE,
    );
    $result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-b');

    expect($result->platformAudienceId)->toBe('aud_existing_xyz')
        ->and($result->uploadedCount)->toBe(0)
        ->and($result->matchedCount)->toBe(0);
});

test('syncAudience — MOCK_REJECT in name is NOT a rejection (audience has no rejection semantics)', function () {
    // syncAudience doesn't have a "rejected" state in AudienceSyncResult, so
    // name-triggered RateLimit still fires but plain MOCK_REJECT passes through.
    $mock = new MockAdPlatform();
    $dto = new SyncAudienceDto(
        platformAudienceId: null, name: 'MOCK_REJECT Audience',
        description: null, hashedEmails: ['h1', 'h2'],
    );
    $result = $mock->syncAudience('tok', 'act_1', $dto, 'idem-c');

    expect($result->isSuccess())->toBeTrue();
});

test('syncAudience — MOCK_RATE_LIMIT in name throws', function () {
    $mock = new MockAdPlatform();
    $dto = new SyncAudienceDto(
        platformAudienceId: null, name: 'MOCK_RATE_LIMIT Audience',
        description: null, hashedEmails: [],
    );
    expect(fn () => $mock->syncAudience('tok', 'act_1', $dto, 'idem-x'))
        ->toThrow(RateLimitException::class);
});

test('deleteAudience — returns STATUS_DELETED with same ID', function () {
    $mock = new MockAdPlatform();
    $result = $mock->deleteAudience('tok', 'aud_to_delete_123');

    expect($result->status)->toBe(AudienceSyncResult::STATUS_DELETED)
        ->and($result->platformAudienceId)->toBe('aud_to_delete_123')
        ->and($result->isSuccess())->toBeTrue();
});

// ============================================================
// Leads
// ============================================================

test('getLeadForms — returns 3 realistic form schemas', function () {
    $mock = new MockAdPlatform();
    $forms = $mock->getLeadForms('tok', 'act_1');

    expect($forms)->toHaveCount(3);
    foreach ($forms as $form) {
        expect($form)->toHaveKeys(['id', 'name', 'status', 'questions', 'created_time'])
            ->and($form['questions'])->not->toBeEmpty();
        foreach ($form['questions'] as $q) {
            expect($q)->toHaveKeys(['key', 'type', 'label']);
        }
    }
    // At least one form MUST have an email field — that's the minimum useful schema
    $emailForms = array_filter($forms, fn ($f) => in_array('email', array_column($f['questions'], 'key'), true));
    expect($emailForms)->not->toBeEmpty();
});

test('fetchLeads — first page returns 5 leads and a next cursor', function () {
    $mock = new MockAdPlatform();
    $page1 = $mock->fetchLeads('tok', 'form_123', null);

    expect($page1)->toBeInstanceOf(LeadFetchResult::class)
        ->and($page1->status)->toBe(LeadFetchResult::STATUS_SUCCESS)
        ->and($page1->count())->toBe(5)
        ->and($page1->hasMore())->toBeTrue()
        ->and($page1->nextCursor)->toBe('mock_cursor_end');

    foreach ($page1->leads as $lead) {
        expect($lead)->toHaveKeys(['platform_lead_id', 'fields', 'created_at'])
            ->and($lead['fields'])->toHaveKey('email')
            ->and($lead['created_at'])->toBeInt();
    }
});

test('fetchLeads — following nextCursor returns empty last page', function () {
    $mock = new MockAdPlatform();
    $page2 = $mock->fetchLeads('tok', 'form_123', null, 100, 'mock_cursor_end');

    expect($page2->count())->toBe(0)
        ->and($page2->hasMore())->toBeFalse()
        ->and($page2->nextCursor)->toBeNull();
});

test('fetchLeads — respects since filter (excludes older leads)', function () {
    $mock = new MockAdPlatform();
    // Since = 1 hour in the future → no fake lead should pass
    $page = $mock->fetchLeads('tok', 'form_1', Carbon::now()->addHour());
    expect($page->count())->toBe(0);
});

// ============================================================
// Catalog
// ============================================================

test('createCatalog — returns mock_cat_* ID', function () {
    $mock = new MockAdPlatform();
    $id = $mock->createCatalog('tok', 'act_1', 'My Product Catalog');

    expect($id)->toBeString()->toStartWith('mock_cat_');
});

test('syncCatalog — syncedCount matches product count, 0 errors', function () {
    $mock = new MockAdPlatform();
    $products = [
        ['external_id' => 'sku-001', 'name' => 'P1'],
        ['external_id' => 'sku-002', 'name' => 'P2'],
        ['external_id' => 'sku-003', 'name' => 'P3'],
    ];
    $result = $mock->syncCatalog('tok', 'cat_xyz', $products, 'idem-cat');

    expect($result)->toBeInstanceOf(CatalogSyncResult::class)
        ->and($result->status)->toBe(CatalogSyncResult::STATUS_SUCCESS)
        ->and($result->syncedCount)->toBe(3)
        ->and($result->errorCount)->toBe(0);
});

test('createCatalog — MOCK_RATE_LIMIT in name triggers RateLimitException', function () {
    $mock = new MockAdPlatform();
    expect(fn () => $mock->createCatalog('tok', 'act_1', 'Catalog MOCK_RATE_LIMIT'))
        ->toThrow(RateLimitException::class);
});

// ============================================================
// N-th-call transient error simulation (R2.2)
// ============================================================

test('config-driven error simulation throws every N-th call (rate_limit)', function () {
    config(['ads.mock.error_simulation' => ['every' => 3, 'exception' => 'rate_limit']]);
    $mock = new MockAdPlatform();

    // Calls 1, 2 pass; call 3 throws; call 4, 5 pass; call 6 throws
    $mock->pauseCampaign('tok', 'c1', 'k1');
    $mock->pauseCampaign('tok', 'c1', 'k2');
    expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k3'))->toThrow(RateLimitException::class);
    $mock->pauseCampaign('tok', 'c1', 'k4');
    $mock->pauseCampaign('tok', 'c1', 'k5');
    expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k6'))->toThrow(RateLimitException::class);
});

test('config-driven error simulation can throw NetworkException', function () {
    config(['ads.mock.error_simulation' => ['every' => 1, 'exception' => 'network']]);
    $mock = new MockAdPlatform();

    expect(fn () => $mock->resumeCampaign('tok', 'c1', 'k1'))->toThrow(NetworkException::class);
});

test('config-driven error simulation is per-method (counters independent)', function () {
    config(['ads.mock.error_simulation' => ['every' => 2, 'exception' => 'rate_limit']]);
    $mock = new MockAdPlatform();

    // pauseCampaign call 1 OK, pullMetrics call 1 OK — independent counters
    $mock->pauseCampaign('tok', 'c1', 'k1');
    $mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), ['c1'], 'UTC');

    // Each at their own call 2 now throws
    expect(fn () => $mock->pauseCampaign('tok', 'c1', 'k2'))->toThrow(RateLimitException::class);
    expect(fn () => $mock->pullMetrics('tok', 'act_1', Carbon::now(), Carbon::now(), ['c1'], 'UTC'))->toThrow(RateLimitException::class);
});

test('error simulation disabled by default — no throws', function () {
    // beforeEach already nulls config; confirm 10 successive calls all pass
    $mock = new MockAdPlatform();
    for ($i = 0; $i < 10; $i++) {
        $result = $mock->pauseCampaign('tok', 'c1', "k{$i}");
        expect($result->isSuccess())->toBeTrue();
    }
});

// ============================================================
// No more LogicException — every R1 stub now overridden
// ============================================================

test('all 10 R1-stubbed methods now return real Result VOs on MockAdPlatform', function () {
    $mock = new MockAdPlatform();
    $dto = mockPublishDto();
    $audDto = new SyncAudienceDto(
        platformAudienceId: null, name: 'list', description: null, hashedEmails: ['h1'],
    );

    // Every one of these MUST return a VO / string / array — never throw LogicException
    expect($mock->publishCampaign('t', 'a', $dto, 'k'))->toBeInstanceOf(PublishResult::class);
    expect($mock->pauseCampaign('t', 'rc', 'k'))->toBeInstanceOf(PublishResult::class);
    expect($mock->resumeCampaign('t', 'rc', 'k'))->toBeInstanceOf(PublishResult::class);
    expect($mock->pullMetrics('t', 'a', Carbon::now(), Carbon::now(), ['c'], 'UTC'))->toBeInstanceOf(MetricsResult::class);
    expect($mock->syncAudience('t', 'a', $audDto, 'k'))->toBeInstanceOf(AudienceSyncResult::class);
    expect($mock->deleteAudience('t', 'aud_x'))->toBeInstanceOf(AudienceSyncResult::class);
    expect($mock->getLeadForms('t', 'a'))->toBeArray();
    expect($mock->fetchLeads('t', 'f1', null))->toBeInstanceOf(LeadFetchResult::class);
    expect($mock->createCatalog('t', 'a', 'name'))->toBeString();
    expect($mock->syncCatalog('t', 'cat', [], 'k'))->toBeInstanceOf(CatalogSyncResult::class);
});