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