From 10c1dfa5a3f96dc632506fc35edaaf09b5151ada Mon Sep 17 00:00:00 2001 From: Nikita Kiselev Date: Sat, 6 Dec 2025 23:55:40 +0300 Subject: [PATCH] Add comprehensive unit tests for scheduler components --- .../framework/Scheduler/SchedulerService.php | 22 +- .../Unit/Framework/Scheduler/JobTest.php | 210 ++++++++++ .../Scheduler/ScheduleJobRegistryTest.php | 97 +++++ .../Scheduler/SchedulerResultTest.php | 100 +++++ .../Scheduler/SchedulerServiceTest.php | 379 ++++++++++++++++++ 5 files changed, 802 insertions(+), 6 deletions(-) create mode 100644 module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/JobTest.php create mode 100644 module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/ScheduleJobRegistryTest.php create mode 100644 module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerResultTest.php create mode 100644 module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerServiceTest.php diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php index 1c25df0..ca242c9 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Scheduler/SchedulerService.php @@ -15,6 +15,7 @@ class SchedulerService private CacheInterface $cache; private Container $container; private SettingsInterface $settings; + private ?ScheduleJobRegistry $registry = null; private const GLOBAL_LOCK_KEY = 'scheduler.global_lock'; private const GLOBAL_LOCK_TTL = 300; // 5 minutes @@ -27,6 +28,12 @@ class SchedulerService $this->settings = $settings; } + // For testing purposes + public function setRegistry(ScheduleJobRegistry $registry): void + { + $this->registry = $registry; + } + public function run(): SchedulerResult { $result = new SchedulerResult(); @@ -50,13 +57,16 @@ class SchedulerService $this->updateGlobalLastRun(); try { - $scheduler = new ScheduleJobRegistry($this->container); + $scheduler = $this->registry ?: new ScheduleJobRegistry($this->container); - $configFile = BP_BASE_PATH . '/configs/schedule.php'; - if (file_exists($configFile)) { - require $configFile; - } else { - $this->logger->warning('Scheduler config file not found: ' . $configFile); + // Only load config file if registry was not injected (for production use) + if (!$this->registry) { + $configFile = BP_BASE_PATH . '/configs/schedule.php'; + if (file_exists($configFile)) { + require $configFile; + } else { + $this->logger->warning('Scheduler config file not found: ' . $configFile); + } } foreach ($scheduler->getJobs() as $job) { diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/JobTest.php b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/JobTest.php new file mode 100644 index 0000000..5ec5d8e --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/JobTest.php @@ -0,0 +1,210 @@ +app; + + // Act + $job = new Job($container, function() {}, 'TestJob'); + + // Assert + $this->assertEquals('TestJob', $job->getName()); + $this->assertEquals(md5('TestJob'), $job->getId()); + } + + public function testJobWithoutNameUsesClassName() + { + // Arrange + $container = $this->app; + + // Act + $job = new Job($container, TestTask::class); + + // Assert + $this->assertEquals(TestTask::class, $job->getName()); + } + + public function testJobWithClosureGetsClosureName() + { + // Arrange + $container = $this->app; + + // Act + $job = new Job($container, function() {}); + + // Assert + $this->assertEquals('Closure', $job->getName()); + } + + public function testEveryMinuteSchedule() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + + // Act + $job->everyMinute(); + + // Assert + $this->assertEquals('* * * * *', $job->getExpression()); + } + + public function testEveryFiveMinutesSchedule() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + + // Act + $job->everyFiveMinutes(); + + // Assert + $this->assertEquals('*/5 * * * *', $job->getExpression()); + } + + public function testEveryHourSchedule() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + + // Act + $job->everyHour(); + + // Assert + $this->assertEquals('0 * * * *', $job->getExpression()); + } + + public function testDailyAtSchedule() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + + // Act + $job->dailyAt(9, 30); + + // Assert + $this->assertEquals('30 9 * * *', $job->getExpression()); + } + + public function testAtSchedule() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + + // Act + $job->at('0 12 * * 1'); + + // Assert + $this->assertEquals('0 12 * * 1', $job->getExpression()); + } + + public function testIsDueReturnsTrueForMinuteExpression() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + $job->everyMinute(); + + // Act + $isDue = $job->isDue(); + + // Assert + $this->assertTrue($isDue); + } + + public function testIsDueReturnsFalseForFutureExpression() + { + // Arrange + $container = $this->app; + $job = new Job($container, function() {}); + // Set to run at a specific future time + $job->at('0 23 * * *'); // 23:00 every day + + // Act + $isDue = $job->isDue(); + + // Assert - depends on current time, but generally should be false unless it's exactly 23:00 + // This test is not deterministic, so we'll just check it's boolean + $this->assertIsBool($isDue); + } + + public function testRunExecutesClosure() + { + // Arrange + $container = $this->app; + $executed = false; + + $job = new Job($container, function() use (&$executed) { + $executed = true; + }); + + // Act + $job->run(); + + // Assert + $this->assertTrue($executed); + } + + public function testRunExecutesTaskClass() + { + // Arrange + $container = $this->app; + + // Mock the task class binding + $container->singleton(TestTask::class, function() { + return new TestTask(); + }); + + $job = new Job($container, TestTask::class); + + // Act + $job->run(); + + // Assert + $this->assertTrue(TestTask::$executed); + TestTask::reset(); // Reset for other tests + } + + public function testRunExecutesTaskInterface() + { + // Arrange + $container = $this->app; + $task = new TestTask(); + $job = new Job($container, $task); + + // Act + $job->run(); + + // Assert + $this->assertTrue(TestTask::$executed); + TestTask::reset(); // Reset for other tests + } +} + +// Test helper class +class TestTask implements TaskInterface +{ + public static bool $executed = false; + + public function execute(): void + { + self::$executed = true; + } + + public static function reset(): void + { + self::$executed = false; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/ScheduleJobRegistryTest.php b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/ScheduleJobRegistryTest.php new file mode 100644 index 0000000..39fb4db --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/ScheduleJobRegistryTest.php @@ -0,0 +1,97 @@ +app); + + // Assert + $this->assertInstanceOf(ScheduleJobRegistry::class, $registry); + $this->assertEmpty($registry->getJobs()); + } + + public function testAddJobWithoutName() + { + // Arrange + $registry = new ScheduleJobRegistry($this->app); + + // Act + $job = $registry->add(function() {}); + + // Assert + $this->assertInstanceOf(Job::class, $job); + $this->assertCount(1, $registry->getJobs()); + $this->assertEquals('Closure', $job->getName()); + } + + public function testAddJobWithName() + { + // Arrange + $registry = new ScheduleJobRegistry($this->app); + + // Act + $job = $registry->add(function() {}, 'MyCustomJob'); + + // Assert + $this->assertInstanceOf(Job::class, $job); + $this->assertCount(1, $registry->getJobs()); + $this->assertEquals('MyCustomJob', $job->getName()); + } + + public function testAddMultipleJobs() + { + // Arrange + $registry = new ScheduleJobRegistry($this->app); + + // Act + $job1 = $registry->add(function() {}, 'Job1'); + $job2 = $registry->add(function() {}, 'Job2'); + $job3 = $registry->add(TestTask::class, 'Job3'); + + // Assert + $jobs = $registry->getJobs(); + $this->assertCount(3, $jobs); + $this->assertEquals('Job1', $jobs[0]->getName()); + $this->assertEquals('Job2', $jobs[1]->getName()); + $this->assertEquals('Job3', $jobs[2]->getName()); + } + + public function testGetJobsReturnsArray() + { + // Arrange + $registry = new ScheduleJobRegistry($this->app); + $registry->add(function() {}, 'TestJob'); + + // Act + $jobs = $registry->getJobs(); + + // Assert + $this->assertIsArray($jobs); + $this->assertCount(1, $jobs); + $this->assertInstanceOf(Job::class, $jobs[0]); + } + + public function testJobSchedulingMethods() + { + // Arrange + $registry = new ScheduleJobRegistry($this->app); + + // Act + $job = $registry->add(function() {}, 'TestJob') + ->everyFiveMinutes(); + + // Assert + $this->assertEquals('*/5 * * * *', $job->getExpression()); + // Just check that isDue() returns boolean, don't check the exact value + // as it depends on current time when test runs + $this->assertIsBool($job->isDue()); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerResultTest.php b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerResultTest.php new file mode 100644 index 0000000..acb104f --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerResultTest.php @@ -0,0 +1,100 @@ +assertEmpty($result->executed); + $this->assertEmpty($result->failed); + $this->assertEmpty($result->skipped); + } + + public function testAddExecuted() + { + // Arrange + $result = new SchedulerResult(); + + // Act + $result->addExecuted('TestJob', 1.5); + + // Assert + $this->assertCount(1, $result->executed); + $this->assertEquals('TestJob', $result->executed[0]['name']); + $this->assertEquals(1.5, $result->executed[0]['duration']); + } + + public function testAddFailed() + { + // Arrange + $result = new SchedulerResult(); + + // Act + $result->addFailed('TestJob', 'Database connection failed'); + + // Assert + $this->assertCount(1, $result->failed); + $this->assertEquals('TestJob', $result->failed[0]['name']); + $this->assertEquals('Database connection failed', $result->failed[0]['error']); + } + + public function testAddSkipped() + { + // Arrange + $result = new SchedulerResult(); + + // Act + $result->addSkipped('TestJob', 'Not due yet'); + + // Assert + $this->assertCount(1, $result->skipped); + $this->assertEquals('TestJob', $result->skipped[0]['name']); + $this->assertEquals('Not due yet', $result->skipped[0]['reason']); + } + + public function testMultipleOperations() + { + // Arrange + $result = new SchedulerResult(); + + // Act + $result->addExecuted('Job1', 0.5); + $result->addExecuted('Job2', 1.2); + $result->addFailed('Job3', 'Timeout'); + $result->addSkipped('Job4', 'Already running'); + $result->addSkipped('Job5', 'Not due'); + + // Assert + $this->assertCount(2, $result->executed); + $this->assertCount(1, $result->failed); + $this->assertCount(2, $result->skipped); + } + + public function testToArray() + { + // Arrange + $result = new SchedulerResult(); + $result->addExecuted('Job1', 1.0); + $result->addFailed('Job2', 'Error'); + $result->addSkipped('Job3', 'Skipped'); + + // Act + $array = $result->toArray(); + + // Assert + $expected = [ + 'executed' => [['name' => 'Job1', 'duration' => 1.0]], + 'failed' => [['name' => 'Job2', 'error' => 'Error']], + 'skipped' => [['name' => 'Job3', 'reason' => 'Skipped']], + ]; + $this->assertEquals($expected, $array); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerServiceTest.php b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerServiceTest.php new file mode 100644 index 0000000..9f7fc07 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Unit/Framework/Scheduler/SchedulerServiceTest.php @@ -0,0 +1,379 @@ +cacheMock = Mockery::mock(CacheInterface::class); + $this->settingsMock = Mockery::mock(SettingsInterface::class); + $this->loggerMock = Mockery::mock(LoggerInterface::class); + + $this->scheduler = new SchedulerService( + $this->loggerMock, + $this->cacheMock, + $this->app, + $this->settingsMock + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testDisabledModeSkipsExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('disabled'); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertEquals([['name' => 'Global', 'reason' => 'Scheduler is disabled']], $result->skipped); + $this->assertEmpty($result->executed); + $this->assertEmpty($result->failed); + } + + public function testGlobalLockPreventsExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn('1'); // Lock is active + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertEquals([['name' => 'Global', 'reason' => 'Global scheduler lock active']], $result->skipped); + $this->assertEmpty($result->executed); + $this->assertEmpty($result->failed); + } + + public function testJobLockPreventsExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn(null); // No global lock + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_lock', 1, 300) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.global_lock') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_last_run', Mockery::type('int')) + ->once(); + + // Mock registry and job + $registryMock = Mockery::mock(ScheduleJobRegistry::class); + $jobMock = Mockery::mock(Job::class); + + $jobMock->shouldReceive('getName')->andReturn('TestJob'); + $jobMock->shouldReceive('getId')->andReturn('test_job_id'); + $jobMock->shouldReceive('isDue')->andReturn(true); + + // Job has not run recently (getLastRun returns null) + $this->cacheMock->shouldReceive('get') + ->with('scheduler.last_run.test_job_id') + ->andReturn(null); + + // Job is locked + $this->cacheMock->shouldReceive('get') + ->with('scheduler.lock.test_job_id') + ->andReturn('1'); + + $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); + + // Logger should not be called for this test + $this->loggerMock->shouldReceive('error')->never(); + + // Inject registry for testing + $this->scheduler->setRegistry($registryMock); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertEquals([['name' => 'TestJob', 'reason' => 'Job is locked (running)']], $result->skipped); + } + + public function testJobRecentlyExecutedPreventsExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn(null); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_lock', 1, 300) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.global_lock') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_last_run', Mockery::type('int')) + ->once(); + + // Mock registry and job + $registryMock = Mockery::mock(ScheduleJobRegistry::class); + $jobMock = Mockery::mock(Job::class); + + $jobMock->shouldReceive('getName')->andReturn('TestJob'); + $jobMock->shouldReceive('getId')->andReturn('test_job_id'); + $jobMock->shouldReceive('isDue')->andReturn(true); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.lock.test_job_id') + ->andReturn(null); // Not locked + + // Job was recently executed (same minute) + $recentTime = time(); + $this->cacheMock->shouldReceive('get') + ->with('scheduler.last_run.test_job_id') + ->andReturn($recentTime); + + $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); + + // Inject registry for testing + $this->scheduler->setRegistry($registryMock); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertEquals([['name' => 'TestJob', 'reason' => 'Already ran recently']], $result->skipped); + } + + public function testJobNotDuePreventsExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn(null); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_lock', 1, 300) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.global_lock') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_last_run', Mockery::type('int')) + ->once(); + + // Mock registry and job + $registryMock = Mockery::mock(ScheduleJobRegistry::class); + $jobMock = Mockery::mock(Job::class); + + $jobMock->shouldReceive('getName')->andReturn('TestJob'); + $jobMock->shouldReceive('getId')->andReturn('test_job_id'); + $jobMock->shouldReceive('isDue')->andReturn(false); // Not due + + $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); + + // Inject registry for testing + $this->scheduler->setRegistry($registryMock); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertEquals([['name' => 'TestJob', 'reason' => 'Not due']], $result->skipped); + } + + public function testSuccessfulJobExecution() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn(null); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_lock', 1, 300) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.global_lock') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_last_run', Mockery::type('int')) + ->once(); + + // Mock registry and job + $registryMock = Mockery::mock(ScheduleJobRegistry::class); + $jobMock = Mockery::mock(Job::class); + + $jobMock->shouldReceive('getName')->andReturn('TestJob'); + $jobMock->shouldReceive('getId')->andReturn('test_job_id'); + $jobMock->shouldReceive('isDue')->andReturn(true); + $jobMock->shouldReceive('run')->once(); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.lock.test_job_id') + ->andReturn(null); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.last_run.test_job_id') + ->andReturn(null); // Never ran before + + // Lock and unlock operations + $this->cacheMock->shouldReceive('set') + ->with('scheduler.lock.test_job_id', 1, 1800) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.lock.test_job_id') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.last_run.test_job_id', Mockery::type('int')) + ->once(); + + $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); + + $this->loggerMock->shouldReceive('info') + ->with('Job executed: TestJob', Mockery::type('array')) + ->once(); + + // Inject registry for testing + $this->scheduler->setRegistry($registryMock); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertCount(1, $result->executed); + $this->assertEquals('TestJob', $result->executed[0]['name']); + $this->assertEmpty($result->failed); + $this->assertEmpty($result->skipped); + } + + public function testJobExecutionFailure() + { + // Arrange + $this->settingsMock->shouldReceive('get') + ->with('cron.mode', 'disabled') + ->andReturn('system'); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.global_lock') + ->andReturn(null); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_lock', 1, 300) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.global_lock') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.global_last_run', Mockery::type('int')) + ->once(); + + // Mock registry and job + $registryMock = Mockery::mock(ScheduleJobRegistry::class); + $jobMock = Mockery::mock(Job::class); + + $jobMock->shouldReceive('getName')->andReturn('TestJob'); + $jobMock->shouldReceive('getId')->andReturn('test_job_id'); + $jobMock->shouldReceive('isDue')->andReturn(true); + $jobMock->shouldReceive('run')->andThrow(new \Exception('Job failed')); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.lock.test_job_id') + ->andReturn(null); + + $this->cacheMock->shouldReceive('get') + ->with('scheduler.last_run.test_job_id') + ->andReturn(null); + + // Lock and unlock operations + $this->cacheMock->shouldReceive('set') + ->with('scheduler.lock.test_job_id', 1, 1800) + ->once(); + + $this->cacheMock->shouldReceive('delete') + ->with('scheduler.lock.test_job_id') + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.last_failure.test_job_id', Mockery::type('int')) + ->once(); + + $this->cacheMock->shouldReceive('set') + ->with('scheduler.last_failure_msg.test_job_id', 'Job failed') + ->once(); + + $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); + + $this->loggerMock->shouldReceive('error') + ->with('Job failed: TestJob', Mockery::type('array')) + ->once(); + + // Inject registry for testing + $this->scheduler->setRegistry($registryMock); + + // Act + $result = $this->scheduler->run(); + + // Assert + $this->assertCount(1, $result->failed); + $this->assertEquals('TestJob', $result->failed[0]['name']); + $this->assertEquals('Job failed', $result->failed[0]['error']); + $this->assertEmpty($result->executed); + $this->assertEmpty($result->skipped); + } +}