feat: database-backed cron job queue replacing JSON file system
Replace file-based JSON cron queue with DB-backed job queue (pp_cron_jobs, pp_cron_schedules). New Domain\CronJob module: CronJobType (constants), CronJobRepository (CRUD, atomic fetch, retry/backoff), CronJobProcessor (orchestration with handler registration). Priority ordering guarantees apilo_send_order (40) runs before sync tasks (50). Includes cron.php auth protection, race condition fix in fetchNext, API response validation, and DI wiring across all entry points. 41 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
385
tests/Unit/Domain/CronJob/CronJobRepositoryTest.php
Normal file
385
tests/Unit/Domain/CronJob/CronJobRepositoryTest.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject|\medoo */
|
||||
private $mockDb;
|
||||
|
||||
/** @var CronJobRepository */
|
||||
private $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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([
|
||||
'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([
|
||||
'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([
|
||||
'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);
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user