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:
2026-02-27 13:29:11 +01:00
parent 97d7473753
commit 52119a0724
19 changed files with 1723 additions and 417 deletions

View 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']);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
}