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

/**
 * Ads Phase R3 — Service Refactor test suite.
 *
 * Verifies:
 *   1. Feature flag `ads.hardening.use_session_pattern` defaults OFF
 *   2. AdPlatformManager exposes isMockMode() + useSessionPattern() helpers
 *   3. AdPublishingService: flag-OFF path preserves legacy mockPublish behavior
 *      (idempotency_key now populated either way — that's the only additive
 *      bit that ships at default-OFF)
 *   4. AdPublishingService: flag-ON path routes through session chain,
 *      persists PublishResult to AdCampaignPlatform + AdPublishLog with the
 *      correct remote IDs + success status
 *   5. AdPublishingService: flag-ON path maps MOCK_REJECT → STATUS_REJECTED
 *      on both pivot and log, records rejection reason
 *   6. AdAudienceService: flag-ON path routes through session chain and
 *      persists platformAudienceId returned by chain
 *   7. AdAudienceService: flag-OFF path matches pre-R3 mock behavior
 *   8. Grep gate: outside AdPlatformManager, no service in app/Services/Ads/
 *      reads `config('ads.mock_mode')` — they go through $manager->isMockMode()
 *   9. Services inject AdPlatformManager via constructor (DI signature smoke)
 *  10. idempotency_key is written on AdPublishLog for both paths
 *
 * DB strategy: DatabaseTransactions (NOT RefreshDatabase) so the developer's
 * local DB state is preserved. Rows are seeded via forceCreate() because
 * CustomerFactory nests User::factory() which doesn't resolve for App\Model\User.
 */

use App\Model\AdAccount;
use App\Model\AdAudience;
use App\Model\AdCampaign;
use App\Model\AdCampaignPlatform;
use App\Model\AdPlatform;
use App\Model\AdPublishLog;
use App\Services\Ads\AdAudienceService;
use App\Services\Ads\AdLeadFormService;
use App\Services\Ads\AdPlatformManager;
use App\Services\Ads\AdProductCatalogService;
use App\Services\Ads\AdPublishingService;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);

beforeEach(function () {
    config([
        'ads.mock_mode' => true,
        'ads.hardening.use_session_pattern' => false,
    ]);
});

/**
 * Seeds an isolated campaign scenario: Customer → AdPlatform (Meta) →
 * default AdAccount → AdCampaign (draft).  Uses forceCreate() to avoid the
 * CustomerFactory → User::factory() chain (User model has no factory hook).
 *
 * @return array{0: int, 1: AdPlatform, 2: AdCampaign}
 */
function seedR3Scenario(): array
{
    $customerId = (int) \DB::table('customers')->insertGetId([
        'uid' => 'r3cus_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $platform = AdPlatform::create([
        'uid' => 'r3plat_' . uniqid(),
        'customer_id' => $customerId,
        'platform' => AdPlatform::PLATFORM_META,
        'name' => 'Meta',
        'access_token' => 'mock_access_r3',
        'refresh_token' => 'mock_refresh_r3',
        'token_expires_at' => now()->addDays(30),
        'platform_user_id' => 'r3-user',
        'platform_user_name' => 'R3 Biz',
        'scopes' => ['ads_management'],
        'status' => AdPlatform::STATUS_ACTIVE,
        'health_status' => AdPlatform::HEALTH_HEALTHY,
        'last_health_check_at' => now(),
    ]);

    AdAccount::create([
        'ad_platform_id' => $platform->id,
        'remote_account_id' => 'act_mock_r3',
        'name' => 'R3 Mock Account',
        'currency' => 'USD',
        'status' => AdAccount::STATUS_ACTIVE,
        'is_default' => true,
    ]);

    $campaign = AdCampaign::create([
        'uid' => 'r3camp_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'R3 Test Campaign',
        'objective' => 'traffic',
        'status' => AdCampaign::STATUS_DRAFT,
        'daily_budget' => 10.00,
        'budget_currency' => 'USD',
        'bid_strategy' => AdCampaign::BID_AUTO,
    ]);

    return [$customerId, $platform, $campaign];
}

// ============================================================
// 1-2. Flag plumbing
// ============================================================

test('ads.hardening.use_session_pattern defaults to false when env unset', function () {
    // Fresh config reload with ADS_USE_SESSION_PATTERN unused — config file
    // default must be falsy so existing installs keep legacy behavior after
    // upgrade without any .env changes.
    $default = require base_path('config/ads.php');
    expect($default['hardening']['use_session_pattern'] ?? true)->toBeFalse();
});

test('AdPlatformManager exposes isMockMode() + useSessionPattern()', function () {
    $manager = app(AdPlatformManager::class);

    config(['ads.mock_mode' => true, 'ads.hardening.use_session_pattern' => false]);
    expect($manager->isMockMode())->toBeTrue()
        ->and($manager->useSessionPattern())->toBeFalse();

    config(['ads.hardening.use_session_pattern' => true]);
    expect($manager->useSessionPattern())->toBeTrue();

    config(['ads.mock_mode' => false]);
    expect($manager->isMockMode())->toBeFalse();
});

// ============================================================
// 3-5. AdPublishingService — legacy + session paths
// ============================================================

test('AdPublishingService::publish — flag OFF uses legacy mockPublish path', function () {
    [, $platform, $campaign] = seedR3Scenario();
    config(['ads.hardening.use_session_pattern' => false]);

    $service = app(AdPublishingService::class);
    $results = $service->publish($campaign, [$platform->id]);

    expect($results)->toHaveCount(1)
        ->and($results[0]['status'])->toBe('success')
        ->and($results[0]['platform'])->toBe('meta');

    $cp = AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->firstOrFail();
    // Legacy mockPublish writes the `mock_meta_*` id style
    expect($cp->remote_campaign_id)->toStartWith('mock_meta_')
        ->and($cp->status)->toBe(AdCampaignPlatform::STATUS_ACTIVE);

    $log = AdPublishLog::where('ad_campaign_id', $campaign->id)->firstOrFail();
    expect($log->status)->toBe(AdPublishLog::STATUS_SUCCESS)
        ->and($log->idempotency_key)->not->toBeNull()
        ->and(strlen((string) $log->idempotency_key))->toBe(64); // sha256 hex
});

test('AdPublishingService::publish — flag ON routes through session chain', function () {
    [, $platform, $campaign] = seedR3Scenario();
    config(['ads.hardening.use_session_pattern' => true]);

    $service = app(AdPublishingService::class);
    $results = $service->publish($campaign, [$platform->id]);

    expect($results[0]['status'])->toBe('success');

    $cp = AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->firstOrFail();
    // Session-chain path uses MockAdPlatform's `mock_camp_*` id shape (R2)
    expect($cp->remote_campaign_id)->toStartWith('mock_camp_')
        ->and($cp->remote_adset_id)->toStartWith('mock_adset_')
        ->and($cp->remote_ad_id)->toStartWith('mock_ad_')
        ->and($cp->status)->toBe(AdCampaignPlatform::STATUS_ACTIVE)
        ->and($cp->ad_account_id)->not->toBeNull();

    $log = AdPublishLog::where('ad_campaign_id', $campaign->id)->firstOrFail();
    expect($log->status)->toBe(AdPublishLog::STATUS_SUCCESS)
        ->and($log->response_data['status'] ?? null)->toBe('active')
        ->and($log->response_data['remote_campaign_id'] ?? null)->toStartWith('mock_camp_')
        ->and($log->request_data)->toHaveKey('objective');
});

test('AdPublishingService::publish — flag ON maps MOCK_REJECT to STATUS_REJECTED', function () {
    [, $platform, $campaign] = seedR3Scenario();
    $campaign->update(['name' => 'Summer MOCK_REJECT sale']);
    config(['ads.hardening.use_session_pattern' => true]);

    $service = app(AdPublishingService::class);
    $results = $service->publish($campaign, [$platform->id]);

    expect($results[0]['status'])->toBe('rejected');

    $cp = AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->firstOrFail();
    expect($cp->status)->toBe(AdCampaignPlatform::STATUS_REJECTED)
        ->and($cp->rejection_reason)->toContain('MOCK_REJECT');

    $log = AdPublishLog::where('ad_campaign_id', $campaign->id)->firstOrFail();
    expect($log->status)->toBe(AdPublishLog::STATUS_REJECTED)
        ->and($log->error_message)->toContain('MOCK_REJECT');

    // Campaign overall status should NOT flip ACTIVE when a platform rejects
    $campaign->refresh();
    expect($campaign->status)->not->toBe(AdCampaign::STATUS_ACTIVE);
});

test('AdPublishingService::pause — flag ON pauses via session chain', function () {
    [, $platform, $campaign] = seedR3Scenario();
    AdCampaignPlatform::create([
        'ad_campaign_id' => $campaign->id,
        'ad_platform_id' => $platform->id,
        'remote_campaign_id' => 'mock_camp_abc',
        'status' => AdCampaignPlatform::STATUS_ACTIVE,
    ]);
    config(['ads.hardening.use_session_pattern' => true]);

    app(AdPublishingService::class)->pause($campaign->fresh());

    $cp = AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->firstOrFail();
    expect($cp->status)->toBe(AdCampaignPlatform::STATUS_PAUSED);

    $log = AdPublishLog::where('ad_campaign_id', $campaign->id)
        ->where('action', AdPublishLog::ACTION_PAUSE)->firstOrFail();
    expect($log->status)->toBe(AdPublishLog::STATUS_SUCCESS)
        ->and($log->idempotency_key)->not->toBeNull()
        ->and($log->response_data['status'] ?? null)->toBe('paused');
});

// ============================================================
// 6-7. AdAudienceService — legacy + session paths
// ============================================================

test('AdAudienceService::syncToPlatform — flag OFF matches pre-R3 mock behavior', function () {
    [$customerId, ] = seedR3Scenario();
    $audience = AdAudience::create([
        'uid' => 'r3aud_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Flag-off audience',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);
    config(['ads.hardening.use_session_pattern' => false]);

    app(AdAudienceService::class)->syncToPlatform($audience);

    $audience->refresh();
    expect($audience->status)->toBe(AdAudience::STATUS_SYNCED)
        ->and($audience->sync_status)->toBe(AdAudience::SYNC_COMPLETED)
        ->and($audience->platform_audience_id)->toStartWith('mock_aud_'); // mock-mode is ON in beforeEach
});

test('AdAudienceService::syncToPlatform — flag ON routes through session chain', function () {
    [$customerId, ] = seedR3Scenario();
    $audience = AdAudience::create([
        'uid' => 'r3aud_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Flag-on audience',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);
    config(['ads.hardening.use_session_pattern' => true]);

    app(AdAudienceService::class)->syncToPlatform($audience);

    $audience->refresh();
    // Session-chain path sets id from AudienceSyncResult — still starts with
    // mock_aud_ because the adapter resolved to MockAdPlatform, but went
    // through the full 6-decorator chain to get there.
    expect($audience->status)->toBe(AdAudience::STATUS_SYNCED)
        ->and($audience->sync_status)->toBe(AdAudience::SYNC_COMPLETED)
        ->and($audience->platform_audience_id)->toStartWith('mock_aud_');
});

test('AdAudienceService::syncToPlatform — flag ON errors out cleanly when no platform connected', function () {
    $customerId = (int) \DB::table('customers')->insertGetId([
        'uid' => 'r3cus_noplat_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);
    $audience = AdAudience::create([
        'uid' => 'r3aud_' . uniqid(),
        'customer_id' => $customerId,
        'name' => 'Orphan audience',
        'type' => AdAudience::TYPE_CUSTOM,
        'source_type' => AdAudience::SOURCE_MANUAL,
        'platform' => 'meta',
        'status' => AdAudience::STATUS_DRAFT,
        'sync_status' => AdAudience::SYNC_PENDING,
    ]);
    config(['ads.hardening.use_session_pattern' => true]);

    app(AdAudienceService::class)->syncToPlatform($audience);

    $audience->refresh();
    expect($audience->status)->toBe(AdAudience::STATUS_ERROR)
        ->and($audience->sync_status)->toBe(AdAudience::SYNC_FAILED);
});

// ============================================================
// 8. Grep gate — mock_mode not read outside AdPlatformManager
// ============================================================

test('grep gate — zero config(ads.mock_mode) reads in app/Services/Ads/ outside AdPlatformManager', function () {
    $root = base_path('app/Services/Ads');
    $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
    $offenders = [];
    foreach ($iter as $file) {
        if (!$file->isFile() || $file->getExtension() !== 'php') continue;
        $path = $file->getPathname();
        if (str_ends_with($path, 'AdPlatformManager.php')) continue;
        $content = file_get_contents($path);
        if ($content !== false && preg_match("/config\\(\\s*['\"]ads\\.mock_mode['\"]/", $content)) {
            $offenders[] = str_replace(base_path() . '/', '', $path);
        }
    }
    expect($offenders)->toBe([], sprintf(
        "These files still read config('ads.mock_mode') directly — must go through \$manager->isMockMode():\n%s",
        implode("\n", $offenders),
    ));
});

// ============================================================
// 9. Constructor DI signatures
// ============================================================

test('services inject AdPlatformManager via constructor', function () {
    foreach ([AdPublishingService::class, AdAudienceService::class, AdLeadFormService::class, AdProductCatalogService::class] as $class) {
        $ref = new ReflectionClass($class);
        $ctor = $ref->getConstructor();
        expect($ctor)->not->toBeNull("{$class} must declare a constructor");
        $params = $ctor->getParameters();
        $hasManager = false;
        foreach ($params as $p) {
            $type = $p->getType();
            if ($type instanceof ReflectionNamedType && $type->getName() === AdPlatformManager::class) {
                $hasManager = true;
                break;
            }
        }
        expect($hasManager)->toBeTrue("{$class} must inject AdPlatformManager");
    }
});