'success', 'executed_at' => time()]; } } #[Schedule(at: new Every(hours: 1))] final class HourlyTestJob { public function __invoke(): string { return 'hourly job executed'; } } #[Schedule(at: new Every(days: 1))] final class DailyTestJob { // No handle() or __invoke() - should throw exception } describe('ScheduleDiscoveryService', function () { beforeEach(function () { // Create mock DiscoveryRegistry $this->discoveryRegistry = Mockery::mock(DiscoveryRegistry::class); // Create mock SchedulerService $this->schedulerService = Mockery::mock(SchedulerService::class); $this->scheduleDiscovery = new ScheduleDiscoveryService( $this->discoveryRegistry, $this->schedulerService ); }); afterEach(function () { Mockery::close(); }); it('discovers and registers scheduled jobs', function () { // Mock discovery registry to return test job classes $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([ TestScheduledJob::class, HourlyTestJob::class ]); // Expect scheduler to be called for each job $this->schedulerService ->shouldReceive('schedule') ->twice() ->withArgs(function ($taskId, $schedule, $task) { // Verify task ID is kebab-case expect($taskId)->toMatch('/^[a-z0-9-]+$/'); // Verify schedule is IntervalSchedule expect($schedule)->toBeInstanceOf(\App\Framework\Scheduler\Schedules\IntervalSchedule::class); // Verify task is callable expect($task)->toBeCallable(); return true; }); $registered = $this->scheduleDiscovery->discoverAndRegister(); expect($registered)->toBe(2); }); it('converts Every to IntervalSchedule correctly', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([TestScheduledJob::class]); $this->schedulerService ->shouldReceive('schedule') ->once() ->withArgs(function ($taskId, $schedule, $task) { // TestScheduledJob has Every(minutes: 5) = 300 seconds // IntervalSchedule should use this duration expect($schedule)->toBeInstanceOf(\App\Framework\Scheduler\Schedules\IntervalSchedule::class); return true; }); $this->scheduleDiscovery->discoverAndRegister(); }); it('generates kebab-case task IDs from class names', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([TestScheduledJob::class, HourlyTestJob::class]); $capturedTaskIds = []; $this->schedulerService ->shouldReceive('schedule') ->twice() ->withArgs(function ($taskId) use (&$capturedTaskIds) { $capturedTaskIds[] = $taskId; return true; }); $this->scheduleDiscovery->discoverAndRegister(); expect($capturedTaskIds)->toContain('test-scheduled-job'); expect($capturedTaskIds)->toContain('hourly-test-job'); }); it('executes jobs with handle() method', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([TestScheduledJob::class]); $capturedTask = null; $this->schedulerService ->shouldReceive('schedule') ->once() ->withArgs(function ($taskId, $schedule, $task) use (&$capturedTask) { $capturedTask = $task; return true; }); $this->scheduleDiscovery->discoverAndRegister(); // Execute the captured task $result = $capturedTask(); expect($result)->toBeArray(); expect($result['status'])->toBe('success'); expect($result)->toHaveKey('executed_at'); }); it('executes callable jobs', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([HourlyTestJob::class]); $capturedTask = null; $this->schedulerService ->shouldReceive('schedule') ->once() ->withArgs(function ($taskId, $schedule, $task) use (&$capturedTask) { $capturedTask = $task; return true; }); $this->scheduleDiscovery->discoverAndRegister(); // Execute the captured task $result = $capturedTask(); expect($result)->toBe('hourly job executed'); }); it('throws exception for jobs without handle() or __invoke()', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([DailyTestJob::class]); $capturedTask = null; $this->schedulerService ->shouldReceive('schedule') ->once() ->withArgs(function ($taskId, $schedule, $task) use (&$capturedTask) { $capturedTask = $task; return true; }); $this->scheduleDiscovery->discoverAndRegister(); // Executing the task should throw exception expect(fn() => $capturedTask())->toThrow( \RuntimeException::class, 'must have handle() method or be callable' ); }); it('handles multiple Schedule attributes on same class', function () { // Create a test class with multiple schedules (IS_REPEATABLE) $testClass = new class { #[Schedule(at: new Every(minutes: 5))] #[Schedule(at: new Every(hours: 1))] public function handle(): string { return 'multi-schedule job'; } }; $className = $testClass::class; $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([$className]); // Should register twice (one for each Schedule attribute) $this->schedulerService ->shouldReceive('schedule') ->twice(); $registered = $this->scheduleDiscovery->discoverAndRegister(); expect($registered)->toBe(2); }); it('returns 0 when no scheduled jobs found', function () { $this->discoveryRegistry ->shouldReceive('getClassesWithAttribute') ->with(Schedule::class) ->once() ->andReturn([]); $this->schedulerService ->shouldReceive('schedule') ->never(); $registered = $this->scheduleDiscovery->discoverAndRegister(); expect($registered)->toBe(0); }); it('delegates getScheduledTasks to SchedulerService', function () { $expectedTasks = [ ['taskId' => 'test-task-1'], ['taskId' => 'test-task-2'] ]; $this->schedulerService ->shouldReceive('getScheduledTasks') ->once() ->andReturn($expectedTasks); $tasks = $this->scheduleDiscovery->getScheduledTasks(); expect($tasks)->toBe($expectedTasks); }); });