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