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