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:
301
tests/Unit/Domain/CronJob/CronJobProcessorTest.php
Normal file
301
tests/Unit/Domain/CronJob/CronJobProcessorTest.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobProcessor;
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobProcessorTest extends TestCase
|
||||
{
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject|CronJobRepository */
|
||||
private $mockRepo;
|
||||
|
||||
/** @var CronJobProcessor */
|
||||
private $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mockRepo = $this->createMock(CronJobRepository::class);
|
||||
$this->processor = new CronJobProcessor($this->mockRepo);
|
||||
}
|
||||
|
||||
// --- registerHandler ---
|
||||
|
||||
public function testRegisterHandlerAndProcessJob(): void
|
||||
{
|
||||
$handlerCalled = false;
|
||||
|
||||
$this->processor->registerHandler('test_job', function ($payload) use (&$handlerCalled) {
|
||||
$handlerCalled = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 1, 'job_type' => 'test_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markCompleted')->with(1, null);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertTrue($handlerCalled);
|
||||
$this->assertSame(1, $stats['processed']);
|
||||
$this->assertSame(1, $stats['succeeded']);
|
||||
$this->assertSame(0, $stats['failed']);
|
||||
}
|
||||
|
||||
// --- processQueue ---
|
||||
|
||||
public function testProcessQueueReturnsEmptyStatsWhenNoJobs(): void
|
||||
{
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
|
||||
$stats = $this->processor->processQueue(5);
|
||||
|
||||
$this->assertSame(0, $stats['processed']);
|
||||
$this->assertSame(0, $stats['succeeded']);
|
||||
$this->assertSame(0, $stats['failed']);
|
||||
$this->assertSame(0, $stats['skipped']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerReturnsFalse(): void
|
||||
{
|
||||
$this->processor->registerHandler('fail_job', function ($payload) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 2, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(2, 'Handler returned false', 1);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
$this->assertSame(0, $stats['succeeded']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerThrowsException(): void
|
||||
{
|
||||
$this->processor->registerHandler('error_job', function ($payload) {
|
||||
throw new \RuntimeException('Connection failed');
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 3, 'job_type' => 'error_job', 'payload' => null, 'attempts' => 2],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(3, 'Connection failed', 2);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
}
|
||||
|
||||
public function testProcessQueueNoHandlerRegistered(): void
|
||||
{
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 4, 'job_type' => 'unknown_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(4, $this->stringContains('No handler registered'), 1);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['skipped']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerReturnsArray(): void
|
||||
{
|
||||
$resultData = ['synced' => true, 'items' => 5];
|
||||
|
||||
$this->processor->registerHandler('array_job', function ($payload) use ($resultData) {
|
||||
return $resultData;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 5, 'job_type' => 'array_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markCompleted')
|
||||
->with(5, $resultData);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['succeeded']);
|
||||
}
|
||||
|
||||
public function testProcessQueuePassesPayloadToHandler(): void
|
||||
{
|
||||
$receivedPayload = null;
|
||||
|
||||
$this->processor->registerHandler('payload_job', function ($payload) use (&$receivedPayload) {
|
||||
$receivedPayload = $payload;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 6, 'job_type' => 'payload_job', 'payload' => ['order_id' => 42], 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('markCompleted');
|
||||
|
||||
$this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(['order_id' => 42], $receivedPayload);
|
||||
}
|
||||
|
||||
public function testProcessQueueMultipleJobs(): void
|
||||
{
|
||||
$this->processor->registerHandler('ok_job', function ($payload) {
|
||||
return true;
|
||||
});
|
||||
$this->processor->registerHandler('fail_job', function ($payload) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 10, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
|
||||
['id' => 11, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
|
||||
['id' => 12, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$stats = $this->processor->processQueue(10);
|
||||
|
||||
$this->assertSame(3, $stats['processed']);
|
||||
$this->assertSame(2, $stats['succeeded']);
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
}
|
||||
|
||||
// --- createScheduledJobs ---
|
||||
|
||||
public function testCreateScheduledJobsFromDueSchedules(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 1,
|
||||
'job_type' => 'price_history',
|
||||
'interval_seconds' => 86400,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('enqueue')
|
||||
->with('price_history', null, 100, 3);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('touchSchedule')
|
||||
->with(1, 86400);
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(1, $created);
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsSkipsDuplicates(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 2,
|
||||
'job_type' => 'apilo_send_order',
|
||||
'interval_seconds' => 60,
|
||||
'priority' => 50,
|
||||
'max_attempts' => 10,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(true);
|
||||
|
||||
$this->mockRepo->expects($this->never())->method('enqueue');
|
||||
// touchSchedule still called to prevent re-checking
|
||||
$this->mockRepo->expects($this->once())->method('touchSchedule');
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(0, $created);
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsWithPayload(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 3,
|
||||
'job_type' => 'custom_job',
|
||||
'interval_seconds' => 600,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => '{"key":"value"}',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('enqueue')
|
||||
->with('custom_job', ['key' => 'value'], 100, 3);
|
||||
|
||||
$this->processor->createScheduledJobs();
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsReturnsZeroWhenNoSchedules(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([]);
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(0, $created);
|
||||
}
|
||||
|
||||
// --- run ---
|
||||
|
||||
public function testRunExecutesFullPipeline(): void
|
||||
{
|
||||
$this->mockRepo->expects($this->once())->method('recoverStuck')->with(30);
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([]);
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
$this->mockRepo->expects($this->once())->method('cleanup')->with(30);
|
||||
|
||||
$stats = $this->processor->run(20);
|
||||
|
||||
$this->assertArrayHasKey('scheduled', $stats);
|
||||
$this->assertArrayHasKey('processed', $stats);
|
||||
$this->assertArrayHasKey('succeeded', $stats);
|
||||
$this->assertArrayHasKey('failed', $stats);
|
||||
$this->assertArrayHasKey('skipped', $stats);
|
||||
}
|
||||
|
||||
public function testRunReturnsScheduledCount(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 1,
|
||||
'job_type' => 'job_a',
|
||||
'interval_seconds' => 60,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'job_type' => 'job_b',
|
||||
'interval_seconds' => 120,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
|
||||
$stats = $this->processor->run(20);
|
||||
|
||||
$this->assertSame(2, $stats['scheduled']);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
97
tests/Unit/Domain/CronJob/CronJobTypeTest.php
Normal file
97
tests/Unit/Domain/CronJob/CronJobTypeTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobTypeTest extends TestCase
|
||||
{
|
||||
public function testAllTypesReturnsAllJobTypes(): void
|
||||
{
|
||||
$types = CronJobType::allTypes();
|
||||
|
||||
$this->assertContains('apilo_token_keepalive', $types);
|
||||
$this->assertContains('apilo_send_order', $types);
|
||||
$this->assertContains('apilo_sync_payment', $types);
|
||||
$this->assertContains('apilo_sync_status', $types);
|
||||
$this->assertContains('apilo_product_sync', $types);
|
||||
$this->assertContains('apilo_pricelist_sync', $types);
|
||||
$this->assertContains('apilo_status_poll', $types);
|
||||
$this->assertContains('price_history', $types);
|
||||
$this->assertContains('order_analysis', $types);
|
||||
$this->assertContains('trustmate_invitation', $types);
|
||||
$this->assertContains('google_xml_feed', $types);
|
||||
$this->assertCount(11, $types);
|
||||
}
|
||||
|
||||
public function testAllStatusesReturnsAllStatuses(): void
|
||||
{
|
||||
$statuses = CronJobType::allStatuses();
|
||||
|
||||
$this->assertContains('pending', $statuses);
|
||||
$this->assertContains('processing', $statuses);
|
||||
$this->assertContains('completed', $statuses);
|
||||
$this->assertContains('failed', $statuses);
|
||||
$this->assertContains('cancelled', $statuses);
|
||||
$this->assertCount(5, $statuses);
|
||||
}
|
||||
|
||||
public function testPriorityConstants(): void
|
||||
{
|
||||
$this->assertSame(10, CronJobType::PRIORITY_CRITICAL);
|
||||
$this->assertSame(40, CronJobType::PRIORITY_SEND_ORDER);
|
||||
$this->assertSame(50, CronJobType::PRIORITY_HIGH);
|
||||
$this->assertSame(100, CronJobType::PRIORITY_NORMAL);
|
||||
$this->assertSame(200, CronJobType::PRIORITY_LOW);
|
||||
|
||||
// Lower value = higher priority
|
||||
$this->assertLessThan(CronJobType::PRIORITY_SEND_ORDER, CronJobType::PRIORITY_CRITICAL);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_HIGH, CronJobType::PRIORITY_SEND_ORDER);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_NORMAL, CronJobType::PRIORITY_HIGH);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_LOW, CronJobType::PRIORITY_NORMAL);
|
||||
}
|
||||
|
||||
public function testCalculateBackoffExponential(): void
|
||||
{
|
||||
// Attempt 1: 60s
|
||||
$this->assertSame(60, CronJobType::calculateBackoff(1));
|
||||
// Attempt 2: 120s
|
||||
$this->assertSame(120, CronJobType::calculateBackoff(2));
|
||||
// Attempt 3: 240s
|
||||
$this->assertSame(240, CronJobType::calculateBackoff(3));
|
||||
// Attempt 4: 480s
|
||||
$this->assertSame(480, CronJobType::calculateBackoff(4));
|
||||
}
|
||||
|
||||
public function testCalculateBackoffCapsAtMax(): void
|
||||
{
|
||||
// Very high attempt should cap at MAX_BACKOFF_SECONDS (3600)
|
||||
$this->assertSame(3600, CronJobType::calculateBackoff(10));
|
||||
$this->assertSame(3600, CronJobType::calculateBackoff(20));
|
||||
}
|
||||
|
||||
public function testJobTypeConstantsMatchStrings(): void
|
||||
{
|
||||
$this->assertSame('apilo_token_keepalive', CronJobType::APILO_TOKEN_KEEPALIVE);
|
||||
$this->assertSame('apilo_send_order', CronJobType::APILO_SEND_ORDER);
|
||||
$this->assertSame('apilo_sync_payment', CronJobType::APILO_SYNC_PAYMENT);
|
||||
$this->assertSame('apilo_sync_status', CronJobType::APILO_SYNC_STATUS);
|
||||
$this->assertSame('apilo_product_sync', CronJobType::APILO_PRODUCT_SYNC);
|
||||
$this->assertSame('apilo_pricelist_sync', CronJobType::APILO_PRICELIST_SYNC);
|
||||
$this->assertSame('apilo_status_poll', CronJobType::APILO_STATUS_POLL);
|
||||
$this->assertSame('price_history', CronJobType::PRICE_HISTORY);
|
||||
$this->assertSame('order_analysis', CronJobType::ORDER_ANALYSIS);
|
||||
$this->assertSame('trustmate_invitation', CronJobType::TRUSTMATE_INVITATION);
|
||||
$this->assertSame('google_xml_feed', CronJobType::GOOGLE_XML_FEED);
|
||||
}
|
||||
|
||||
public function testStatusConstantsMatchStrings(): void
|
||||
{
|
||||
$this->assertSame('pending', CronJobType::STATUS_PENDING);
|
||||
$this->assertSame('processing', CronJobType::STATUS_PROCESSING);
|
||||
$this->assertSame('completed', CronJobType::STATUS_COMPLETED);
|
||||
$this->assertSame('failed', CronJobType::STATUS_FAILED);
|
||||
$this->assertSame('cancelled', CronJobType::STATUS_CANCELLED);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use Domain\Order\OrderRepository;
|
||||
use Domain\Product\ProductRepository;
|
||||
use Domain\Settings\SettingsRepository;
|
||||
use Domain\Transport\TransportRepository;
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
|
||||
class OrderAdminServiceTest extends TestCase
|
||||
{
|
||||
@@ -229,108 +231,14 @@ class OrderAdminServiceTest extends TestCase
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// processApiloSyncQueue — awaiting apilo_order_id
|
||||
// queueApiloSync — DB-based via CronJobRepository
|
||||
// =========================================================================
|
||||
|
||||
private function getQueuePath(): string
|
||||
public function testConstructorAcceptsCronJobRepo(): void
|
||||
{
|
||||
// Musi odpowiadać ścieżce w OrderAdminService::apiloSyncQueuePath()
|
||||
// dirname(autoload/Domain/Order/, 2) = autoload/
|
||||
return dirname(__DIR__, 4) . '/autoload/temp/apilo-sync-queue.json';
|
||||
}
|
||||
|
||||
private function writeQueue(array $queue): void
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function readQueue(): array
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
if (!file_exists($path)) return [];
|
||||
$content = file_get_contents($path);
|
||||
return $content ? json_decode($content, true) : [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testProcessApiloSyncQueueKeepsTaskWhenApiloOrderIdIsNull(): void
|
||||
{
|
||||
// Zamówienie bez apilo_order_id — task powinien zostać w kolejce
|
||||
$this->writeQueue([
|
||||
'42' => [
|
||||
'order_id' => 42,
|
||||
'payment' => 1,
|
||||
'status' => null,
|
||||
'attempts' => 0,
|
||||
'last_error' => 'awaiting_apilo_order',
|
||||
'updated_at' => '2026-01-01 00:00:00',
|
||||
],
|
||||
]);
|
||||
|
||||
$orderRepo = $this->createMock(OrderRepository::class);
|
||||
$orderRepo->method('findRawById')
|
||||
->with(42)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'apilo_order_id' => null,
|
||||
'paid' => 1,
|
||||
'summary' => '100.00',
|
||||
]);
|
||||
|
||||
$service = new OrderAdminService($orderRepo);
|
||||
$processed = $service->processApiloSyncQueue(10);
|
||||
|
||||
$this->assertSame(1, $processed);
|
||||
|
||||
$queue = $this->readQueue();
|
||||
$this->assertArrayHasKey('42', $queue);
|
||||
$this->assertSame('awaiting_apilo_order', $queue['42']['last_error']);
|
||||
$this->assertSame(1, $queue['42']['attempts']);
|
||||
}
|
||||
|
||||
public function testProcessApiloSyncQueueRemovesTaskAfterMaxAttempts(): void
|
||||
{
|
||||
// Task z 49 próbami — limit to 50, więc powinien zostać usunięty
|
||||
$this->writeQueue([
|
||||
'42' => [
|
||||
'order_id' => 42,
|
||||
'payment' => 1,
|
||||
'status' => null,
|
||||
'attempts' => 49,
|
||||
'last_error' => 'awaiting_apilo_order',
|
||||
'updated_at' => '2026-01-01 00:00:00',
|
||||
],
|
||||
]);
|
||||
|
||||
$orderRepo = $this->createMock(OrderRepository::class);
|
||||
$orderRepo->method('findRawById')
|
||||
->with(42)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'apilo_order_id' => null,
|
||||
'paid' => 1,
|
||||
'summary' => '100.00',
|
||||
]);
|
||||
|
||||
$service = new OrderAdminService($orderRepo);
|
||||
$processed = $service->processApiloSyncQueue(10);
|
||||
|
||||
$this->assertSame(1, $processed);
|
||||
|
||||
$queue = $this->readQueue();
|
||||
$this->assertArrayNotHasKey('42', $queue);
|
||||
$cronJobRepo = $this->createMock(CronJobRepository::class);
|
||||
$service = new OrderAdminService($orderRepo, null, null, null, $cronJobRepo);
|
||||
$this->assertInstanceOf(OrderAdminService::class, $service);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user