- Email notyfikacji zawiera numer zamówienia, klienta, datę, kwotę - Order joby (send_order, sync_payment, sync_status) ponawiane w nieskończoność co 30 min - Rozróżnienie PONAWIANY vs TRWAŁY BŁĄD w emailu - Cleanup stuck jobów po udanym wysłaniu zamówienia - +2 testy infinite retry w CronJobRepositoryTest Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
13 KiB
PHP
434 lines
13 KiB
PHP
<?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([
|
|
'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);
|
|
}
|
|
}
|