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(); }); it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->for($tenant)->create(); $tier1CreateCalled = false; $tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel { public function __construct(public bool &$createCalled) {} public function createProject(SupplierProjectDto $dto): int { $this->createCalled = true; return 0; } public function updateProject(int $externalId, SupplierProjectDto $dto): void {} public function listProjects(): array { return [ ['id' => 555555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'], ]; } }; $id = makeFailover($tier1)->createProjectForLiderra($project, makeDto()); expect($id)->toBe(555555); expect($tier1CreateCalled)->toBeFalse(); });