test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-19 16:13:27 +03:00
parent 4d331457ea
commit 0c3ff35485
3 changed files with 107 additions and 48 deletions
@@ -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<string, mixed> $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
{
+18 -48
View File
@@ -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
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', 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([]); // 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');
});