test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user