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

/**
 * Ads Phase R15.1 — Async sync job + scheduler + controller dispatch.
 *
 * Verifies:
 *   1. Controller `sync()` dispatches SyncAdProductCatalog instead of
 *      running synchronously.  Catalog flips to SYNC_IN_PROGRESS in the
 *      same request.
 *   2. SyncAdProductCatalog inherits AbstractAdsJob defaults (5 tries,
 *      progressive backoff, ads queue).
 *   3. SyncAdProductCatalog::handle calls AdProductCatalogService::sync.
 *   4. SyncAdProductCatalog::failed flips catalog status to SYNC_FAILED
 *      and writes an AdActivityLog entry.
 *   5. SyncAdProductCatalog gracefully no-ops when the catalog row was
 *      deleted between dispatch and pickup.
 *   6. SweepProductCatalogSync filters: skips manual catalogs, skips
 *      catalogs without ad_platform_id, skips catalogs synced within
 *      the cutoff window.
 *   7. SweepProductCatalogSync dispatches one SyncAdProductCatalog per
 *      eligible catalog.
 *   8. Scheduler entry registered as `ads:sweep-catalog-sync`.
 */

use App\Jobs\Ads\SweepProductCatalogSync;
use App\Jobs\Ads\SyncAdProductCatalog;
use App\Model\AdActivityLog;
use App\Model\AdPlatform;
use App\Model\AdProductCatalog;
use App\Services\Ads\AdProductCatalogService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;

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

/**
 * @return array{customerId:int, catalog:AdProductCatalog, platform:?AdPlatform}
 */
function r151Scenario(bool $bindPlatform = true, string $sourceType = AdProductCatalog::SOURCE_FEED_URL, ?string $lastSyncedAt = null): array
{
    $customerId = (int) DB::table('customers')->insertGetId([
        'uid' => 'r151cus_' . uniqid(),
        'timezone' => 'UTC',
        'status' => 'active',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $platform = null;
    if ($bindPlatform) {
        $platform = new AdPlatform([
            'uid' => 'r151plat_' . uniqid(),
            'customer_id' => $customerId,
            'platform' => 'meta',
            'name' => 'R15.1 Platform',
            'access_token' => 'tok_' . uniqid(),
            'platform_user_id' => 'pu_' . uniqid(),
        ]);
        $platform->status = AdPlatform::STATUS_ACTIVE;
        $platform->health_status = AdPlatform::HEALTH_HEALTHY;
        $platform->save();
    }

    $catalog = AdProductCatalog::create([
        'uid' => 'r151cat_' . uniqid(),
        'customer_id' => $customerId,
        'ad_platform_id' => $platform?->id,
        'name' => 'R15.1 Catalog',
        'source_type' => $sourceType,
        'sync_status' => AdProductCatalog::SYNC_PENDING,
        'last_synced_at' => $lastSyncedAt,
    ]);

    return ['customerId' => $customerId, 'catalog' => $catalog->fresh(), 'platform' => $platform];
}

// ---------------------------------------------------------------------------
// 2. Job inherits AbstractAdsJob defaults
// ---------------------------------------------------------------------------

test('SyncAdProductCatalog extends AbstractAdsJob with 5 tries + progressive backoff', function () {
    $job = new SyncAdProductCatalog(123);
    expect($job->tries)->toBe(5);
    expect($job->backoff)->toBe([60, 300, 900, 3600, 14400]);
    expect($job->timeout)->toBe(300);
    expect($job->queue)->toBe(config('ads.queues.default', 'default'));
});

// ---------------------------------------------------------------------------
// 3. Job handle calls service::sync
// ---------------------------------------------------------------------------

test('SyncAdProductCatalog::handle delegates to AdProductCatalogService::sync', function () {
    $scenario = r151Scenario(bindPlatform: false);
    $catalog = $scenario['catalog'];

    $called = false;
    $captured = null;
    $service = new class($called, $captured) extends AdProductCatalogService {
        public bool $syncCalled = false;
        public ?int $syncedCatalogId = null;
        public function __construct(bool &$called, ?int &$captured)
        {
            // Skip parent constructor — we don't need the manager for this stub.
        }
        public function sync(AdProductCatalog $catalog): void
        {
            $this->syncCalled = true;
            $this->syncedCatalogId = (int) $catalog->id;
        }
    };

    $job = new SyncAdProductCatalog((int) $catalog->id);
    $job->handle($service);

    expect($service->syncCalled)->toBeTrue();
    expect($service->syncedCatalogId)->toBe((int) $catalog->id);
});

test('SyncAdProductCatalog::handle is a no-op when catalog was deleted', function () {
    $service = new class extends AdProductCatalogService {
        public bool $syncCalled = false;
        public function __construct() {}
        public function sync(AdProductCatalog $catalog): void
        {
            $this->syncCalled = true;
        }
    };

    $job = new SyncAdProductCatalog(999999);
    $job->handle($service);

    expect($service->syncCalled)->toBeFalse();
});

// ---------------------------------------------------------------------------
// 4. failed() flips status + logs
// ---------------------------------------------------------------------------

test('SyncAdProductCatalog::failed flips catalog to SYNC_FAILED + writes activity log', function () {
    $scenario = r151Scenario();
    $catalog = $scenario['catalog'];
    $catalog->sync_status = AdProductCatalog::SYNC_IN_PROGRESS;
    $catalog->save();

    $beforeLogCount = AdActivityLog::where('loggable_id', $catalog->id)
        ->where('loggable_type', AdProductCatalog::class)
        ->count();

    $job = new SyncAdProductCatalog((int) $catalog->id);
    $job->failed(new \RuntimeException('Adapter exploded'));

    $catalog->refresh();
    expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_FAILED);

    $afterLogCount = AdActivityLog::where('loggable_id', $catalog->id)
        ->where('loggable_type', AdProductCatalog::class)
        ->count();
    expect($afterLogCount)->toBeGreaterThan($beforeLogCount);
});

test('SyncAdProductCatalog::failed is a no-op when catalog was deleted', function () {
    $job = new SyncAdProductCatalog(888888);
    // Should not throw — just logs failure.
    $job->failed(new \RuntimeException('whatever'));
    expect(true)->toBeTrue();
});

// ---------------------------------------------------------------------------
// 1. Controller dispatches the job + flips status to IN_PROGRESS
// ---------------------------------------------------------------------------

test('controller sync action dispatches SyncAdProductCatalog + flips status to IN_PROGRESS', function () {
    Queue::fake();

    // Use the test scenario but bypass auth — call the controller directly.
    $scenario = r151Scenario();
    $catalog = $scenario['catalog'];

    $controller = app(\App\Http\Controllers\Refactor\Ads\AdProductCatalogController::class);

    // Simulate the customer + request — controller reads $request->user()->customer.
    $user = new class($scenario['customerId']) {
        public object $customer;
        public function __construct(int $cid)
        {
            $this->customer = new class($cid) {
                public int $localId;
                public function __construct(int $cid) { $this->localId = $cid; }
                public function local(): object {
                    return new class($this->localId) {
                        public int $id;
                        public function __construct(int $id) { $this->id = $id; }
                    };
                }
            };
        }
    };
    $request = \Illuminate\Http\Request::create('/test', 'POST');
    $request->setUserResolver(fn () => $user);

    $response = $controller->sync($request, $catalog->uid);

    $catalog->refresh();
    expect($catalog->sync_status)->toBe(AdProductCatalog::SYNC_IN_PROGRESS);
    Queue::assertPushed(SyncAdProductCatalog::class, fn ($job) => $job->catalogId === (int) $catalog->id);
});

// ---------------------------------------------------------------------------
// 6 + 7. Sweeper filtering + fan-out
// ---------------------------------------------------------------------------

test('SweepProductCatalogSync skips manual catalogs', function () {
    Queue::fake();
    config(['ads.catalog.sweep_min_age_hours' => 24]);

    $manual = r151Scenario(sourceType: AdProductCatalog::SOURCE_MANUAL, lastSyncedAt: now()->subDays(5)->toDateTimeString());

    (new SweepProductCatalogSync())->handle();

    Queue::assertNotPushed(SyncAdProductCatalog::class);
});

test('SweepProductCatalogSync skips catalogs without ad_platform_id', function () {
    Queue::fake();
    config(['ads.catalog.sweep_min_age_hours' => 24]);

    $unbound = r151Scenario(bindPlatform: false, sourceType: AdProductCatalog::SOURCE_FEED_URL, lastSyncedAt: now()->subDays(5)->toDateTimeString());

    (new SweepProductCatalogSync())->handle();

    Queue::assertNotPushed(SyncAdProductCatalog::class);
});

test('SweepProductCatalogSync skips catalogs synced within cutoff window', function () {
    Queue::fake();
    config(['ads.catalog.sweep_min_age_hours' => 24]);

    $recent = r151Scenario(sourceType: AdProductCatalog::SOURCE_FEED_URL, lastSyncedAt: now()->subHours(1)->toDateTimeString());

    (new SweepProductCatalogSync())->handle();

    Queue::assertNotPushed(SyncAdProductCatalog::class);
});

test('SweepProductCatalogSync dispatches eligible catalogs', function () {
    Queue::fake();
    config(['ads.catalog.sweep_min_age_hours' => 24]);

    $stale = r151Scenario(sourceType: AdProductCatalog::SOURCE_FEED_URL, lastSyncedAt: now()->subDays(2)->toDateTimeString());
    $neverSynced = r151Scenario(sourceType: AdProductCatalog::SOURCE_FEED_URL, lastSyncedAt: null);

    (new SweepProductCatalogSync())->handle();

    Queue::assertPushed(SyncAdProductCatalog::class, fn ($job) => $job->catalogId === (int) $stale['catalog']->id);
    Queue::assertPushed(SyncAdProductCatalog::class, fn ($job) => $job->catalogId === (int) $neverSynced['catalog']->id);
});

test('SweepProductCatalogSync respects custom sweep_min_age_hours config', function () {
    Queue::fake();
    config(['ads.catalog.sweep_min_age_hours' => 1]);  // 1-hour cutoff

    $oneHourOld = r151Scenario(sourceType: AdProductCatalog::SOURCE_FEED_URL, lastSyncedAt: now()->subHours(2)->toDateTimeString());

    (new SweepProductCatalogSync())->handle();

    Queue::assertPushed(SyncAdProductCatalog::class, fn ($job) => $job->catalogId === (int) $oneHourOld['catalog']->id);
});

// ---------------------------------------------------------------------------
// 8. Scheduler registration
// ---------------------------------------------------------------------------

test('scheduler registers ads:sweep-catalog-sync', function () {
    $events = collect(app(\Illuminate\Console\Scheduling\Schedule::class)->events());
    $sweep = $events->firstWhere(fn ($e) => $e->description === 'ads:sweep-catalog-sync');
    expect($sweep)->not->toBeNull();
    expect($sweep->expression)->toBe('0 4 * * *');  // dailyAt('04:00')
});