File: /home/xedaptot/be.naniguide.com/tests/Feature/AdsR7MetricsReconcileTest.php
<?php
/**
* Ads Phase R7 — Metrics Pull + Reconciliation test suite.
*
* Closes gates R-14 (metrics persistence) + R-15 (reconciliation) + T-3..T-5
* (timezone audit trail).
*
* Verifies:
* 1. PullAdMetrics upserts per (campaign, platform, date)
* 2. Re-running for same window is idempotent (no duplicate rows)
* 3. `custom.timezone` + `custom.timezone_offset_minutes` persisted for audit
* 4. Empty pivot table → no HTTP call, no row written
* 5. Account-TZ alignment uses the AdAccount.timezone, not server TZ
* 6. ReconcileAdPublishLog marks SUCCESS when pivot has remote_campaign_id
* 7. ReconcileAdPublishLog marks ERROR when older than ABANDON_SECONDS
* 8. ReconcileAdPublishLog leaves in-grace-period logs PENDING
*/
use App\Jobs\Ads\PullAdMetrics;
use App\Jobs\Ads\ReconcileAdPublishLog;
use App\Model\AdAccount;
use App\Model\AdCampaign;
use App\Model\AdCampaignPlatform;
use App\Model\AdMetric;
use App\Model\AdPlatform;
use App\Model\AdPublishLog;
use App\Services\Ads\AdPlatformManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(Tests\TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
config([
'ads.mock_mode' => true, // manager resolves to MockAdPlatform
'ads.hardening.use_session_pattern' => true,
]);
});
/**
* @return array{0:int,1:AdPlatform,2:AdAccount,3:AdCampaign,4:AdCampaignPlatform}
*/
function seedR7Scenario(string $timezone = 'America/New_York'): array
{
$customerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r7cus_' . uniqid(),
'timezone' => 'UTC',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$platform = AdPlatform::create([
'uid' => 'r7plat_' . uniqid(),
'customer_id' => $customerId,
'platform' => AdPlatform::PLATFORM_META,
'name' => 'Meta',
'access_token' => 'r7-tok',
'refresh_token' => 'r7-refresh',
'token_expires_at' => now()->addDays(30),
'platform_user_id' => 'r7-user',
'platform_user_name' => 'R7 Biz',
'scopes' => ['ads_management'],
'health_status' => AdPlatform::HEALTH_HEALTHY,
'last_health_check_at' => now(),
]);
$account = AdAccount::create([
'ad_platform_id' => $platform->id,
'remote_account_id' => 'act_r7_' . uniqid(),
'name' => 'R7 Account',
'currency' => 'USD',
'timezone' => $timezone,
'status' => AdAccount::STATUS_ACTIVE,
'is_default' => true,
]);
$campaign = AdCampaign::create([
'uid' => 'r7camp_' . uniqid(),
'customer_id' => $customerId,
'name' => 'R7 Campaign',
'objective' => 'traffic',
'status' => AdCampaign::STATUS_ACTIVE,
'daily_budget' => 50.0,
'budget_currency' => 'USD',
'bid_strategy' => AdCampaign::BID_AUTO,
]);
$remoteCampaignId = 'remote_r7_' . uniqid();
$pivot = AdCampaignPlatform::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'ad_account_id' => $account->id,
'remote_campaign_id' => $remoteCampaignId,
'status' => AdCampaignPlatform::STATUS_ACTIVE,
]);
return [$customerId, $platform, $account, $campaign, $pivot];
}
// ============================================================
// 1-5. PullAdMetrics
// ============================================================
test('PullAdMetrics — upserts per (campaign, platform, date)', function () {
[$customerId, $platform, , $campaign] = seedR7Scenario();
(new PullAdMetrics($customerId, windowDays: 3))
->handle(app(AdPlatformManager::class));
$rows = AdMetric::where('ad_campaign_id', $campaign->id)->get();
expect($rows)->not->toBeEmpty();
expect($rows->pluck('platform')->unique()->all())->toBe(['meta']);
});
test('PullAdMetrics — idempotent across runs (unique upsert constraint)', function () {
[$customerId, , , $campaign] = seedR7Scenario();
(new PullAdMetrics($customerId, windowDays: 2))->handle(app(AdPlatformManager::class));
$firstCount = AdMetric::where('ad_campaign_id', $campaign->id)->count();
// Same window, run again — upsert prevents duplicate rows.
(new PullAdMetrics($customerId, windowDays: 2))->handle(app(AdPlatformManager::class));
$secondCount = AdMetric::where('ad_campaign_id', $campaign->id)->count();
expect($secondCount)->toBe($firstCount);
});
test('PullAdMetrics — persists timezone + offset_minutes in custom column', function () {
[$customerId, , , $campaign] = seedR7Scenario('Europe/London');
(new PullAdMetrics($customerId, windowDays: 1))->handle(app(AdPlatformManager::class));
$row = AdMetric::where('ad_campaign_id', $campaign->id)->firstOrFail();
$custom = $row->custom;
expect($custom['timezone'])->toBe('Europe/London')
->and($custom)->toHaveKey('timezone_offset_minutes')
->and($custom['timezone_offset_minutes'])->toBeInt()
->and($custom)->toHaveKey('pulled_at');
});
test('PullAdMetrics — no pivots → no rows written', function () {
$customerId = (int) \DB::table('customers')->insertGetId([
'uid' => 'r7empty_' . uniqid(), 'timezone' => 'UTC', 'status' => 'active',
'created_at' => now(), 'updated_at' => now(),
]);
$platform = AdPlatform::create([
'uid' => 'r7plat_empty_' . uniqid(),
'customer_id' => $customerId,
'platform' => AdPlatform::PLATFORM_META,
'name' => 'Meta',
'access_token' => 't',
'scopes' => ['ads_management'],
'health_status' => AdPlatform::HEALTH_HEALTHY,
]);
// No pivots created
(new PullAdMetrics($customerId))->handle(app(AdPlatformManager::class));
// Scope to this customer's campaigns only — global AdMetric count includes
// pre-existing dev DB state from other seeders.
$count = AdMetric::whereIn(
'ad_campaign_id',
AdCampaign::where('customer_id', $customerId)->pluck('id')
)->count();
expect($count)->toBe(0);
});
test('PullAdMetrics — skips orphan metric rows (remote id not in pivot map)', function () {
// This is implicit in the mock adapter returning rows only for passed-in
// remote IDs. Sanity check: pivot exists, row comes back for it, and
// the campaign is persisted.
[$customerId, , , $campaign] = seedR7Scenario();
(new PullAdMetrics($customerId, windowDays: 1))->handle(app(AdPlatformManager::class));
expect(AdMetric::where('ad_campaign_id', $campaign->id)->count())->toBeGreaterThan(0);
});
test('PullAdMetrics — deterministic: same platform + date produces stable values', function () {
[$customerId, , , $campaign] = seedR7Scenario();
(new PullAdMetrics($customerId, windowDays: 1))->handle(app(AdPlatformManager::class));
$first = AdMetric::where('ad_campaign_id', $campaign->id)->first();
// Wipe and re-pull same window
AdMetric::where('ad_campaign_id', $campaign->id)->delete();
(new PullAdMetrics($customerId, windowDays: 1))->handle(app(AdPlatformManager::class));
$second = AdMetric::where('ad_campaign_id', $campaign->id)->first();
// MockAdPlatform's pullMetrics seeds RNG from crc32(campaign_id) so the
// same remote id on the same date always produces identical rows.
expect($second->impressions)->toBe($first->impressions)
->and($second->clicks)->toBe($first->clicks)
->and($second->spend)->toBe($first->spend);
});
// ============================================================
// 6-8. ReconcileAdPublishLog
// ============================================================
test('ReconcileAdPublishLog — marks SUCCESS when pivot has remote_campaign_id', function () {
[$customerId, $platform, , $campaign, $pivot] = seedR7Scenario();
$log = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PUBLISH,
'status' => AdPublishLog::STATUS_PENDING,
'idempotency_key' => str_repeat('a', 64),
]);
// Backdate to trip the grace cutoff
$log->created_at = now()->subSeconds(ReconcileAdPublishLog::GRACE_SECONDS + 60);
$log->save();
(new ReconcileAdPublishLog())->handle(app(AdPlatformManager::class));
$log->refresh();
expect($log->status)->toBe(AdPublishLog::STATUS_SUCCESS)
->and($log->response_data)->toHaveKey('reconciled')
->and($log->response_data['remote_campaign_id'])->toBe($pivot->remote_campaign_id);
});
test('ReconcileAdPublishLog — marks ERROR when older than ABANDON_SECONDS and no remote id', function () {
[$customerId, $platform, , $campaign] = seedR7Scenario();
// Remove the pivot so there's no remote id to reconcile against
AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->delete();
$log = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PUBLISH,
'status' => AdPublishLog::STATUS_PENDING,
'idempotency_key' => str_repeat('b', 64),
]);
$log->created_at = now()->subSeconds(ReconcileAdPublishLog::ABANDON_SECONDS + 60);
$log->save();
(new ReconcileAdPublishLog())->handle(app(AdPlatformManager::class));
$log->refresh();
expect($log->status)->toBe(AdPublishLog::STATUS_ERROR)
->and($log->error_message)->toContain('timed out');
});
test('ReconcileAdPublishLog — leaves PENDING logs within grace period alone', function () {
[$customerId, $platform, , $campaign] = seedR7Scenario();
AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->delete();
// created just now — well inside grace period
$log = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PUBLISH,
'status' => AdPublishLog::STATUS_PENDING,
'idempotency_key' => str_repeat('c', 64),
]);
(new ReconcileAdPublishLog())->handle(app(AdPlatformManager::class));
$log->refresh();
expect($log->status)->toBe(AdPublishLog::STATUS_PENDING);
});
test('ReconcileAdPublishLog — leaves PENDING logs past grace but within abandon window', function () {
[$customerId, $platform, , $campaign] = seedR7Scenario();
AdCampaignPlatform::where('ad_campaign_id', $campaign->id)->delete();
$log = AdPublishLog::create([
'ad_campaign_id' => $campaign->id,
'ad_platform_id' => $platform->id,
'platform' => 'meta',
'action' => AdPublishLog::ACTION_PUBLISH,
'status' => AdPublishLog::STATUS_PENDING,
'idempotency_key' => str_repeat('d', 64),
]);
// Past grace (5m) but inside abandon (1h)
$log->created_at = now()->subSeconds(ReconcileAdPublishLog::GRACE_SECONDS + 60);
$log->save();
(new ReconcileAdPublishLog())->handle(app(AdPlatformManager::class));
$log->refresh();
expect($log->status)->toBe(AdPublishLog::STATUS_PENDING);
});