Expand Bird Voice Webhook tests to cover scenarios with shared gates and distinct entrance/exit gates, refine payload validation, and enhance gate resolution logic.
This commit is contained in:
@@ -405,6 +405,14 @@ class birdVoiceWebhooksRoute
|
||||
];
|
||||
}
|
||||
|
||||
$sharedGate = $this->resolveSharedPhoneCallGate($departmentId, $availableGateTypes);
|
||||
if ($sharedGate !== null && $sharedGate->exists()) {
|
||||
$state['gate_id'] = (int)$sharedGate->id;
|
||||
$state['gate_options'] = [];
|
||||
|
||||
return ['action' => 'open_gate', 'state' => $state];
|
||||
}
|
||||
|
||||
$state['stage'] = self::IVR_STAGE_GATE_TYPE_SELECT;
|
||||
|
||||
return ['action' => 'gather', 'state' => $state];
|
||||
@@ -416,7 +424,7 @@ class birdVoiceWebhooksRoute
|
||||
$gateType = $this->selectedGateType($state);
|
||||
$gateId = $this->selectedGateId($state);
|
||||
|
||||
if ($departmentId === null || $gateType === null) {
|
||||
if ($departmentId === null) {
|
||||
$this->clearIvrState($callId);
|
||||
|
||||
return $this->buildLifecycleCompletionResponse(
|
||||
@@ -434,7 +442,11 @@ class birdVoiceWebhooksRoute
|
||||
);
|
||||
}
|
||||
|
||||
$gate = $this->resolvePhoneCallGate($departmentId, $gateType);
|
||||
$gate = $gateId !== null ? $this->resolvePhoneCallGateById($gateId) : null;
|
||||
if (($gate === null || !$gate->exists()) && $gateType !== null) {
|
||||
$gate = $this->resolvePhoneCallGate($departmentId, $gateType);
|
||||
}
|
||||
|
||||
if ($gate === null || !$gate->exists()) {
|
||||
$this->clearIvrState($callId);
|
||||
|
||||
@@ -817,6 +829,40 @@ class birdVoiceWebhooksRoute
|
||||
: $gates->getEntrancePhoneCallGate($departmentId);
|
||||
}
|
||||
|
||||
protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o
|
||||
{
|
||||
if ($gateId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gate = (new department_gates_o())->select($gateId);
|
||||
|
||||
return $gate instanceof department_gates_o && $gate->exists() ? $gate : null;
|
||||
}
|
||||
|
||||
protected function resolveSharedPhoneCallGate(int $departmentId, array $availableGateTypes): ?department_gates_o
|
||||
{
|
||||
if (
|
||||
!in_array(self::IVR_GATE_TYPE_ENTRANCE, $availableGateTypes, true)
|
||||
|| !in_array(self::IVR_GATE_TYPE_EXIT, $availableGateTypes, true)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entranceGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_ENTRANCE);
|
||||
$exitGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_EXIT);
|
||||
if (
|
||||
$entranceGate === null
|
||||
|| !$entranceGate->exists()
|
||||
|| $exitGate === null
|
||||
|| !$exitGate->exists()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$entranceGate->id === (int)$exitGate->id ? $entranceGate : null;
|
||||
}
|
||||
|
||||
protected function triggerGateOpen(department_gates_o $gate): void
|
||||
{
|
||||
$gate->openGate();
|
||||
|
||||
@@ -192,7 +192,7 @@ it('accepts the legacy initial webhook body with top-level call identifiers', fu
|
||||
expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for Legacy HTTP.');
|
||||
});
|
||||
|
||||
it('returns native flow gather data after a top-level department selection', function (): void {
|
||||
it('returns native flow gather data after a top-level department selection when distinct gate choices exist', function (): void {
|
||||
disable_bird_transport_for_api_test();
|
||||
|
||||
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
|
||||
@@ -208,14 +208,25 @@ it('returns native flow gather data after a top-level department selection', fun
|
||||
api_fixtures()->createDepartmentGate([
|
||||
'department' => $north['id'],
|
||||
'is_entrance' => true,
|
||||
'is_exit' => true,
|
||||
'name' => 'North Both',
|
||||
'is_exit' => false,
|
||||
'name' => 'North Entrance',
|
||||
'config' => [
|
||||
'type' => 'PHONE_CALL',
|
||||
'phone_number' => '+4511111199',
|
||||
'call_duration_threshold' => 5,
|
||||
],
|
||||
]);
|
||||
api_fixtures()->createDepartmentGate([
|
||||
'department' => $north['id'],
|
||||
'is_entrance' => false,
|
||||
'is_exit' => true,
|
||||
'name' => 'North Exit',
|
||||
'config' => [
|
||||
'type' => 'PHONE_CALL',
|
||||
'phone_number' => '+4511111188',
|
||||
'call_duration_threshold' => 5,
|
||||
],
|
||||
]);
|
||||
api_fixtures()->createDepartmentGate([
|
||||
'department' => $south['id'],
|
||||
'is_entrance' => true,
|
||||
@@ -228,7 +239,7 @@ it('returns native flow gather data after a top-level department selection', fun
|
||||
],
|
||||
]);
|
||||
|
||||
$callId = '212c606f-a906-4a50-be0b-db95d74f2ff2';
|
||||
$callId = 'bird-flow-' . bin2hex(random_bytes(8));
|
||||
$initialResponse = api_client()->post('/bird/voice/calls/webhook/inbound', [
|
||||
'callId' => $callId,
|
||||
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
|
||||
@@ -238,14 +249,13 @@ it('returns native flow gather data after a top-level department selection', fun
|
||||
$initialResponse->assertStatus(200);
|
||||
|
||||
$prompt = (string)($initialResponse->json['prompt'] ?? '');
|
||||
expect($prompt)->toContain('North Flow.');
|
||||
expect(preg_match('/Press ([0-9]+) for North Flow\./', $prompt, $matches))->toBe(1);
|
||||
expect($prompt)->toContain('Press 1 for North Flow.');
|
||||
|
||||
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
|
||||
'callId' => $callId,
|
||||
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
|
||||
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
|
||||
'keys' => $matches[1],
|
||||
'keys' => '1',
|
||||
], $session['headers']);
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -259,6 +269,57 @@ it('returns native flow gather data after a top-level department selection', fun
|
||||
expect($response->json['selection']['departmentName'] ?? null)->toBe('North Flow');
|
||||
});
|
||||
|
||||
it('opens the gate immediately after a top-level department selection when entrance and exit share the same gate', function (): void {
|
||||
disable_bird_transport_for_api_test();
|
||||
|
||||
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
|
||||
$shared = api_fixtures()->createDepartment([
|
||||
'name' => 'Shared Flow',
|
||||
'order_priority' => 1,
|
||||
]);
|
||||
|
||||
$sharedGate = api_fixtures()->createDepartmentGate([
|
||||
'department' => $shared['id'],
|
||||
'is_entrance' => true,
|
||||
'is_exit' => true,
|
||||
'name' => 'Shared Both',
|
||||
'config' => [
|
||||
'type' => 'PHONE_CALL',
|
||||
'phone_number' => '+4511111177',
|
||||
'call_duration_threshold' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$callId = 'bird-shared-' . bin2hex(random_bytes(8));
|
||||
$initialResponse = api_client()->post('/bird/voice/calls/webhook/inbound', [
|
||||
'callId' => $callId,
|
||||
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
|
||||
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
|
||||
], $session['headers']);
|
||||
|
||||
$initialResponse->assertStatus(200);
|
||||
|
||||
$prompt = (string)($initialResponse->json['prompt'] ?? '');
|
||||
expect($prompt)->toContain('Press 1 for Shared Flow.');
|
||||
|
||||
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
|
||||
'callId' => $callId,
|
||||
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
|
||||
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
|
||||
'keys' => '1',
|
||||
], $session['headers']);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
expect($response->json['status'] ?? null)->toBeIn(['completed', 'failed']);
|
||||
expect($response->json['completed'] ?? null)->toBeTrue();
|
||||
expect($response->json['action'] ?? null)->toBeIn(['gate_opened', 'gate_open_failed']);
|
||||
expect($response->json['departmentId'] ?? null)->toBe((int)$shared['id']);
|
||||
expect($response->json['departmentName'] ?? null)->toBe('Shared Flow');
|
||||
expect($response->json['gateType'] ?? null)->toBeNull();
|
||||
expect($response->json['gateId'] ?? null)->toBe((int)$sharedGate['id']);
|
||||
});
|
||||
|
||||
it('uses a multi-digit gather contract when 10 departments are eligible', function (): void {
|
||||
disable_bird_transport_for_api_test();
|
||||
|
||||
|
||||
@@ -122,6 +122,17 @@ final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWe
|
||||
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;
|
||||
@@ -321,7 +332,7 @@ it('accepts legacy initial webhook payloads with top-level call identifiers', fu
|
||||
expect($state['wait_timeout'] ?? null)->toBe('PT10M');
|
||||
});
|
||||
|
||||
it('returns a flow-data gather payload after department selection in native flow mode', function (): void {
|
||||
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());
|
||||
@@ -342,6 +353,39 @@ it('returns a flow-data gather payload after department selection in native flow
|
||||
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();
|
||||
|
||||
@@ -362,7 +406,7 @@ it('returns a flow-data completion payload after gate confirmation in native flo
|
||||
expect($route->loadState('call_1'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns a second-stage raw 202 gather response after department selection', function (): void {
|
||||
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());
|
||||
@@ -381,6 +425,36 @@ it('returns a second-stage raw 202 gather response after department selection',
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user