1
0
Files
breadcrumb-the-shire/tests/Service/Scheduler/ScheduledJobServiceTest.php
fs 312d43d9d9 feat(scheduler): delete orphaned jobs and block editing of unregistered jobs
Adds deleteOrphanedJobs() to clean up stale job_keys during ensureSystemJobs, and prevents editing jobs that are no longer registered in the runtime registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:13:09 +02:00

251 lines
11 KiB
PHP

<?php
namespace MintyPHP\Tests\Service\Scheduler;
use MintyPHP\Repository\Scheduler\ScheduledJobRepository;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository;
use MintyPHP\Service\Scheduler\ScheduleCalculator;
use MintyPHP\Service\Scheduler\ScheduledJobRegistry;
use MintyPHP\Service\Scheduler\ScheduledJobService;
use PHPUnit\Framework\TestCase;
class ScheduledJobServiceTest extends TestCase
{
public function testEnsureSystemJobsCreatesModuleJobDefinition(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([
'addressbook.sync' => [
'label' => 'Addressbook Sync',
'description' => 'Syncs address book data',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
],
]);
$jobRepository->expects($this->once())
->method('findByKey')
->with('addressbook.sync')
->willReturn(null);
$jobRepository->expects($this->once())
->method('create')
->with($this->callback(static function (array $job): bool {
return ($job['job_key'] ?? null) === 'addressbook.sync'
&& ($job['label'] ?? null) === 'Addressbook Sync'
&& ($job['timezone'] ?? null) === 'UTC'
&& ($job['schedule_type'] ?? null) === 'daily';
}))
->willReturn(99);
$jobRepository->expects($this->never())->method('updateJobMeta');
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$service->ensureSystemJobs();
}
public function testEnsureSystemJobsUpdatesExistingModuleJobMetadata(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([
'addressbook.sync' => [
'label' => 'Addressbook Sync',
'description' => 'Syncs address book data',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
],
]);
$jobRepository->expects($this->once())
->method('findByKey')
->with('addressbook.sync')
->willReturn([
'id' => 12,
'job_key' => 'addressbook.sync',
'label' => 'Old Label',
'description' => 'Old description',
'enabled' => 1,
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => 1,
'schedule_time' => '03:00',
'schedule_weekdays_csv' => null,
'catchup_once' => 1,
'next_run_at' => '2026-01-01 03:00:00',
]);
$jobRepository->expects($this->once())
->method('updateJobMeta')
->with(12, $this->callback(static function (array $job): bool {
return ($job['label'] ?? null) === 'Addressbook Sync'
&& ($job['description'] ?? null) === 'Syncs address book data';
}))
->willReturn(true);
$jobRepository->expects($this->never())->method('create');
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$service->ensureSystemJobs();
}
public function testEnsureSystemJobsDisablesOrphanedJobs(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([
'system_audit_purge' => [
'label' => 'System audit log cleanup',
'description' => 'Purges system audit log entries',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily', 'weekly'],
],
]);
$jobRepository->method('findByKey')->willReturn(null);
$jobRepository->method('create')->willReturn(1);
$jobRepository->expects($this->once())
->method('deleteOrphanedJobs')
->with(['system_audit_purge'])
->willReturn(1);
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$service->ensureSystemJobs();
}
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([]);
$jobRepository->expects($this->once())->method('find')->with(10)->willReturn([
'id' => 10,
'job_key' => 'user_lifecycle_run',
'label' => 'User lifecycle run',
'description' => '',
'enabled' => 1,
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => 1,
'schedule_time' => '02:15',
'schedule_weekdays_csv' => null,
'catchup_once' => 1,
]);
$registry->expects($this->once())->method('get')->with('user_lifecycle_run')->willReturn([
'label' => 'User lifecycle run',
'description' => '',
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
]);
$jobRepository->expects($this->never())->method('updateJobMeta');
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$result = $service->updateFromAdmin(10, [
'enabled' => '1',
'timezone' => 'UTC',
'schedule_type' => 'weekly',
'schedule_interval' => '1',
'schedule_time' => '03:00',
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testUpdateFromAdminPersistsNormalizedValuesAndReturnsFreshJob(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->exactly(2))->method('definitions')->willReturn([]);
$jobRepository->expects($this->exactly(2))->method('find')->with(11)->willReturnOnConsecutiveCalls(
[
'id' => 11,
'job_key' => 'user_lifecycle_run',
'label' => 'User lifecycle run',
'description' => 'Runs lifecycle',
'enabled' => 1,
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => 1,
'schedule_time' => '02:15',
'schedule_weekdays_csv' => null,
'catchup_once' => 1,
],
[
'id' => 11,
'job_key' => 'user_lifecycle_run',
'label' => 'User lifecycle run',
'description' => 'Runs lifecycle',
'enabled' => 0,
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => 1,
'schedule_time' => '04:00',
'schedule_weekdays_csv' => null,
'catchup_once' => 0,
'next_run_at' => null,
]
);
$registry->expects($this->once())->method('get')->with('user_lifecycle_run')->willReturn([
'label' => 'User lifecycle run',
'description' => 'Runs lifecycle',
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
]);
$capturedUpdate = null;
$jobRepository->expects($this->once())
->method('updateJobMeta')
->with(11, $this->anything())
->willReturnCallback(function (int $id, array $data) use (&$capturedUpdate): bool {
$capturedUpdate = $data;
return true;
});
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$result = $service->updateFromAdmin(11, [
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => '1',
'schedule_time' => '04:00',
]);
$this->assertTrue($result['ok']);
$this->assertSame(11, (int) (($result['job'] ?? [])['id'] ?? 0));
$this->assertSame(0, (int) (($result['job'] ?? [])['enabled'] ?? 1));
$this->assertIsArray($capturedUpdate);
$this->assertSame(0, (int) ($capturedUpdate['enabled'] ?? -1));
$this->assertSame('UTC', (string) ($capturedUpdate['timezone'] ?? ''));
$this->assertSame('daily', (string) ($capturedUpdate['schedule_type'] ?? ''));
$this->assertSame(1, (int) ($capturedUpdate['schedule_interval'] ?? -1));
$this->assertSame('04:00', (string) ($capturedUpdate['schedule_time'] ?? ''));
$this->assertSame(0, (int) ($capturedUpdate['catchup_once'] ?? -1));
$this->assertNull($capturedUpdate['next_run_at'] ?? null);
}
}