401 lines
15 KiB
PHP
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');
|
|
});
|