handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(NotificationService::class), app(LedgerService::class), app(LeadDistributor::class), app(RegionTagResolver::class), ); } function countFailedWebhookJobs(): int { return (int) DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->count(); } // ---------- setup ---------------------------------------------------------- beforeEach(function (): void { // Ensure pgsql_supplier sees the same transaction via shared PDO. DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->delete(); // Create one shared SupplierProject so all tests in this file share it — // avoids unique constraint violations from repeated factory calls. $this->sharedProject = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => 'fast-fail-test-'.uniqid(), ]); }); // ---------- tests ---------------------------------------------------------- it('fast-fails when supplier_lead has terminal "does not support" error and processed_at IS NULL', function (): void { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'platform' => 'B1', 'error' => 'B1 platform does not support SMS signals (supplier limitation: chk_supplier_projects_b1_not_for_sms)', 'processed_at' => null, ]); $beforeFails = countFailedWebhookJobs(); dispatchHandleSync($lead->id); $afterFails = countFailedWebhookJobs(); expect($afterFails)->toBe($beforeFails, 'fast-fail must not write to failed_webhook_jobs'); $fresh = $lead->fresh(); expect($fresh?->processed_at)->not->toBeNull('fast-fail must mark processed_at'); expect($fresh?->error)->toContain('[fast-failed by RouteSupplierLeadJob]'); }); it('fast-fails when error contains "platform mismatch"', function (): void { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'platform' => 'B2', 'error' => 'Routing failed: platform mismatch for this lead type', 'processed_at' => null, ]); $beforeFails = countFailedWebhookJobs(); dispatchHandleSync($lead->id); expect(countFailedWebhookJobs())->toBe($beforeFails); expect($lead->fresh()?->processed_at)->not->toBeNull(); }); it('fast-fails when error contains "no matching supplier_project"', function (): void { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'platform' => 'B3', 'error' => 'no matching supplier_project found for identifier ваши_деньги', 'processed_at' => null, ]); $beforeFails = countFailedWebhookJobs(); dispatchHandleSync($lead->id); expect(countFailedWebhookJobs())->toBe($beforeFails); expect($lead->fresh()?->processed_at)->not->toBeNull(); }); it('does NOT fast-fail when lead error is null (normal new lead)', function (): void { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'platform' => 'B1', 'error' => null, 'processed_at' => null, ]); // Normal path will throw (no matching supplier_project in test env) — that's OK. // The important thing: no fast-fail terminal mark has been set on the lead. try { dispatchHandleSync($lead->id); } catch (Throwable) { // expected } $fresh = $lead->fresh(); $wasFastFailed = $fresh?->processed_at !== null && str_contains($fresh?->error ?? '', '[fast-failed by RouteSupplierLeadJob]'); expect($wasFastFailed)->toBeFalse('must not fast-fail a lead with no prior error'); }); it('does NOT fast-fail when lead already has processed_at set (idempotency guard fires first)', function (): void { $processedAt = now()->subMinutes(5); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'error' => 'B1 platform does not support SMS signals', 'processed_at' => $processedAt, ]); // Should return early due to processed_at guard, not the fast-fail guard. dispatchHandleSync($lead->id); // processed_at must remain unchanged (not overwritten by fast-fail) $fresh = $lead->fresh(); expect($fresh?->processed_at?->toDateTimeString()) ->toBe($processedAt->toDateTimeString(), 'processed_at must not change when already set'); // error must not get the fast-fail suffix expect($fresh?->error)->not->toContain('[fast-failed by RouteSupplierLeadJob]'); }); it('does NOT fast-fail for transient connection errors not matching terminal patterns', function (): void { $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $this->sharedProject->id, 'platform' => 'B1', 'error' => 'Connection refused to PostgreSQL at 127.0.0.1', 'processed_at' => null, ]); try { dispatchHandleSync($lead->id); } catch (Throwable) { // expected — transient errors may rethrow } $fresh = $lead->fresh(); $wasFastFailed = $fresh?->processed_at !== null && str_contains($fresh?->error ?? '', '[fast-failed by RouteSupplierLeadJob]'); expect($wasFastFailed)->toBeFalse('transient errors must not trigger fast-fail'); });