0e27844a28
Tier 1 → классификация исключения → ярус 2 (плейсхолдер) / ярус 3 (queue). Без портального dedup (см. Task 5). Без реального Tier 2 (см. Task 7). Матрица эскалации: - Tier 1 success → return id - WindowDeferredException → re-throw (операция переносится, без queue/alert) - SupplierTransientException → сразу Tier 3 (skip Tier 2 — хост недоступен) - SupplierClient/AuthException → Tier 2; success → failover_to_form alert; fail → Tier 3 queue + manual_required alert + TierEscalatedException - escalateToTier3 пишет supplier_manual_sync_queue + queue'ит критический alert. 6 тестов матрицы эскалации зелёные (17 assertions). Spec §4.4, §6, §8. Task 4 of 12. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
8.4 KiB
PHP
274 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Exceptions\Supplier\SupplierAuthException;
|
|
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\Exceptions\WindowDeferredException;
|
|
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\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
function makeDto(): SupplierProjectDto
|
|
{
|
|
return new SupplierProjectDto(
|
|
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
|
|
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
|
|
);
|
|
}
|
|
|
|
function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $tier2 = null): FailoverProjectChannel
|
|
{
|
|
return new FailoverProjectChannel(
|
|
$tier1,
|
|
$tier2 ?? new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new RuntimeException('tier2 not configured');
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
},
|
|
app(Mailer::class),
|
|
);
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
Mail::fake();
|
|
});
|
|
|
|
it('createProject — Tier 1 success: returns id, no queue, no alert', function (): void {
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
return 700123;
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
$id = makeFailover($tier1)->createProject(makeDto());
|
|
|
|
expect($id)->toBe(700123);
|
|
expect(SupplierManualSyncQueue::count())->toBe(0);
|
|
Mail::assertNothingQueued();
|
|
});
|
|
|
|
it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3 with portal_unreachable', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->for($tenant)->create(['signal_type' => 'site', 'signal_identifier' => 'foo.com']);
|
|
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new SupplierTransientException('5xx exhausted', httpStatus: 503);
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
$tier2Called = false;
|
|
$tier2 = new class($tier2Called) implements SupplierProjectChannel
|
|
{
|
|
public function __construct(public bool &$called) {}
|
|
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
$this->called = true;
|
|
|
|
return 0;
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
|
{
|
|
$this->called = true;
|
|
}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
$this->called = true;
|
|
|
|
return [];
|
|
}
|
|
};
|
|
|
|
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
|
|
->toThrow(TierEscalatedException::class);
|
|
|
|
expect($tier2Called)->toBeFalse();
|
|
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
|
|
Mail::assertQueued(SupplierCriticalAlertMail::class);
|
|
});
|
|
|
|
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->for($tenant)->create();
|
|
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new SupplierClientException('4xx contract break', httpStatus: 400);
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
$tier2 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
return 800001;
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
|
|
|
|
expect($id)->toBe(800001);
|
|
expect(SupplierManualSyncQueue::count())->toBe(0);
|
|
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
|
|
});
|
|
|
|
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->for($tenant)->create();
|
|
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new SupplierClientException('4xx', httpStatus: 400);
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
$tier2 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new RuntimeException('form_selector_break');
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
|
|
->toThrow(TierEscalatedException::class);
|
|
|
|
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
|
|
Mail::assertQueued(SupplierCriticalAlertMail::class);
|
|
});
|
|
|
|
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->for($tenant)->create();
|
|
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new SupplierAuthException('sticky 401', httpStatus: 401);
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
$tier2 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
return 900042;
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
|
|
|
|
expect($id)->toBe(900042);
|
|
});
|
|
|
|
it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (re-throws WindowDeferred)', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
$project = Project::factory()->for($tenant)->create();
|
|
|
|
$tier1 = new class implements SupplierProjectChannel
|
|
{
|
|
public function createProject(SupplierProjectDto $dto): int
|
|
{
|
|
throw new WindowDeferredException('portal returned 22:00-00:00 window-block');
|
|
}
|
|
|
|
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
|
|
|
public function listProjects(): array
|
|
{
|
|
return [];
|
|
}
|
|
};
|
|
|
|
expect(fn () => makeFailover($tier1)->createProjectForLiderra($project, makeDto()))
|
|
->toThrow(WindowDeferredException::class);
|
|
|
|
expect(SupplierManualSyncQueue::count())->toBe(0);
|
|
Mail::assertNothingQueued();
|
|
});
|