Add comprehensive unit tests for scheduler components

This commit is contained in:
2025-12-06 23:55:40 +03:00
parent 64f2fc5364
commit 10c1dfa5a3
5 changed files with 802 additions and 6 deletions

View File

@@ -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) {

View File

@@ -0,0 +1,210 @@
<?php
namespace Tests\Unit\Framework\Scheduler;
use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\TaskInterface;
use Tests\TestCase;
class JobTest extends TestCase
{
public function testJobCreation()
{
// Arrange
$container = $this->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;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\Framework\Scheduler;
use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Tests\TestCase;
class ScheduleJobRegistryTest extends TestCase
{
public function testRegistryCreation()
{
// Arrange & Act
$registry = new ScheduleJobRegistry($this->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());
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Unit\Framework\Scheduler;
use Openguru\OpenCartFramework\Scheduler\SchedulerResult;
use Tests\TestCase;
class SchedulerResultTest extends TestCase
{
public function testSchedulerResultInitialization()
{
// Arrange & Act
$result = new SchedulerResult();
// Assert
$this->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);
}
}

View File

@@ -0,0 +1,379 @@
<?php
namespace Tests\Unit\Framework\Scheduler;
use Mockery;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\SettingsInterface;
use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
class SchedulerServiceTest extends TestCase
{
private SchedulerService $scheduler;
private $cacheMock;
private $settingsMock;
private $loggerMock;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}