586 lines
23 KiB
PHP
586 lines
23 KiB
PHP
<?php
|
|
|
|
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
|
|
|
|
app_require('classes/bird.php');
|
|
app_require('objects/department_gates_o.php');
|
|
app_require('routes/birdVoiceWebhooksRoute.php');
|
|
|
|
use classes\bird;
|
|
use objects\department_gates_o;
|
|
|
|
final class BirdVoiceWebhookLifecycleBirdClientFake extends bird
|
|
{
|
|
public array $answerPayloads = [];
|
|
public array $gatherPayloads = [];
|
|
public array $updatePayloads = [];
|
|
public ?string $answerErrorMessage = null;
|
|
|
|
public function __construct()
|
|
{
|
|
}
|
|
|
|
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
|
|
{
|
|
if ($this->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 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', 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('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', 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 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();
|
|
});
|