locks = new JobLockRepository($this->db); $this->runs = new JobRunRepository($this->db); $this->clock = FixedClock::at('2026-04-29T00:00:00Z'); $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $this->runner = new JobRunner($this->locks, $this->runs, $this->clock, $logger); } public function testSuccessfulRunWritesRowAndReleasesLock(): void { $job = new class () implements Job { public function name(): string { return 'demo'; } public function defaultIntervalSeconds(): int { return 60; } public function maxRuntimeSeconds(): int { return 30; } public function run(JobContext $context): JobResult { return new JobResult(itemsProcessed: 7); } }; $outcome = $this->runner->run($job, [], 'manual'); self::assertSame(JobStatus::Success, $outcome->status); self::assertSame(7, $outcome->itemsProcessed); // Lock is released — a second run must succeed. $second = $this->runner->run($job, [], 'manual'); self::assertSame(JobStatus::Success, $second->status); $rows = $this->db->fetchAllAssociative("SELECT status FROM job_runs WHERE job_name='demo'"); self::assertCount(2, $rows); foreach ($rows as $row) { self::assertSame('success', $row['status']); } } public function testFailedRunCapturesErrorMessageAndReleasesLock(): void { $job = new class () implements Job { public function name(): string { return 'failing'; } public function defaultIntervalSeconds(): int { return 60; } public function maxRuntimeSeconds(): int { return 30; } public function run(JobContext $context): JobResult { throw new RuntimeException('intentional'); } }; $outcome = $this->runner->run($job, [], 'manual'); self::assertSame(JobStatus::Failure, $outcome->status); self::assertSame('intentional', $outcome->errorMessage); // Lock released even on failure → next run also fails (not skipped). $second = $this->runner->run($job, [], 'manual'); self::assertSame(JobStatus::Failure, $second->status); } public function testLockContentionProducesOneSuccessAndOneSkipped(): void { // Pre-acquire the lock to simulate "another worker is already // running this job", then dispatch a fresh runner invocation. The // second one must come back as skipped_locked, not block. $now = $this->clock->now(); $this->locks->tryAcquire( 'busy', $now, $now->modify('+10 minutes'), 'OTHER-WORKER', ); $job = new class () implements Job { public bool $bodyExecuted = false; public function name(): string { return 'busy'; } public function defaultIntervalSeconds(): int { return 60; } public function maxRuntimeSeconds(): int { return 30; } public function run(JobContext $context): JobResult { $this->bodyExecuted = true; // @codeCoverageIgnore — must NOT be reached return new JobResult(0); } }; $outcome = $this->runner->run($job, [], 'api'); self::assertSame(JobStatus::SkippedLocked, $outcome->status); self::assertFalse($job->bodyExecuted, 'job body must not run when lock is held'); $rows = $this->db->fetchAllAssociative("SELECT status FROM job_runs WHERE job_name='busy'"); self::assertCount(1, $rows); self::assertSame('skipped_locked', $rows[0]['status']); } }