Files
api/services/nginx/app/tests/Api/BirdVoiceWebhookApiTest.php
T

401 lines
15 KiB
PHP

<?php
declare(strict_types=1);
usesApiSuite();
function disable_bird_transport_for_api_test(): void
{
api_fixtures()->setModuleConfig('bird', 'enabled', 'false');
}
it('returns the raw 202 gather webhook contract over HTTP', function (): void {
api_test_covers('POST /bird/voice/calls/webhook/inbound', 'happy');
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$north = api_fixtures()->createDepartment([
'name' => 'North HTTP',
'order_priority' => 1,
]);
$south = api_fixtures()->createDepartment([
'name' => 'South HTTP',
'order_priority' => 2,
]);
api_fixtures()->createDepartmentGate([
'department' => $north['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'North Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4511111111',
'call_duration_threshold' => 5,
],
]);
api_fixtures()->createDepartmentGate([
'department' => $south['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'South Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4522222222',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'input' => 'dtmf',
'maxNumKeys' => 1,
'retries' => 3,
'say' => [
'locale' => 'en-US',
'voice' => 'female',
],
'speechLocale' => 'en-US',
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffd',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->headers['content-type'] ?? '')->toContain('application/json');
expect($response->json)->toBeArray();
expect($response->json)->not->toHaveKey('success');
expect($response->json['statusCode'] ?? null)->toBe(202);
expect($response->json['statusText'] ?? null)->toBe('Accepted');
expect($response->json['result']['status'] ?? null)->toBe('accepted');
expect($response->json['event']['callCommand']['type'] ?? null)->toBe('gather');
expect($response->json['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf');
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($response->json['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female');
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
});
it('still prompts for department selection when only one department is eligible', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$solo = api_fixtures()->createDepartment([
'name' => 'Solo HTTP',
'order_priority' => 1,
]);
api_fixtures()->createDepartmentGate([
'department' => $solo['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Solo Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4533333333',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'retries' => 3,
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffe',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo HTTP.');
});
it('accepts the legacy initial webhook body with top-level call identifiers', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$solo = api_fixtures()->createDepartment([
'name' => 'Legacy HTTP',
'order_priority' => 1,
]);
api_fixtures()->createDepartmentGate([
'department' => $solo['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Legacy Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4533333399',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ff1',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
], $session['headers']);
$response->assertStatus(200);
expect($response->json['status'] ?? null)->toBe('gather');
expect($response->json['completed'] ?? null)->toBeFalse();
expect($response->json['stage'] ?? null)->toBe('department_select');
expect($response->json['gather']['input'] ?? null)->toBe('dtmf');
expect($response->json['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['gather']['retries'] ?? null)->toBe(3);
expect($response->json['gather']['timeout'] ?? null)->toBe(30);
expect($response->json['gather']['endKey'] ?? null)->toBe('#');
expect($response->json['gatherInput'] ?? null)->toBe('dtmf');
expect($response->json['gatherMaxNumKeys'] ?? null)->toBe(1);
expect($response->json['gatherRetries'] ?? null)->toBe(3);
expect($response->json['gatherTimeout'] ?? null)->toBe(30);
expect($response->json['gatherEndKey'] ?? null)->toBe('#');
expect($response->json['gatherSayLocale'] ?? null)->toBe('en-US');
expect($response->json['gatherSayVoice'] ?? null)->toBe('female');
expect($response->json['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($response->json['gather']['say']['voice'] ?? null)->toBe('female');
expect($response->json['gather']['say']['text'] ?? null)->toContain('Choose department.');
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 when distinct gate choices exist', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$north = api_fixtures()->createDepartment([
'name' => 'North Flow',
'order_priority' => 1,
]);
$south = api_fixtures()->createDepartment([
'name' => 'South Flow',
'order_priority' => 2,
]);
api_fixtures()->createDepartmentGate([
'department' => $north['id'],
'is_entrance' => true,
'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,
'is_exit' => false,
'name' => 'South Entrance',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4522222299',
'call_duration_threshold' => 5,
],
]);
$callId = 'bird-flow-' . 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 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' => '1',
], $session['headers']);
$response->assertStatus(200);
expect($response->json['status'] ?? null)->toBe('gather');
expect($response->json['completed'] ?? null)->toBeFalse();
expect($response->json['stage'] ?? null)->toBe('gate_type_select');
expect($response->json['prompt'] ?? null)->toContain('You selected North Flow.');
expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');
expect($response->json['selection']['departmentId'] ?? null)->toBe((int)$north['id']);
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();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
for ($index = 1; $index <= 10; $index++) {
$department = api_fixtures()->createDepartment([
'name' => 'Department ' . $index . ' HTTP',
'order_priority' => $index,
]);
api_fixtures()->createDepartmentGate([
'department' => $department['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Gate ' . $index,
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => sprintf('+45444444%02d', $index),
'call_duration_threshold' => 5,
],
]);
}
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'retries' => 3,
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2fff',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2);
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.');
expect(preg_match(
'/Press [1-9][0-9]+ for Department 10 HTTP\./',
(string)($response->json['event']['callCommand']['gather']['say']['text'] ?? '')
))->toBe(1);
});
it('returns a raw 400 transport error for malformed webhook payloads', function (): void {
api_test_covers('POST /bird/voice/calls/webhook/inbound', 'failure');
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'request' => [
'callId' => 'broken',
],
], $session['headers']);
$response->assertStatus(400);
expect($response->headers['content-type'] ?? '')->toContain('application/json');
expect($response->json)->toBeArray();
expect($response->json)->not->toHaveKey('success');
expect($response->json['statusCode'] ?? null)->toBe(400);
expect($response->json['statusText'] ?? null)->toBe('Bad Request');
expect($response->json['error']['message'] ?? null)->toContain('Malformed inbound Bird webhook payload');
});