From 0c3ff3548587241f06c293f15ca8db4e52c836b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 16:13:27 +0300 Subject: [PATCH] test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass Co-Authored-By: Claude Sonnet 4.6 --- app/app/Models/SupplierManualSyncQueue.php | 13 ++++ app/phpstan-baseline.neon | 66 +++++----------- .../FailoverProjectChannelLiveSmokeTest.php | 76 +++++++++++++++++++ 3 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php diff --git a/app/app/Models/SupplierManualSyncQueue.php b/app/app/Models/SupplierManualSyncQueue.php index d5d971c3..ab1bafbe 100644 --- a/app/app/Models/SupplierManualSyncQueue.php +++ b/app/app/Models/SupplierManualSyncQueue.php @@ -7,11 +7,24 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * Очередь яруса 3 резерва канала миграции проектов. * * Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5 + * + * @property int $id + * @property int $project_id + * @property string $platform + * @property string $operation + * @property string|null $external_id + * @property array $payload_snapshot + * @property string $failure_reason + * @property string $status + * @property int|null $resolved_by_user_id + * @property Carbon|null $created_at + * @property Carbon|null $resolved_at */ class SupplierManualSyncQueue extends Model { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 0ca79ef1..643738f8 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -12,30 +12,6 @@ parameters: count: 1 path: app/Console/Commands/RemindersDispatchDue.php - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$payload_snapshot\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminSupplierIntegrationController.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$platform\.$#' - identifier: property.notFound - count: 3 - path: app/Http/Controllers/Api/AdminSupplierIntegrationController.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$project_id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminSupplierIntegrationController.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/AdminSupplierIntegrationController.php - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$tariff_name\.$#' identifier: property.notFound @@ -372,24 +348,6 @@ parameters: count: 3 path: tests/Feature/Admin/AdminSuppliersControllerTest.php - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$external_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Admin/SupplierManualQueueTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$resolved_by_user_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Admin/SupplierManualQueueTest.php - - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$status\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/Admin/SupplierManualQueueTest.php - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound @@ -918,12 +876,6 @@ parameters: count: 9 path: tests/Feature/DashboardSummaryTest.php - - - message: '#^Access to an undefined property App\\Models\\SupplierManualSyncQueue\:\:\$payload_snapshot\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Database/SupplierManualSyncQueueSchemaTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -1842,6 +1794,24 @@ parameters: count: 1 path: tests/Feature/Supplier/CsvReconcileJobTest.php + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php + + - + message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#' + identifier: argument.type + count: 2 + path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php + + - + message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#' + identifier: argument.type + count: 2 + path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound diff --git a/app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php b/app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php new file mode 100644 index 00000000..ba830823 --- /dev/null +++ b/app/tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php @@ -0,0 +1,76 @@ + 'ops@liderra.local']); + + $tenant = Tenant::factory()->create(); + $project = Project::factory()->for($tenant)->create(); + + $tier1 = mock(SupplierProjectChannel::class); + $tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка: нет совпадений + $tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail')); + + $tier2 = mock(SupplierProjectChannel::class); + $tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break')); + + $channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class)); + + $dto = new SupplierProjectDto( + platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example', + limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active', + ); + + expect(fn () => $channel->createProjectForLiderra($project, $dto)) + ->toThrow(TierEscalatedException::class); + + expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1); + + Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required'); +}); + +test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void { + Mail::fake(); + config(['services.supplier.alert_email' => 'ops@liderra.local']); + + $tenant = Tenant::factory()->create(); + $project = Project::factory()->for($tenant)->create(); + + $tier1 = mock(SupplierProjectChannel::class); + $tier1->shouldReceive('listProjects')->andReturn([]); + $tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused')); + + $tier2 = mock(SupplierProjectChannel::class); + $tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2 + + $channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class)); + + $dto = new SupplierProjectDto( + platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example', + limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active', + ); + + expect(fn () => $channel->createProjectForLiderra($project, $dto)) + ->toThrow(TierEscalatedException::class); + + $row = SupplierManualSyncQueue::where('project_id', $project->id)->first(); + expect($row->failure_reason)->toBe('portal_unreachable'); +});