mockDb = $this->createMock(\medoo::class); $this->repo = new CronJobRepository($this->mockDb); } // --- enqueue --- public function testEnqueueInsertsJobAndReturnsId(): void { $this->mockDb->expects($this->once()) ->method('insert') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['job_type'] === 'apilo_send_order' && $data['status'] === 'pending' && $data['priority'] === 50 && $data['max_attempts'] === 10 && isset($data['scheduled_at']); }) ); $this->mockDb->method('id')->willReturn('42'); $id = $this->repo->enqueue('apilo_send_order', null, CronJobType::PRIORITY_HIGH); $this->assertSame(42, $id); } public function testEnqueueWithPayloadEncodesJson(): void { $payload = ['order_id' => 123, 'action' => 'sync']; $this->mockDb->expects($this->once()) ->method('insert') ->with( 'pp_cron_jobs', $this->callback(function ($data) use ($payload) { return $data['payload'] === json_encode($payload); }) ); $this->mockDb->method('id')->willReturn('1'); $this->repo->enqueue('apilo_sync_payment', $payload); } public function testEnqueueWithoutPayloadDoesNotSetPayloadKey(): void { $this->mockDb->expects($this->once()) ->method('insert') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return !array_key_exists('payload', $data); }) ); $this->mockDb->method('id')->willReturn('1'); $this->repo->enqueue('price_history'); } public function testEnqueueWithScheduledAt(): void { $scheduled = '2026-03-01 10:00:00'; $this->mockDb->expects($this->once()) ->method('insert') ->with( 'pp_cron_jobs', $this->callback(function ($data) use ($scheduled) { return $data['scheduled_at'] === $scheduled; }) ); $this->mockDb->method('id')->willReturn('1'); $this->repo->enqueue('price_history', null, CronJobType::PRIORITY_NORMAL, 10, $scheduled); } public function testEnqueueReturnsNullOnFailure(): void { $this->mockDb->method('insert'); $this->mockDb->method('id')->willReturn(null); $id = $this->repo->enqueue('test_job'); $this->assertNull($id); } // --- fetchNext --- public function testFetchNextReturnsEmptyArrayWhenNoJobs(): void { $this->mockDb->method('select')->willReturn([]); $result = $this->repo->fetchNext(5); $this->assertSame([], $result); } public function testFetchNextUpdatesStatusToProcessing(): void { $pendingJobs = [ ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => null], ['id' => 2, 'job_type' => 'test2', 'status' => 'pending', 'payload' => '{"x":1}'], ]; $claimedJobs = [ ['id' => 1, 'job_type' => 'test', 'status' => 'processing', 'payload' => null], ['id' => 2, 'job_type' => 'test2', 'status' => 'processing', 'payload' => '{"x":1}'], ]; $this->mockDb->method('select') ->willReturnOnConsecutiveCalls($pendingJobs, $claimedJobs); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'processing' && isset($data['started_at']); }), $this->callback(function ($where) { return $where['id'] === [1, 2] && $where['status'] === 'pending'; }) ); $result = $this->repo->fetchNext(5); $this->assertCount(2, $result); $this->assertSame('processing', $result[0]['status']); $this->assertSame('processing', $result[1]['status']); } public function testFetchNextDecodesPayloadJson(): void { $jobs = [ ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => '{"order_id":99}'], ]; $this->mockDb->method('select')->willReturn($jobs); $this->mockDb->method('update'); $result = $this->repo->fetchNext(1); $this->assertSame(['order_id' => 99], $result[0]['payload']); } // --- markCompleted --- public function testMarkCompletedUpdatesStatus(): void { $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'completed' && isset($data['completed_at']); }), ['id' => 5] ); $this->repo->markCompleted(5); } public function testMarkCompletedWithResult(): void { $result = ['synced' => true, 'count' => 3]; $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) use ($result) { return $data['result'] === json_encode($result); }), ['id' => 7] ); $this->repo->markCompleted(7, $result); } // --- markFailed --- public function testMarkFailedWithRetriesLeft(): void { // Job with attempts < max_attempts → reschedule with backoff $this->mockDb->method('get')->willReturn([ 'job_type' => 'price_history', 'max_attempts' => 10, 'attempts' => 2, ]); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'pending' && isset($data['scheduled_at']) && isset($data['last_error']); }), ['id' => 3] ); $this->repo->markFailed(3, 'Connection timeout', 2); } public function testMarkFailedWhenMaxAttemptsReached(): void { // Job with attempts >= max_attempts → permanent failure $this->mockDb->method('get')->willReturn([ 'job_type' => 'price_history', 'max_attempts' => 3, 'attempts' => 3, ]); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'failed' && isset($data['completed_at']); }), ['id' => 4] ); $this->repo->markFailed(4, 'Max retries exceeded'); } public function testMarkFailedTruncatesErrorTo500Chars(): void { $this->mockDb->method('get')->willReturn([ 'job_type' => 'price_history', 'max_attempts' => 10, 'attempts' => 1, ]); $longError = str_repeat('x', 600); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return mb_strlen($data['last_error']) <= 500; }), ['id' => 1] ); $this->repo->markFailed(1, $longError); } public function testMarkFailedApiloOrderJobNeverPermanentlyFails(): void { // apilo_send_order with max attempts reached → still pending (infinite retry) $this->mockDb->method('get')->willReturn([ 'job_type' => 'apilo_send_order', 'max_attempts' => 10, 'attempts' => 15, ]); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'pending' && isset($data['scheduled_at']) && isset($data['last_error']); }), ['id' => 5] ); $this->repo->markFailed(5, 'API timeout'); } public function testMarkFailedApiloSyncPaymentInfiniteRetry(): void { $this->mockDb->method('get')->willReturn([ 'job_type' => 'apilo_sync_payment', 'max_attempts' => 10, 'attempts' => 10, ]); $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'pending'; }), ['id' => 6] ); $this->repo->markFailed(6, 'Apilo unavailable'); } // --- hasPendingJob --- public function testHasPendingJobReturnsTrueWhenExists(): void { $this->mockDb->method('count') ->with('pp_cron_jobs', $this->callback(function ($where) { return $where['job_type'] === 'apilo_sync_payment' && $where['status'] === ['pending', 'processing']; })) ->willReturn(1); $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment')); } public function testHasPendingJobReturnsFalseWhenNone(): void { $this->mockDb->method('count')->willReturn(0); $this->assertFalse($this->repo->hasPendingJob('apilo_sync_payment')); } public function testHasPendingJobWithPayloadMatch(): void { $payload = ['order_id' => 42]; $this->mockDb->expects($this->once()) ->method('count') ->with('pp_cron_jobs', $this->callback(function ($where) use ($payload) { return $where['payload'] === json_encode($payload); })) ->willReturn(1); $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment', $payload)); } // --- cleanup --- public function testCleanupDeletesOldCompletedJobs(): void { $this->mockDb->expects($this->once()) ->method('delete') ->with( 'pp_cron_jobs', $this->callback(function ($where) { return $where['status'] === ['completed', 'failed', 'cancelled'] && isset($where['updated_at[<]']); }) ); $this->repo->cleanup(30); } // --- recoverStuck --- public function testRecoverStuckResetsProcessingJobs(): void { $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_jobs', $this->callback(function ($data) { return $data['status'] === 'pending' && $data['started_at'] === null; }), $this->callback(function ($where) { return $where['status'] === 'processing' && isset($where['started_at[<]']); }) ); $this->repo->recoverStuck(30); } // --- getDueSchedules --- public function testGetDueSchedulesReturnsEnabledSchedules(): void { $schedules = [ ['id' => 1, 'job_type' => 'price_history', 'interval_seconds' => 86400], ]; $this->mockDb->expects($this->once()) ->method('select') ->with( 'pp_cron_schedules', '*', $this->callback(function ($where) { return $where['enabled'] === 1 && isset($where['OR']); }) ) ->willReturn($schedules); $result = $this->repo->getDueSchedules(); $this->assertCount(1, $result); } // --- touchSchedule --- public function testTouchScheduleUpdatesTimestamps(): void { $this->mockDb->expects($this->once()) ->method('update') ->with( 'pp_cron_schedules', $this->callback(function ($data) { return isset($data['last_run_at']) && isset($data['next_run_at']); }), ['id' => 5] ); $this->repo->touchSchedule(5, 3600); } }