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