Files
portal/app/tests/Feature/Supplier/SupplierWebhookFastFailTest.php
T

179 lines
6.6 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 2 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md
*
* Tests the fast-fail guard in RouteSupplierLeadJob::handle():
* if supplier_lead.error contains a terminal pattern ('does not support',
* 'platform mismatch', 'no matching supplier_project') and processed_at IS NULL,
* the job marks processed and exits without writing to failed_webhook_jobs.
*
* Correction 1/2: uses RouteSupplierLeadJob (not ProcessSupplierWebhookJob).
* Correction 3: fast-fail inserted between the 2 existing idempotency guards
* and parseProjectField call.
*/
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
// ---------- helpers --------------------------------------------------------
function dispatchHandleSync(int $leadId): void
{
$job = new RouteSupplierLeadJob($leadId);
$job->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');
});