answerErrorMessage !== null) { throw new RuntimeException($this->answerErrorMessage); } $this->answerPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); return ['status' => 'accepted']; } public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null { $this->gatherPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); return ['status' => 'unexpected']; } public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null { $this->updatePayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload'); return ['status' => 'unexpected']; } } final class BirdVoiceWebhookLifecycleGateFake extends department_gates_o { public function __construct( int $id, private readonly bool $existsFlag = true, ) { $this->id = $id; } public function exists(): bool { return $this->existsFlag; } } final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWebhooksRoute { public array $rawState = []; public array $ttls = []; public array $activeLocks = []; public bool $lockAvailable = true; public ?BirdVoiceWebhookLifecycleBirdClientFake $lastClient = null; public array $eligibleDepartments = []; public array $gatesByDepartmentAndType = []; public array $openedGates = []; public ?string $gateOpenErrorMessage = null; public function __construct() { $this->eligibleDepartments = [ [ 'department_id' => 11, 'department_name' => 'Nord', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => true, ], [ 'department_id' => 22, 'department_name' => 'Syd', 'order_priority' => 2, 'has_entrance_gate' => true, 'has_exit_gate' => false, ], ]; $this->gatesByDepartmentAndType = [ '11:entrance' => new BirdVoiceWebhookLifecycleGateFake(1101), '11:exit' => new BirdVoiceWebhookLifecycleGateFake(1102), '22:entrance' => new BirdVoiceWebhookLifecycleGateFake(2201), ]; } public function runLifecycle(array $payload, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array { $client = $this->lastClient ?? new BirdVoiceWebhookLifecycleBirdClientFake(); $this->lastClient = $client; return $this->handleInboundCallLifecycle($client, $payload, $callId, $workspaceId, $channelId); } public function loadState(string $callId): ?array { return $this->readIvrState($callId); } protected function loadEligibleDepartmentSummaries(): array { return $this->eligibleDepartments; } protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o { return $this->gatesByDepartmentAndType[$departmentId . ':' . $gateType] ?? null; } protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o { foreach ($this->gatesByDepartmentAndType as $gate) { if ((int)$gate->id === $gateId) { return $gate; } } return null; } protected function shouldAcceptInboundCall(bird $client): bool { return true; } protected function triggerGateOpen(department_gates_o $gate): void { if ($this->gateOpenErrorMessage !== null) { throw new RuntimeException($this->gateOpenErrorMessage); } $this->openedGates[] = (int)$gate->id; } protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void { $this->rawState[$key] = $value; $this->ttls[$key] = $ttlSeconds; } protected function readIvrStateRaw(string $key): ?string { return $this->rawState[$key] ?? null; } protected function deleteIvrStateRaw(string $key): void { unset($this->rawState[$key], $this->ttls[$key]); } protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool { if (!$this->lockAvailable || isset($this->activeLocks[$key])) { return false; } $this->activeLocks[$key] = ['ttl' => $ttlSeconds]; return true; } protected function releaseIvrLockRaw(string $key): void { unset($this->activeLocks[$key]); } } function bird_webhook_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array { return [ 'payload' => [ 'endKey' => '#', 'input' => 'speech', 'maxNumKeys' => 9, 'retries' => 3, 'speechLocale' => 'en-US', 'timeout' => 30, 'say' => [ 'locale' => 'en-US', 'voice' => 'female', ], ], 'request' => [ 'callId' => $callId, 'channelId' => $channelId, 'workspaceId' => $workspaceId, ], 'requestId' => 'request-123', 'waitConditions' => [ 'events' => [ [ 'action' => 'continue', 'name' => 'call_command_gather_finished', ], ], 'timeout' => 'PT10M', ], ]; } function bird_webhook_resumed_payload(?string $eventKeys, ?string $fallbackKeys = null, string $callId = 'call_1', string $channelId = 'channel_1'): array { $payload = [ 'event' => [ 'callCommand' => [ 'callId' => $callId, 'channelId' => $channelId, 'gather' => [], ], ], 'resumeData' => [ 'action' => 'continue', ], ]; if ($eventKeys !== null) { $payload['event']['callCommand']['gather']['keys'] = $eventKeys; } if ($fallbackKeys !== null) { $payload['result'] = ['keys' => $fallbackKeys]; } return $payload; } function bird_webhook_legacy_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array { return [ 'callId' => $callId, 'channelId' => $channelId, 'workspaceId' => $workspaceId, ]; } function bird_webhook_flow_selection_payload(string $keys, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array { return [ 'callId' => $callId, 'channelId' => $channelId, 'workspaceId' => $workspaceId, 'keys' => $keys, ]; } it('returns a raw 202 gather response for initial department selection', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $result = $route->runLifecycle(bird_webhook_initial_payload()); expect($result['statusCode'])->toBe(202); expect($result)->not->toHaveKey('success'); expect($result['result']['status'] ?? null)->toBe('accepted'); expect($result['event']['callCommand']['type'] ?? null)->toBe('gather'); expect($result['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf'); expect($result['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1); expect($result['event']['callCommand']['gather']['retries'] ?? null)->toBe(3); expect($result['event']['callCommand']['gather']['timeout'] ?? null)->toBe(30); expect($result['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US'); expect($result['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female'); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); expect($route->lastClient?->answerPayloads ?? [])->toBe([ [ 'workspaceId' => 'workspace_1', 'channelId' => 'channel_1', 'callId' => 'call_1', 'payload' => [], ], ]); expect($route->lastClient?->gatherPayloads ?? [])->toBe([]); expect($route->lastClient?->updatePayloads ?? [])->toBe([]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('department_select'); expect($state['request_id'] ?? null)->toBe('request-123'); }); it('accepts legacy initial webhook payloads with top-level call identifiers', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $result = $route->runLifecycle(bird_webhook_legacy_initial_payload()); expect($result['statusCode'])->toBe(200); expect($result['status'])->toBe('gather'); expect($result['completed'])->toBeFalse(); expect($result['stage'])->toBe('department_select'); expect($result['gather']['input'] ?? null)->toBe('dtmf'); expect($result['gather']['maxNumKeys'] ?? null)->toBe(1); expect($result['gather']['retries'] ?? null)->toBe(3); expect($result['gather']['timeout'] ?? null)->toBe(30); expect($result['gather']['endKey'] ?? null)->toBe('#'); expect($result['gatherInput'] ?? null)->toBe('dtmf'); expect($result['gatherMaxNumKeys'] ?? null)->toBe(1); expect($result['gatherRetries'] ?? null)->toBe(3); expect($result['gatherTimeout'] ?? null)->toBe(30); expect($result['gatherEndKey'] ?? null)->toBe('#'); expect($result['gatherSayLocale'] ?? null)->toBe('en-US'); expect($result['gatherSayVoice'] ?? null)->toBe('female'); expect($result['gather']['say']['locale'] ?? null)->toBe('en-US'); expect($result['gather']['say']['voice'] ?? null)->toBe('female'); expect($result['gather']['say']['text'] ?? null)->toContain('Choose department.'); expect($result['prompt'] ?? null)->toContain('Choose department.'); expect($route->lastClient?->answerPayloads ?? [])->toBe([ [ 'workspaceId' => 'workspace_1', 'channelId' => 'channel_1', 'callId' => 'call_1', 'payload' => [], ], ]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('department_select'); expect($state['resume_action'] ?? null)->toBe('continue'); expect($state['wait_timeout'] ?? null)->toBe('PT10M'); }); it('returns a flow-data gather payload after department selection in native flow mode when distinct gate choices exist', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_legacy_initial_payload()); $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1')); expect($result['statusCode'])->toBe(200); expect($result['status'])->toBe('gather'); expect($result['completed'])->toBeFalse(); expect($result['stage'])->toBe('gate_type_select'); expect($result['prompt'] ?? null)->toContain('You selected Nord.'); expect($result['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.'); expect($result['selection']['departmentId'] ?? null)->toBe(11); expect($result['selection']['departmentName'] ?? null)->toBe('Nord'); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('gate_type_select'); expect($state['selected_department_id'] ?? null)->toBe(11); }); it('opens the gate immediately after department selection when entrance and exit resolve to the same gate in native flow mode', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = [ [ 'department_id' => 44, 'department_name' => 'Shared', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => true, ], ]; $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401); $route->gatesByDepartmentAndType = [ '44:entrance' => $sharedGate, '44:exit' => $sharedGate, ]; $route->runLifecycle(bird_webhook_legacy_initial_payload()); $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1')); expect($result['statusCode'])->toBe(200); expect($result['status'] ?? null)->toBe('completed'); expect($result['completed'] ?? null)->toBeTrue(); expect($result['action'] ?? null)->toBe('gate_opened'); expect($result['gateOpened'] ?? null)->toBeTrue(); expect($result['departmentId'] ?? null)->toBe(44); expect($result['departmentName'] ?? null)->toBe('Shared'); expect($result['gateType'] ?? null)->toBeNull(); expect($result['gateId'] ?? null)->toBe(4401); expect($route->openedGates)->toBe([4401]); expect($route->loadState('call_1'))->toBeNull(); }); it('returns a flow-data completion payload after gate confirmation in native flow mode', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_legacy_initial_payload()); $route->runLifecycle(bird_webhook_flow_selection_payload('1')); $result = $route->runLifecycle(bird_webhook_flow_selection_payload('2')); expect($result['statusCode'])->toBe(200); expect($result['status'] ?? null)->toBe('completed'); expect($result['completed'] ?? null)->toBeTrue(); expect($result['action'] ?? null)->toBe('gate_opened'); expect($result['gateOpened'] ?? null)->toBeTrue(); expect($result['departmentId'] ?? null)->toBe(11); expect($result['departmentName'] ?? null)->toBe('Nord'); expect($result['gateType'] ?? null)->toBe('exit'); expect($result['gateId'] ?? null)->toBe(1102); expect($route->openedGates)->toBe([1102]); expect($route->loadState('call_1'))->toBeNull(); }); it('returns a second-stage raw 202 gather response after department selection when distinct gate choices exist', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_initial_payload()); $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); expect($result['statusCode'])->toBe(202); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Nord.'); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.'); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('gate_type_select'); expect($state['selected_department_id'] ?? null)->toBe(11); expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance', '2' => 'exit']); expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1); expect($route->openedGates)->toBe([]); }); it('opens the shared gate immediately after department selection in raw command mode', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = [ [ 'department_id' => 44, 'department_name' => 'Shared', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => true, ], ]; $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401); $route->gatesByDepartmentAndType = [ '44:entrance' => $sharedGate, '44:exit' => $sharedGate, ]; $route->runLifecycle(bird_webhook_initial_payload()); $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); expect($result['statusCode'])->toBe(200); expect($result['result']['action'] ?? null)->toBe('gate_opened'); expect($result['result']['gateOpened'] ?? null)->toBeTrue(); expect($result['result']['departmentId'] ?? null)->toBe(44); expect($result['result']['gateType'] ?? null)->toBeNull(); expect($result['result']['gateId'] ?? null)->toBe(4401); expect($route->openedGates)->toBe([4401]); expect($route->loadState('call_1'))->toBeNull(); }); it('opens the selected gate and clears redis state on final selection', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_initial_payload()); $route->runLifecycle(bird_webhook_resumed_payload('1')); $result = $route->runLifecycle(bird_webhook_resumed_payload('2')); expect($result['statusCode'])->toBe(200); expect($result['statusText'])->toBe('OK'); expect($result['result']['action'] ?? null)->toBe('gate_opened'); expect($result['result']['gateOpened'] ?? null)->toBeTrue(); expect($result['result']['departmentId'] ?? null)->toBe(11); expect($result['result']['gateType'] ?? null)->toBe('exit'); expect($result['result']['gateId'] ?? null)->toBe(1102); expect($route->openedGates)->toBe([1102]); expect($route->loadState('call_1'))->toBeNull(); }); it('reprompts with invalid selection while keeping webhook state', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_initial_payload()); $result = $route->runLifecycle(bird_webhook_resumed_payload('9')); expect($result['statusCode'])->toBe(202); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Invalid selection.'); expect($route->openedGates)->toBe([]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('department_select'); expect($state['invalid_selection_count'] ?? 0)->toBe(1); }); it('uses fallback dtmf extraction when event gather keys are missing', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->runLifecycle(bird_webhook_initial_payload()); $result = $route->runLifecycle(bird_webhook_resumed_payload(null, '2')); expect($result['statusCode'])->toBe(202); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Syd.'); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); expect($route->openedGates)->toBe([]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['selected_department_id'] ?? null)->toBe(22); expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']); }); it('prompts for department selection even when only one eligible department exists', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = [ [ 'department_id' => 44, 'department_name' => 'Solo', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => false, ], ]; $route->gatesByDepartmentAndType = [ '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), ]; $result = $route->runLifecycle(bird_webhook_initial_payload()); expect($result['statusCode'])->toBe(202); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo.'); expect($route->openedGates)->toBe([]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('department_select'); }); it('prompts for a single available gate type and opens only after explicit confirmation', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = [ [ 'department_id' => 44, 'department_name' => 'Solo', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => false, ], ]; $route->gatesByDepartmentAndType = [ '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), ]; $route->runLifecycle(bird_webhook_initial_payload()); $gatePrompt = $route->runLifecycle(bird_webhook_resumed_payload('1')); expect($gatePrompt['statusCode'])->toBe(202); expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Solo.'); expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); expect($route->openedGates)->toBe([]); $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); expect($result['statusCode'])->toBe(200); expect($result['result']['action'] ?? null)->toBe('gate_opened'); expect($result['result']['gateId'] ?? null)->toBe(4401); expect($route->openedGates)->toBe([4401]); expect($route->loadState('call_1'))->toBeNull(); }); it('supports multi-digit department selections before gate confirmation', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = []; $route->gatesByDepartmentAndType = []; for ($index = 1; $index <= 10; $index++) { $departmentId = 100 + $index; $route->eligibleDepartments[] = [ 'department_id' => $departmentId, 'department_name' => 'Department ' . $index, 'order_priority' => $index, 'has_entrance_gate' => true, 'has_exit_gate' => false, ]; $route->gatesByDepartmentAndType[$departmentId . ':entrance'] = new BirdVoiceWebhookLifecycleGateFake(1000 + $index); } $initial = $route->runLifecycle(bird_webhook_initial_payload()); expect($initial['statusCode'])->toBe(202); expect($initial['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2); expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.'); expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 10 for Department 10.'); $selected = $route->runLifecycle(bird_webhook_resumed_payload('10#')); expect($selected['statusCode'])->toBe(202); expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Department 10.'); expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.'); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['selected_department_id'] ?? null)->toBe(110); expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']); }); it('returns a direct raw 200 completion when no departments are eligible', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = []; $result = $route->runLifecycle(bird_webhook_initial_payload()); expect($result['statusCode'])->toBe(200); expect($result['result']['action'] ?? null)->toBe('no_action'); expect($result['result']['message'] ?? null)->toContain('No phone-controlled gates are configured'); expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1); expect($route->openedGates)->toBe([]); }); it('continues with the first gather prompt when backend call acceptance fails', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->lastClient = new BirdVoiceWebhookLifecycleBirdClientFake(); $route->lastClient->answerErrorMessage = 'already answered'; $result = $route->runLifecycle(bird_webhook_initial_payload()); expect($result['statusCode'])->toBe(202); expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.'); expect($route->lastClient?->answerPayloads ?? [])->toBe([]); $state = $route->loadState('call_1'); expect($state)->not->toBeNull(); expect($state['stage'] ?? null)->toBe('department_select'); }); it('returns a business-failure raw 200 response when gate opening fails', function (): void { $route = new BirdVoiceWebhookLifecycleRouteTestDouble(); $route->eligibleDepartments = [ [ 'department_id' => 44, 'department_name' => 'Solo', 'order_priority' => 1, 'has_entrance_gate' => true, 'has_exit_gate' => false, ], ]; $route->gatesByDepartmentAndType = [ '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401), ]; $route->gateOpenErrorMessage = 'relay offline'; $route->runLifecycle(bird_webhook_initial_payload()); $route->runLifecycle(bird_webhook_resumed_payload('1')); $result = $route->runLifecycle(bird_webhook_resumed_payload('1')); expect($result['statusCode'])->toBe(200); expect($result['result']['status'] ?? null)->toBe('failed'); expect($result['result']['action'] ?? null)->toBe('gate_open_failed'); expect($result['result']['gateOpened'] ?? null)->toBeFalse(); expect($result['result']['message'] ?? null)->toContain('relay offline'); expect($route->loadState('call_1'))->toBeNull(); });