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); } }