Files
api/services/nginx/app/tests/Unit/Bird/BirdVoiceWebhooksRouteCallLifecycleTest.php
T

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();
});