File: /home/xedaptot/ai.naniguide.com/tests/Unit/Jobs/RefreshCacheableJobTest.php
<?php
use App\Jobs\RefreshCacheableJob;
use App\Library\Cache\AppCache;
use App\Library\Cache\Cacheable;
use App\Notifications\AppNotification;
use App\Notifications\System\CacheRefreshFailed;
use App\Services\Notifications\Notifier;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
uses(Tests\TestCase::class);
beforeEach(function () {
Cache::flush();
AppCache::reset();
});
function fakeCacheableForJob(string $key, array $manifest): Cacheable
{
return new class($key, $manifest) implements Cacheable {
public function __construct(private string $key, private array $manifest) {}
public function cacheKey(): string { return $this->key; }
public function cacheManifest(): array { return $this->manifest; }
};
}
test('dispatchAll fans out one job per manifest entry', function () {
Bus::fake();
$subject = fakeCacheableForJob('model:Foo:1', [
'Alpha' => fn () => 1,
'Beta' => fn () => 2,
'Gamma' => fn () => 3,
]);
RefreshCacheableJob::dispatchAll($subject);
Bus::assertDispatchedTimes(RefreshCacheableJob::class, 3);
Bus::assertDispatched(RefreshCacheableJob::class, fn ($job) => $job->entry === 'Alpha');
Bus::assertDispatched(RefreshCacheableJob::class, fn ($job) => $job->entry === 'Beta');
Bus::assertDispatched(RefreshCacheableJob::class, fn ($job) => $job->entry === 'Gamma');
});
test('dispatchAll dispatches no jobs when manifest is empty', function () {
Bus::fake();
$subject = fakeCacheableForJob('model:Empty:1', []);
RefreshCacheableJob::dispatchAll($subject);
Bus::assertNothingDispatched();
});
test('per-entry job failure does not block sibling entries (fan-out vs serial)', function () {
Bus::fake();
$subject = fakeCacheableForJob('model:Foo:2', [
'Slow' => fn () => throw new RuntimeException('would have killed serial run'),
'Fast' => fn () => 'fast-value',
'AlsoFast' => fn () => 'also',
]);
RefreshCacheableJob::dispatchAll($subject);
Bus::assertDispatchedTimes(RefreshCacheableJob::class, 3);
Bus::assertDispatched(RefreshCacheableJob::class, fn ($job) => $job->entry === 'Fast');
Bus::assertDispatched(RefreshCacheableJob::class, fn ($job) => $job->entry === 'AlsoFast');
});
test('job declares timeout long enough for slow aggregate closures', function () {
$subject = fakeCacheableForJob('model:Foo:timeout', ['Alpha' => fn () => 1]);
$job = new RefreshCacheableJob($subject, 'Alpha');
// 50s bounceCount() is the slowest known closure today; anything below 60s
// would risk regressing the Acorn Labs incident. Headroom for future
// heavier aggregates without letting a runaway query lock a worker forever.
expect($job->timeout)->toBeGreaterThanOrEqual(120);
expect($job->timeout)->toBeLessThanOrEqual(900);
});
test('failed callback dispatches CacheRefreshFailed notification with scope + entry + exception', function () {
$captured = null;
$notifier = new class extends Notifier {
public function __construct() {}
public ?AppNotification $captured = null;
public function shareWithAdmins(AppNotification $event): \App\Model\AppNotification
{
$this->captured = $event;
return new \App\Model\AppNotification();
}
};
$this->app->instance(Notifier::class, $notifier);
$subject = fakeCacheableForJob('model:Campaign:298', ['BounceCount' => fn () => 866]);
$job = new RefreshCacheableJob($subject, 'BounceCount');
$error = new RuntimeException('SQL timeout after 60s');
$job->failed($error);
expect($notifier->captured)->toBeInstanceOf(CacheRefreshFailed::class);
expect($notifier->captured->scope)->toBe('model:Campaign:298');
expect($notifier->captured->entry)->toBe('BounceCount');
expect($notifier->captured->exception)->toBe($error);
$payload = $notifier->captured->toArray();
expect($payload['title'])->toBe('Cache refresh failed');
expect($payload['body'])->toContain('BounceCount');
expect($payload['body'])->toContain('model:Campaign:298');
expect($payload['body'])->toContain('SQL timeout after 60s');
expect($notifier->captured->severity())->toBe(AppNotification::SEVERITY_ERROR);
expect($notifier->captured->audience())->toBe(AppNotification::AUDIENCE_ADMINS);
});
test('CacheRefreshFailed groupKey collapses repeated failures of the same entry', function () {
$e = new RuntimeException('boom');
$a = new CacheRefreshFailed('model:Campaign:1', 'BounceCount', $e);
$b = new CacheRefreshFailed('model:Campaign:1', 'BounceCount', $e);
$c = new CacheRefreshFailed('model:Campaign:1', 'UnsubscribeCount', $e);
$d = new CacheRefreshFailed('model:Campaign:2', 'BounceCount', $e);
expect($a->groupKey())->toBe($b->groupKey());
expect($a->groupKey())->not->toBe($c->groupKey());
expect($a->groupKey())->not->toBe($d->groupKey());
});
test('CacheRefreshFailed groupKey treats null entry as wildcard', function () {
$e = new RuntimeException('boom');
$n = new CacheRefreshFailed('model:Foo:1', null, $e);
expect($n->groupKey())->toBe('cache-refresh-failed:model:Foo:1:*');
});
test('uniqueId distinguishes per-entry jobs', function () {
$subject = fakeCacheableForJob('model:Foo:3', [
'Alpha' => fn () => 1,
'Beta' => fn () => 2,
]);
$alpha = new RefreshCacheableJob($subject, 'Alpha');
$beta = new RefreshCacheableJob($subject, 'Beta');
$all = new RefreshCacheableJob($subject, null);
expect($alpha->uniqueId())->toBe('cache:model:Foo:3:Alpha');
expect($beta->uniqueId())->toBe('cache:model:Foo:3:Beta');
expect($all->uniqueId())->toBe('cache:model:Foo:3:*');
expect($alpha->uniqueId())->not->toBe($beta->uniqueId());
});