> */ public array $hangupPayloads = []; /** @var array> */ public array $createPayloads = []; /** @var array> */ public array $flashCreatePayloads = []; /** @var array|null */ public ?array $flashCreateResponse = null; /** * @param string[] $statusQueue */ public function __construct(mixed $workspaceId = 'workspace_1', mixed $channelId = 'channel_1', array $statusQueue = ['accepted']) { $this->statusQueue = $statusQueue; $this->workspaceIdValue = is_string($workspaceId) ? $workspaceId : ''; $this->channelIdValue = is_string($channelId) ? $channelId : ''; } protected function getConfiguredWorkspaceId(): string { return $this->workspaceIdValue; } protected function getConfiguredChannelId(): string { return $this->channelIdValue; } public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null { $this->createPayloads[] = [ 'workspaceId' => $workspaceId, 'channelId' => $channelId, 'payload' => $payload, ]; return ['id' => 'call_123']; } public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null { $status = array_shift($this->statusQueue); if (!is_string($status) || trim($status) === '') { $status = 'ringing'; } $this->statusesSeen[] = $status; return [ 'id' => $callId, 'status' => $status, ]; } public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null { $this->hangupCalls++; $this->hangupPayloads[] = $payload; return ['status' => 'completed']; } public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null { $this->flashCreatePayloads[] = [ 'workspaceId' => $workspaceId, 'channelId' => $channelId, 'payload' => $payload, ]; return $this->flashCreateResponse ?? [ 'id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288', ]; } public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null { $status = array_shift($this->flashStatusQueue); if (!is_string($status) || trim($status) === '') { $status = 'ringing'; } $this->flashStatusesSeen[] = $status; return [ 'id' => $callId, 'status' => $status, ]; } protected function waitForCallPollInterval(int $pollIntervalSeconds): void { // Avoid real sleeps in tests. } } class BirdGateCallFlashFallbackClientFake extends BirdGateCallClientFake { public bool $shouldFlashFail = false; public int $flashCallAttempts = 0; public int $regularCallAttempts = 0; public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void { $this->flashCallAttempts++; if ($this->shouldFlashFail) { throw new \Exception('Flash call failed'); } } public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout) { $this->regularCallAttempts++; } } it('fails fast when workspace id is missing for gate calls', function (): void { $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10)) ->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate calls'); }); it('fails fast when channel id is missing for gate calls', function (): void { $client = new BirdGateCallClientFake(workspaceId: 'workspace_1', channelId: ''); expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10)) ->toThrow(\Exception::class, 'Bird channelId is not configured for gate calls'); }); it('fails with terminal status details when call never reaches accepted state', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['busy'] ); try { $client->callGateAndHangupWhenAccepted(45, 12345678, 10); $thrown = null; } catch (\Exception $e) { $thrown = $e; } expect($thrown)->toBeInstanceOf(\Exception::class); expect($thrown?->getMessage())->toContain('terminal status: busy'); expect($thrown?->getMessage())->not->toContain('Bird API request failed'); expect($client->hangupCalls)->toBe(0); }); it('hangs up when call reaches accepted state', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->callGateAndHangupWhenAccepted(45, 12345678, 10); expect($client->hangupCalls)->toBe(1); expect($client->createPayloads)->toHaveCount(1); expect($client->createPayloads[0]['workspaceId'])->toBe('workspace_1'); expect($client->createPayloads[0]['channelId'])->toBe('channel_1'); expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); expect($client->createPayloads[0]['payload']['to'])->toBe('+4512345678'); expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(10); expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); expect($client->hangupPayloads[0])->toBe([]); }); it('hangs up immediately when status transitions to accepted', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['ringing', 'accepted', 'ongoing'] ); $client->callGateAndHangupWhenAccepted(45, 12345678, 10); expect($client->hangupCalls)->toBe(1); expect($client->statusesSeen)->toBe(['ringing', 'accepted']); }); it('maps legacy timeout option to documented ringTimeout payload field', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ 'to' => '+4512345678', 'timeout' => 12, ]); expect($client->createPayloads)->toHaveCount(1); expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(12); expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); }); it('clamps derived ringTimeout to Bird documented max when gate timeout is high', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->callGateAndHangupWhenAccepted(45, 12345678, 1000); expect($client->createPayloads)->toHaveCount(1); expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(120); expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); }); it('passes documented hangup cause when provided', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ 'to' => '+4512345678', 'hangupCause' => 'rejected', ]); expect($client->hangupCalls)->toBe(1); expect($client->hangupPayloads[0]['cause'])->toBe('rejected'); }); it('drops unsupported hangup cause values from request payload', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [ 'to' => '+4512345678', 'hangupCause' => 'completed', ]); expect($client->hangupCalls)->toBe(1); expect($client->hangupPayloads[0])->toBe([]); }); it('fails fast when workspace id is missing for gate flash calls', function (): void { $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) ->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate flash calls'); }); it('creates gate flash call with documented ringTimeout payload', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1' ); $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted', 'from' => '+4532330288']; $client->callGateViaFlashCall(45, 12345678, 10); expect($client->flashCreatePayloads)->toHaveCount(1); expect($client->flashCreatePayloads[0]['workspaceId'])->toBe('workspace_1'); expect($client->flashCreatePayloads[0]['channelId'])->toBe('channel_1'); expect($client->flashCreatePayloads[0]['payload']['from'])->toBe('+4532330288'); expect($client->flashCreatePayloads[0]['payload']['to'])->toBe('+4512345678'); expect($client->flashCreatePayloads[0]['payload']['ringTimeout'])->toBe(10); expect(array_key_exists('timeout', $client->flashCreatePayloads[0]['payload']))->toBeFalse(); }); it('polls flash call and succeeds once accepted', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1' ); $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; $client->flashStatusQueue = ['ringing', 'accepted', 'completed']; $client->callGateViaFlashCall(45, 12345678, 10); expect($client->flashStatusesSeen)->toBe(['ringing', 'accepted']); }); it('fails flash gate flow when terminal failure status is returned', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1' ); $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; $client->flashStatusQueue = ['busy']; expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) ->toThrow(\Exception::class, 'Gate flash call failed with status: busy'); }); it('falls back to regular gate call when flash caller id is not confirmed', function (): void { $client = new BirdGateCallClientFake( workspaceId: 'workspace_1', channelId: 'channel_1', statusQueue: ['accepted'] ); $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted']; $client->callGatePreferringFlashCall(45, 12345678, 10); expect($client->flashCreatePayloads)->toHaveCount(1); expect($client->createPayloads)->toHaveCount(1); expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); }); it('does not fallback to regular gate call when flash succeeds', function (): void { $client = new BirdGateCallFlashFallbackClientFake( workspaceId: 'workspace_1', channelId: 'channel_1' ); $client->callGatePreferringFlashCall(45, 12345678, 10); expect($client->flashCallAttempts)->toBe(1); expect($client->regularCallAttempts)->toBe(0); }); it('falls back to regular gate call when flash fails', function (): void { $client = new BirdGateCallFlashFallbackClientFake( workspaceId: 'workspace_1', channelId: 'channel_1' ); $client->shouldFlashFail = true; $client->callGatePreferringFlashCall(45, 12345678, 10); expect($client->flashCallAttempts)->toBe(1); expect($client->regularCallAttempts)->toBe(1); });