shouldReceive('llen')->with(Mockery::on(fn ($k) => str_ends_with((string) $k, ':notify')))->andReturn($llen); $redis->shouldReceive('llen')->andReturn($llen); $redis->shouldReceive('zcard')->andReturnUsing(function ($key) use ($reserved, $delayed) { return str_contains((string) $key, 'reserved') ? $reserved : $delayed; }); $redis->shouldReceive('lindex')->andReturnUsing(function ($key, $i) use ($payloads) { return $payloads[$i % max(1, count($payloads))] ?? null; }); Redis::shouldReceive('connection')->andReturn($redis); return $redis; } it('dry-run inspects forum-moderation without unlinking', function () { config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]); $payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']); $redis = orphanRedisMock(5, [$payload]); $redis->shouldReceive('unlink')->never(); $plan = app(OrphanQueueCleanup::class)->plan('forum-moderation'); expect($plan['can_execute'])->toBeTrue() ->and($plan['llen'])->toBe(5) ->and($plan['classes'])->toHaveKey('cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob'); }); it('refuses forum cleanup when producer dispatch is enabled', function () { config(['skinbase_ai_moderation.queue.dispatch_enabled' => true]); $payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']); orphanRedisMock(2, [$payload]); $plan = app(OrphanQueueCleanup::class)->plan('forum-moderation'); expect($plan['can_execute'])->toBeFalse() ->and($plan['errors'])->toContain('producer_dispatch_enabled'); }); it('refuses protected default/search/mail queues', function () { expect(fn () => app(OrphanQueueCleanup::class)->plan('default')) ->toThrow(\RuntimeException::class); }); it('refuses unexpected job classes unless force is set', function () { config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]); $payload = json_encode(['displayName' => IndexUserJob::class]); orphanRedisMock(2, [$payload]); $plan = app(OrphanQueueCleanup::class)->plan('forum-moderation'); expect($plan['errors'])->toContain('unexpected_job_classes'); $forced = app(OrphanQueueCleanup::class)->plan('forum-moderation', true); expect($forced['errors'])->not->toContain('unexpected_job_classes'); }); it('execute unlinks only the intended orphan queue keys', function () { config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]); $payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']); $redis = orphanRedisMock(3, [$payload]); $redis->shouldReceive('unlink')->once()->with('queues:forum-moderation')->andReturn(1); $redis->shouldReceive('unlink')->once()->with('queues:forum-moderation:notify')->andReturn(1); $result = app(OrphanQueueCleanup::class)->execute('forum-moderation'); expect($result['unlinked'])->toBe(2) ->and($result['unlinked_keys'])->toBe(['queues:forum-moderation', 'queues:forum-moderation:notify']); }); it('plans forum-security cleanup for monitor jobs', function () { config(['forum_security.queues.dispatch_enabled' => false]); $payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\FirewallActivityMonitor']); orphanRedisMock(4, [$payload]); $plan = app(OrphanQueueCleanup::class)->plan('forum-security'); expect($plan['can_execute'])->toBeTrue(); }); it('artisan dry-run does not unlink', function () { config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]); $payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']); $redis = orphanRedisMock(1, [$payload]); $redis->shouldReceive('unlink')->never(); Redis::shouldReceive('connection')->andReturn($redis); $this->artisan('skinbase:redis-cleanup-orphans forum-moderation --dry-run') ->assertSuccessful(); });