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