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:
Jeppe Bundgaard
2026-04-13 17:55:53 +02:00
parent 8465ee794d
commit d267fb0f09
3 changed files with 192 additions and 11 deletions
@@ -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();