createMock(ScheduledJobService::class); $scheduledJobService->expects($this->once())->method('ensureSystemJobs'); $systemAuditService = $this->createMock(SystemAuditService::class); $systemAuditService->expects($this->once()) ->method('record') ->with( 'scheduler.run', 'failed', $this->callback(static function (array $context): bool { $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; return ($metadata['trigger_type'] ?? '') === 'manual' && ($metadata['run_status'] ?? '') === 'failed'; }) ); $service = new SchedulerRunService( $scheduledJobService, $this->createMock(ScheduledJobRepository::class), $this->createMock(ScheduledJobRunRepository::class), $this->createMock(SchedulerRuntimeRepository::class), $this->createMock(ScheduledJobRegistry::class), new ScheduleCalculator(), $this->createMock(DatabaseSessionRepository::class), $systemAuditService ); $result = $service->runJobNow(0, 0); $this->assertFalse($result['ok']); $this->assertSame('invalid_request', $result['error']); } public function testRunJobNowSkippedRunIsAuditedAsSuccessOutcome(): void { $scheduledJobService = $this->createMock(ScheduledJobService::class); $scheduledJobService->expects($this->once())->method('ensureSystemJobs'); $scheduledJobRepository = $this->createMock(ScheduledJobRepository::class); $scheduledJobRepository->expects($this->once()) ->method('find') ->with(7) ->willReturn([ 'id' => 7, 'job_key' => 'user_lifecycle_run', 'enabled' => 1, 'next_run_at' => gmdate('Y-m-d H:i:s'), 'catchup_once' => 1, ]); $scheduledJobRepository->expects($this->once()) ->method('markRunning') ->with(7, $this->isString()) ->willReturn(false); $scheduledJobRunRepository = $this->createMock(ScheduledJobRunRepository::class); $scheduledJobRunRepository->expects($this->once()) ->method('create') ->with($this->isArray()) ->willReturn(1); $databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class); $databaseSessionRepository->expects($this->once()) ->method('acquireAdvisoryLock') ->with('scheduled_jobs_runner', 0) ->willReturn(true); $databaseSessionRepository->expects($this->once()) ->method('releaseAdvisoryLock') ->with('scheduled_jobs_runner'); $records = []; $systemAuditService = $this->createMock(SystemAuditService::class); $systemAuditService->expects($this->exactly(2)) ->method('record') ->willReturnCallback(static function (string $eventType, string $outcome, array $context) use (&$records): int { $records[] = [ 'event_type' => $eventType, 'outcome' => $outcome, 'context' => $context, ]; return 1; }); $service = new SchedulerRunService( $scheduledJobService, $scheduledJobRepository, $scheduledJobRunRepository, $this->createMock(SchedulerRuntimeRepository::class), $this->createMock(ScheduledJobRegistry::class), new ScheduleCalculator(), $databaseSessionRepository, $systemAuditService ); $result = $service->runJobNow(7, 42); $this->assertTrue($result['ok']); $this->assertSame('skipped', $result['status']); $this->assertSame('job_already_running', $result['error']); $this->assertCount(2, $records); $jobRecord = null; $runRecord = null; foreach ($records as $record) { if ($record['event_type'] === 'scheduler.job.run') { $jobRecord = $record; } if ($record['event_type'] === 'scheduler.run') { $runRecord = $record; } } $this->assertNotNull($jobRecord); $this->assertNotNull($runRecord); $this->assertSame('success', $jobRecord['outcome']); $this->assertSame('success', $runRecord['outcome']); $jobMetadata = is_array($jobRecord['context']['metadata'] ?? null) ? $jobRecord['context']['metadata'] : []; $runMetadata = is_array($runRecord['context']['metadata'] ?? null) ? $runRecord['context']['metadata'] : []; $this->assertSame('manual', $jobMetadata['trigger_type'] ?? null); $this->assertSame('skipped', $jobMetadata['run_status'] ?? null); $this->assertSame('manual', $runMetadata['trigger_type'] ?? null); $this->assertSame('skipped', $runMetadata['run_status'] ?? null); } }