217 lines
8.3 KiB
PHP
217 lines
8.3 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use App\Jobs\RouteSupplierLeadJob;
|
|||
|
|
use App\Models\SupplierLead;
|
|||
|
|
use App\Models\Tenant;
|
|||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|||
|
|
use Illuminate\Support\Facades\Bus;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Plan 3 Task 8: RetryFailedSupplierJobsCommand.
|
|||
|
|
*
|
|||
|
|
* Schema adaptation (db/schema.sql v8.11 failed_webhook_jobs):
|
|||
|
|
* - НЕТ supplier_lead_id колонки → марка supplier-flow rows:
|
|||
|
|
* tenant_id IS NULL AND raw_payload->>'supplier_lead_id' IS NOT NULL
|
|||
|
|
* (см. RouteSupplierLeadJob::failed() — он вставляет именно так).
|
|||
|
|
* - НЕТ retry_attempts/last_retried_at → используем existing колонки:
|
|||
|
|
* - retry_count (INT) — счётчик оставшихся попыток (decrement при каждом retry).
|
|||
|
|
* - retried_at (TIMESTAMPTZ) — last retry timestamp (cooldown 1h).
|
|||
|
|
* - resolved_at (TIMESTAMPTZ) — терминальное состояние (исключает retry).
|
|||
|
|
* - failed_at (TIMESTAMPTZ) — window 24h (старше — skip).
|
|||
|
|
*
|
|||
|
|
* Semantics retry_count: при создании row в failed_webhook_jobs RouteSupplierLeadJob
|
|||
|
|
* сетит retry_count = $tries = 3 (max попыток queue-уровня). Command интерпретирует
|
|||
|
|
* это значение как "оставшиеся manual retries"; при каждом retry decrement; при
|
|||
|
|
* достижении 0 — set resolved_at=NOW() со статусом "exhausted" (через JSON marker
|
|||
|
|
* в raw_payload).
|
|||
|
|
*/
|
|||
|
|
uses(DatabaseTransactions::class);
|
|||
|
|
uses(SharesSupplierPdo::class);
|
|||
|
|
|
|||
|
|
beforeEach(function (): void {
|
|||
|
|
Bus::fake();
|
|||
|
|
|
|||
|
|
// Очищаем persistent garbage из failed_webhook_jobs (рудимент старых
|
|||
|
|
// test-сессий, когда pgsql_supplier не был частью DatabaseTransactions).
|
|||
|
|
// Этот DELETE сам выполняется в текущей pgsql-транзакции через shared PDO
|
|||
|
|
// (SharesSupplierPdo trait) и откатится по завершении теста — реальные
|
|||
|
|
// production-данные не страдают.
|
|||
|
|
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->delete();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Helper: вставка supplier-marked failed_webhook_jobs row.
|
|||
|
|
*
|
|||
|
|
* @param array<string, mixed> $overrides
|
|||
|
|
*/
|
|||
|
|
function insertFailedSupplierRow(array $overrides = []): int
|
|||
|
|
{
|
|||
|
|
$supplierLead = SupplierLead::factory()->create([
|
|||
|
|
'processed_at' => null,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$payload = [
|
|||
|
|
'supplier_lead_id' => $supplierLead->id,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
$defaults = [
|
|||
|
|
'tenant_id' => null,
|
|||
|
|
'webhook_log_id' => null,
|
|||
|
|
'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
|||
|
|
'exception' => 'Test failure',
|
|||
|
|
'retry_count' => 3,
|
|||
|
|
'failed_at' => now()->subMinutes(30),
|
|||
|
|
'retried_at' => null,
|
|||
|
|
'resolved_at' => null,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
$row = array_merge($defaults, $overrides);
|
|||
|
|
|
|||
|
|
return (int) DB::connection('pgsql_supplier')
|
|||
|
|
->table('failed_webhook_jobs')
|
|||
|
|
->insertGetId($row);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test('dispatches RouteSupplierLeadJob for each eligible supplier-flow row', function (): void {
|
|||
|
|
$id1 = insertFailedSupplierRow();
|
|||
|
|
$id2 = insertFailedSupplierRow();
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
// suppress unused warning — IDs needed for fresh() verification below.
|
|||
|
|
expect($id1)->toBeGreaterThan(0)->and($id2)->toBeGreaterThan(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 2);
|
|||
|
|
|
|||
|
|
// Both rows должны иметь обновлённый retried_at и decremented retry_count.
|
|||
|
|
foreach ([$id1, $id2] as $id) {
|
|||
|
|
$row = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($id);
|
|||
|
|
expect($row->retried_at)->not->toBeNull();
|
|||
|
|
expect((int) $row->retry_count)->toBe(2);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('skips rows recently retried within cooldown (<1h)', function (): void {
|
|||
|
|
$recentlyRetried = insertFailedSupplierRow([
|
|||
|
|
'retried_at' => now()->subMinutes(30), // < 1h ago
|
|||
|
|
]);
|
|||
|
|
$eligible = insertFailedSupplierRow([
|
|||
|
|
'retried_at' => now()->subHours(2), // > 1h ago
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
|
|||
|
|
|
|||
|
|
// Recently retried row — не тронут.
|
|||
|
|
$skipped = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($recentlyRetried);
|
|||
|
|
expect((int) $skipped->retry_count)->toBe(3); // не decremented
|
|||
|
|
|
|||
|
|
// Eligible row — retried.
|
|||
|
|
$processed = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($eligible);
|
|||
|
|
expect((int) $processed->retry_count)->toBe(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('decrements retry_count and updates retried_at on dispatched row', function (): void {
|
|||
|
|
$id = insertFailedSupplierRow(['retry_count' => 3]);
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
$row = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($id);
|
|||
|
|
expect((int) $row->retry_count)->toBe(2);
|
|||
|
|
expect($row->retried_at)->not->toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('marks resolved_at when retry_count reaches 0 (max attempts exhausted)', function (): void {
|
|||
|
|
// retry_count=1 → после dispatch'а станет 0 → set resolved_at=NOW().
|
|||
|
|
$id = insertFailedSupplierRow(['retry_count' => 1]);
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
|
|||
|
|
|
|||
|
|
$row = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($id);
|
|||
|
|
expect((int) $row->retry_count)->toBe(0);
|
|||
|
|
expect($row->resolved_at)->not->toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('skips rows older than 24h (window safety cap)', function (): void {
|
|||
|
|
$tooOld = insertFailedSupplierRow([
|
|||
|
|
'failed_at' => now()->subDays(2),
|
|||
|
|
]);
|
|||
|
|
$fresh = insertFailedSupplierRow([
|
|||
|
|
'failed_at' => now()->subHours(12),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
|
|||
|
|
|
|||
|
|
$skipped = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($tooOld);
|
|||
|
|
expect($skipped->retried_at)->toBeNull(); // не тронут
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('skips rows already resolved', function (): void {
|
|||
|
|
$resolved = insertFailedSupplierRow([
|
|||
|
|
'resolved_at' => now()->subHours(1),
|
|||
|
|
]);
|
|||
|
|
$unresolved = insertFailedSupplierRow();
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
|
|||
|
|
|
|||
|
|
$skipped = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($resolved);
|
|||
|
|
expect((int) $skipped->retry_count)->toBe(3); // не тронут
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('skips non-supplier rows (tenant_id IS NOT NULL OR missing supplier_lead_id)', function (): void {
|
|||
|
|
// Обычный tenant-bound failed webhook (НЕ supplier-flow).
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
$tenantBoundId = DB::connection('pgsql_supplier')
|
|||
|
|
->table('failed_webhook_jobs')
|
|||
|
|
->insertGetId([
|
|||
|
|
'tenant_id' => $tenant->id,
|
|||
|
|
'webhook_log_id' => null,
|
|||
|
|
'raw_payload' => json_encode(['foo' => 'bar'], JSON_UNESCAPED_UNICODE),
|
|||
|
|
'exception' => 'Other failure',
|
|||
|
|
'retry_count' => 3,
|
|||
|
|
'failed_at' => now()->subMinutes(30),
|
|||
|
|
'retried_at' => null,
|
|||
|
|
'resolved_at' => null,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Supplier-flow row (tenant_id NULL, supplier_lead_id present).
|
|||
|
|
$supplierId = insertFailedSupplierRow();
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
|
|||
|
|
|
|||
|
|
$skipped = DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->find($tenantBoundId);
|
|||
|
|
expect($skipped->retried_at)->toBeNull();
|
|||
|
|
expect((int) $skipped->retry_count)->toBe(3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('reports dispatched count via output and Log', function (): void {
|
|||
|
|
insertFailedSupplierRow();
|
|||
|
|
insertFailedSupplierRow();
|
|||
|
|
insertFailedSupplierRow();
|
|||
|
|
|
|||
|
|
$this->artisan('supplier:retry-failed')
|
|||
|
|
->expectsOutputToContain('Re-dispatched 3')
|
|||
|
|
->assertExitCode(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('handles empty queue gracefully (0 eligible rows)', function (): void {
|
|||
|
|
// No rows inserted.
|
|||
|
|
$this->artisan('supplier:retry-failed')
|
|||
|
|
->expectsOutputToContain('Re-dispatched 0')
|
|||
|
|
->assertExitCode(0);
|
|||
|
|
|
|||
|
|
Bus::assertNotDispatched(RouteSupplierLeadJob::class);
|
|||
|
|
});
|