Files
shopPRO/tests/Unit/Domain/CronJob/CronJobRepositoryTest.php
Jacek d3e3724cfb fix: Apilo email z danymi zamówienia + infinite retry co 30 min dla order jobów
- 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>
2026-03-19 11:23:02 +01:00

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