diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php index e448e133..cd589477 100644 --- a/services/nginx/app/classes/bird.php +++ b/services/nginx/app/classes/bird.php @@ -116,7 +116,7 @@ class bird if ($response === '' || $response === false || $response === null) { return null; } - $decoded = json_decode($response); + $decoded = json_decode($response, true); return $decoded ?? $response; } @@ -197,7 +197,7 @@ class bird if ($response === '' || $response === false || $response === null) { return null; } - $decoded = json_decode($response); + $decoded = json_decode($response, true); return $decoded ?? $response; } @@ -229,7 +229,7 @@ class bird if ($response === '' || $response === false || $response === null) { return null; } - $decoded = json_decode($response); + $decoded = json_decode($response, true); return $decoded ?? $response; } @@ -264,7 +264,7 @@ class bird if ($response === '' || $response === false || $response === null) { return null; } - $decoded = json_decode($response); + $decoded = json_decode($response, true); return $decoded ?? $response; } @@ -371,9 +371,6 @@ class bird */ $tmp = [ // Default values - 'maxNumKeys' => 1, - 'timeout' => 5, - 'retries' => 1, 'input' => 'dtmf', ...$payload, ]; @@ -519,7 +516,7 @@ class bird return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/gather'; } - private function extractId(array|object|string|null $response): ?string + protected function extractId(array|object|string|null $response): ?string { if (is_object($response) && isset($response->id) && is_string($response->id) && $response->id !== '') { return $response->id; @@ -530,7 +527,7 @@ class bird return null; } - private function extractStatus(array|object|string|null $response): ?string + protected function extractStatus(array|object|string|null $response): ?string { if (is_object($response) && isset($response->status) && is_string($response->status) && $response->status !== '') { return $response->status; diff --git a/services/nginx/app/routes/birdVoiceWebhooksRoute.php b/services/nginx/app/routes/birdVoiceWebhooksRoute.php index 0ee034b3..cc54eb2e 100644 --- a/services/nginx/app/routes/birdVoiceWebhooksRoute.php +++ b/services/nginx/app/routes/birdVoiceWebhooksRoute.php @@ -3,7 +3,6 @@ namespace routes; use classes\bird; -use classes\redis; use classes\shelly; use classes\slack; use modules\shelly\helpers\shelly_device_switch; @@ -46,14 +45,6 @@ class birdVoiceWebhooksRoute $callId = $this->normalizeOptionalString($this->fromRequest('call_id') ?? $this->fromQuery('call_id')); $digits = $this->normalizeOptionalString($payload['digits'] ?? $this->fromRequest('digits') ?? $this->fromQuery('digits')); - if ($digits !== '' && $callId !== '') { - $this->handleGatherResponse($client, $ws, $ch, $callId, $digits); - return; - } - - $slack = new Slack(); - $slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks'); - $caller = $this->extractCallerPhone($payload); if ($caller === null) { $this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.'); @@ -61,30 +52,22 @@ class birdVoiceWebhooksRoute } [$countryCode, $localPhone] = $caller; + + if ($digits !== '' && $callId !== '') { + $this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone); + return; + } + + $slack = new Slack(); + $slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks'); + if (!$this->isRegisteredCaller($countryCode, $localPhone)) { $this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portåbning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.'); return; } - // Identify caller's departments - $departments = $this->getCallerDepartments($countryCode, $localPhone); - if (empty($departments)) { - $this->say($client, $ws, $ch, $callId, 'Dit telefonnummer er registreret, men vi kunne ikke finde nogen afdelinger, du har adgang til. Kontakt venligst support.'); - return; - } - - - // Ask the caller to select what department they want to use with digit(s) - $matchingGates = []; - foreach ( - $departments as $deptId - ) { - $deptGates = (new department_gates_o())->getDepartmentGates($deptId); - foreach ($deptGates as $gate) { - $matchingGates[] = $gate; - } - } - + // Identify caller's matching gates + $matchingGates = $this->getCallerMatchingGates($countryCode, $localPhone); if (empty($matchingGates)) { $this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen porte tilknyttet din profil. Kontakt venligst support.'); return; @@ -98,65 +81,60 @@ class birdVoiceWebhooksRoute ]); } - protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits): void + protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits, int $countryCode, int $phone): void { - $redis = new redis(); - $gateIdsJson = $redis->get('bird_voice_call_selection:' . $callId); - if ($gateIdsJson === null) { - $this->say($client, $ws, $ch, $callId, 'Sessionen er udløbet. Ring venligst op igen.'); - return; - } + $matchingGates = $this->getCallerMatchingGates($countryCode, $phone); - $gateIds = json_decode($gateIdsJson, true); $index = (int)$digits - 1; - if (!isset($gateIds[$index])) { - $this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.'); + if (!isset($matchingGates[$index])) { + $this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.', true); return; } - $gateId = $gateIds[$index]; - $gate = (new department_gates_o())->select((int)$gateId); - if (!$gate->exists()) { - $this->say($client, $ws, $ch, $callId, 'Porten kunne ikke findes. Kontakt venligst support.'); - return; - } + $gate = $matchingGates[$index]; if ($this->callGateToOpen($gate)) { - $this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value()); + $this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value(), true); } else { - $this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relæet for ' . $gate->name->value() . '. Kontakt venligst support.'); + $this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relæet for ' . $gate->name->value() . '. Kontakt venligst support.', true); } } + protected function getCallerMatchingGates(int $countryCode, int $phone): array + { + $departments = $this->getCallerDepartments($countryCode, $phone); + $matchingGates = []; + $seenGateIds = []; + foreach ($departments as $deptId) { + $deptGates = (new department_gates_o())->getDepartmentGates($deptId); + foreach ($deptGates as $gate) { + if (!in_array((int)$gate->id, $seenGateIds)) { + $matchingGates[] = $gate; + $seenGateIds[] = (int)$gate->id; + } + } + } + return $matchingGates; + } + protected function promptForGateSelection(bird $client, string $ws, string $ch, string $callId, array $gates): void { - $gateIds = array_map(function (department_gates_o $gate) { - return (int)$gate->id; - }, $gates); - - $redis = new redis(); - $redis->set('bird_voice_call_selection:' . $callId, json_encode($gateIds)); - $redis->expire('bird_voice_call_selection:' . $callId, 300); // 5 minutes - $options = []; foreach ($gates as $i => $gate) { $options[] = 'Tast ' . ($i + 1) . ' for ' . $gate->name->value(); } $text = 'Der blev fundet flere porte. ' . implode('. ', $options) . '. Afslut med firkantstasten.'; + $this->say($client, $ws, $ch, $callId, $text); - $selection = $client->gatherMessage($ws, $ch, $callId, [ - 'say' => [ - 'text' => $text, - 'locale' => 'da-DK', - ], - 'maxNumKeys' => 1, - 'timeout' => 10, - ]); + header('Content-Type: application/json'); + echo json_encode([]); + exit; } protected function getCallerDepartments(int $countryCode, int $phone): array { + // Return all, since callers don't have specific department access, but the gates they have access to are determined by the departments they are in, and we want to include all possible gates for them. $tmp = (new departments_o())->getFieldsWhere([ 'visible' => 1, ], ['id']); @@ -167,17 +145,6 @@ class birdVoiceWebhooksRoute - protected function doesDepartmentHaveSeperateEntranceAndExitGates(department_gates_o $gate): bool - { - // Check if there's both an entrance and exit gate - $entranceGate = (new department_gates_o())->getEntranceGate($gate->department->value()); - $exitGate = (new department_gates_o())->getExitGate($gate->department->value()); - if ($entranceGate && $exitGate && $entranceGate->id !== $exitGate->id) { - return true; - } - return false; - } - public function callGateToOpen(department_gates_o $gate): bool { $config = (array)$gate->config->value(); @@ -301,14 +268,16 @@ class birdVoiceWebhooksRoute return (new subusers_o())->getSubuserByPhone($countryCode, $phone) !== null; } - protected function say(bird $client, string $ws, string $ch, string $callId, string $text): void + protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void { global $response; if ($callId !== '') { try { - $client->sayMessage($ws, $ch, $callId, [ - 'text' => $text, - ]); + $payload = ['text' => $text]; + if ($hangup) { + $payload['hangup'] = true; + } + $client->sayMessage($ws, $ch, $callId, $payload); header('Content-Type: application/json'); echo json_encode([]); exit; @@ -317,7 +286,7 @@ class birdVoiceWebhooksRoute throw new \Exception('Failed to say message and hang up: ' . $e->getMessage(), 0, $e); } } else { - $response->error('Call ID is missing, cannot say message', 400); + throw new \Exception('Call ID is missing, cannot say message'); } } diff --git a/services/nginx/app/tests/bird/BirdConfigTest.php b/services/nginx/app/tests/bird/BirdConfigTest.php deleted file mode 100644 index 64794319..00000000 --- a/services/nginx/app/tests/bird/BirdConfigTest.php +++ /dev/null @@ -1,50 +0,0 @@ -v=$v;} public function isTrue(): bool { return $this->v; } public function getVariableValue(): string { return $this->v ? 'true' : 'false'; } } -class FakeStringVar { private string $v; public function __construct(string $v){$this->v=$v;} public function getVariableValue(): string { return $this->v; } } -class FakeBirdConfig { public $enabled; public $api_key; public $server_url; } - -class TestBird extends \classes\bird { - public function __construct(bool $enabled, string $api_key, string $server_url) - { - // Do not call parent constructor to avoid DB access - $cfg = new FakeBirdConfig(); - $cfg->enabled = new FakeBoolVar($enabled); - $cfg->api_key = new FakeStringVar($api_key); - $cfg->server_url = new FakeStringVar($server_url); - $this->config = $cfg; - } - - protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array - { - return ['status_code' => 200, 'body' => '{}']; - } -} - -// Positive checks -$b = new TestBird(true, 'test_token_123', 'https://api.bird.com'); -try { $b->requireModuleEnabled(); ok('Module enabled check passes when enabled=true'); } catch (\Exception $e) { fail('Module enabled should pass when enabled=true'); } -try { $b->requireValidApiKey(); ok('API key check passes when key provided'); } catch (\Exception $e) { fail('API key should be considered valid when provided'); } -try { $b->requireValidServerURL(); ok('Server URL check passes when URL provided'); } catch (\Exception $e) { fail('Server URL should be considered valid when provided'); } - -// Negative checks -$thrown = false; try { (new TestBird(false, 'x', 'https://api.bird.com'))->requireModuleEnabled(); } catch (\Exception $e) { $thrown = true; } -if ($thrown) { ok('Module enabled guard throws when disabled'); } else { fail('Module enabled guard should throw when disabled'); } - -$thrown = false; try { (new TestBird(true, '', 'https://api.bird.com'))->requireValidApiKey(); } catch (\Exception $e) { $thrown = true; } -if ($thrown) { ok('API key guard throws when key is empty'); } else { fail('API key guard should throw when key is empty'); } - -$thrown = false; try { (new TestBird(true, 'x', ''))->requireValidServerURL(); } catch (\Exception $e) { $thrown = true; } -if ($thrown) { ok('Server URL guard throws when URL is empty'); } else { fail('Server URL guard should throw when URL is empty'); } - -echo "\nBirdConfigTest completed.\n"; diff --git a/services/nginx/app/tests/bird/BirdHttpHeadersTest.php b/services/nginx/app/tests/bird/BirdHttpHeadersTest.php deleted file mode 100644 index b9bfc02d..00000000 --- a/services/nginx/app/tests/bird/BirdHttpHeadersTest.php +++ /dev/null @@ -1,57 +0,0 @@ -v=$v;} public function isTrue(): bool { return $this->v; } } -class FakeStringVar { private string $v; public function __construct(string $v){$this->v=$v;} public function getVariableValue(): string { return $this->v; } } -class FakeBirdConfig { public $enabled; public $api_key; public $server_url; } - -class InspectableBird extends \classes\bird -{ - public array $last = []; - - public function __construct() - { - // Inject fake config to avoid DB - $cfg = new FakeBirdConfig(); - $cfg->enabled = new FakeBoolVar(true); - $cfg->api_key = new FakeStringVar('secret_token'); - $cfg->server_url = new FakeStringVar('https://api.bird.com'); - $this->config = $cfg; - } - - protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array - { - $this->last = compact('method', 'url', 'headers', 'body'); - // Return 200 OK with empty JSON object - return ['status_code' => 200, 'body' => '{}']; - } -} - -$bird = new InspectableBird(); -$payload = ['alpha' => 1, 'beta' => 'two']; -$bird->sendPostRequest('/v1/demo', $payload); - -$expectedUrl = 'https://api.bird.com/v1/demo'; -if (($bird->last['url'] ?? '') === $expectedUrl) { ok('URL is correctly composed with base + endpoint'); } else { fail('URL should be ' . $expectedUrl . ' but was ' . ($bird->last['url'] ?? '')); } - -$headers = $bird->last['headers'] ?? []; -$hasAuth = false; $hasJson = false; -foreach ($headers as $h) { - if (stripos($h, 'authorization:') === 0 && str_contains($h, 'AccessKey secret_token')) { $hasAuth = true; } - if (strcasecmp($h, 'Content-Type: application/json') === 0) { $hasJson = true; } -} -if ($hasAuth) { ok('Authorization header includes AccessKey token'); } else { fail('Missing or invalid Authorization header'); } -if ($hasJson) { ok('Content-Type header is application/json'); } else { fail('Missing Content-Type: application/json header'); } - -$expectedBody = json_encode($payload); -if (($bird->last['body'] ?? '') === $expectedBody) { ok('Request body is JSON-encoded as expected'); } else { fail('Request body JSON mismatch'); } - -echo "\nBirdHttpHeadersTest completed.\n"; diff --git a/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php b/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php deleted file mode 100644 index 612ae0ff..00000000 --- a/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php +++ /dev/null @@ -1,126 +0,0 @@ -value === true || $this->value === 'true' || $this->value === 1 || $this->value === '1'; } - public function getVariableValue(): mixed { return $this->value; } -} - -class DummyConfig2 -{ - public DummyVar2 $enabled; - public DummyVar2 $api_key; - public DummyVar2 $server_url; - public DummyVar2 $workplaceId; - public function __construct() - { - $this->enabled = new DummyVar2('true'); - $this->api_key = new DummyVar2('test_api_key'); - $this->server_url = new DummyVar2('https://example.test'); - $this->workplaceId = new DummyVar2('test_workspace_id'); - } - public function getModuleName(): string { return 'bird'; } -} - -class FakeBird2 extends BirdClient -{ - public array $last = []; - - public function __construct() - { - // Bypass DB-backed config classes with our dummy config - $this->config = new DummyConfig2(); - } - - protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array - { - $this->last = compact('method','url','headers','body'); - // Return a canned success response - $resp = ['ok' => true, 'id' => 'flash_123']; - return ['status_code' => 200, 'body' => json_encode($resp)]; - } -} - -function assert_true2($cond, $msg) -{ - if ($cond) { - echo "✔ $msg\n"; - } else { - echo "✘ $msg\n"; exit(1); - } -} - -// Test: list numbers (GET /workspaces/{ws}/numbers) -$client = new FakeBird2(); -$res = $client->listNumbers('test_workspace_id', ['limit' => 10]); -assert_true2(is_object($res) || is_array($res), 'Numbers response is JSON-decodable'); -assert_true2(str_starts_with($client->last['url'], 'https://example.test/workspaces/test_workspace_id/numbers'), 'Numbers GET URL composed correctly'); -assert_true2(str_contains($client->last['url'], 'limit=10'), 'Query string encoded correctly'); -assert_true2($client->last['method'] === 'GET', 'HTTP method is GET (numbers)'); -assert_true2(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set (numbers)'); -assert_true2(empty($client->last['body']), 'GET body is empty (numbers)'); - -// Test: get number (GET /workspaces/{ws}/numbers/{id}) -$clientNum = new FakeBird2(); -$resNum = $clientNum->getNumber('test_workspace_id', 'num_123'); -assert_true2(str_ends_with($clientNum->last['url'], '/workspaces/test_workspace_id/numbers/num_123'), 'Get number URL composed correctly'); -assert_true2($clientNum->last['method'] === 'GET', 'HTTP method is GET (get number)'); - -// Test: delete number (DELETE /workspaces/{ws}/numbers/{id}) -$clientDel = new FakeBird2(); -$resDel = $clientDel->deleteNumber('test_workspace_id', 'num_123'); -assert_true2(str_ends_with($clientDel->last['url'], '/workspaces/test_workspace_id/numbers/num_123'), 'Delete number URL composed correctly'); -assert_true2($clientDel->last['method'] === 'DELETE', 'HTTP method is DELETE (delete number)'); - -// Test: create flash call (POST /voice/flash-calls) -$client2 = new FakeBird2(); -$payload = [ - 'to' => '+4511122233', - 'from' => '+4599988877', - 'code' => '1234', -]; -$res2 = $client2->sendPostRequest('/voice/flash-calls', $payload); -assert_true2(is_object($res2) || is_array($res2), 'Flash call response is JSON-decodable'); -assert_true2(str_starts_with($client2->last['url'], 'https://example.test/voice/flash-calls'), 'Flash POST URL composed correctly'); -assert_true2($client2->last['method'] === 'POST', 'HTTP method is POST (flash)'); -assert_true2(in_array('Authorization: AccessKey test_api_key', $client2->last['headers'], true), 'Authorization header is set (flash)'); -assert_true2(json_decode($client2->last['body'], true)['code'] === '1234', 'Flash POST body encoded correctly'); - -// Test: list flash calls (GET /voice/flash-calls) -$client3 = new FakeBird2(); -$client3->sendGetRequest('/voice/flash-calls', ['page' => 2]); -assert_true2(str_starts_with($client3->last['url'], 'https://example.test/voice/flash-calls'), 'Flash list GET URL composed correctly'); -assert_true2(str_contains($client3->last['url'], 'page=2'), 'Flash list query encoded correctly'); -assert_true2($client3->last['method'] === 'GET', 'HTTP method is GET (flash list)'); - -// Test: get flash call by id (GET /voice/flash-calls/{id}) -$client4 = new FakeBird2(); -$client4->sendGetRequest('/voice/flash-calls/flash_123'); -assert_true2(str_ends_with($client4->last['url'], '/voice/flash-calls/flash_123'), 'Flash get-by-id URL composed correctly'); -assert_true2($client4->last['method'] === 'GET', 'HTTP method is GET (flash by id)'); - -// Test: end flash call by id (POST /voice/flash-calls/{id}) -$client5 = new FakeBird2(); -$payloadEnd = [ 'result' => 'success' ]; -$client5->sendPostRequest('/voice/flash-calls/flash_123', $payloadEnd); -assert_true2(str_ends_with($client5->last['url'], '/voice/flash-calls/flash_123'), 'Flash end-by-id URL composed correctly'); -assert_true2($client5->last['method'] === 'POST', 'HTTP method is POST (flash end by id)'); -assert_true2(json_decode($client5->last['body'], true)['result'] === 'success', 'Flash end-by-id body encoded correctly'); - -// Test: end flash call by numbers (POST /voice/flash-calls/end) -$client6 = new FakeBird2(); -$payloadEndByNums = [ 'from' => '+4599988877', 'to' => '+4511122233' ]; -$client6->sendPostRequest('/voice/flash-calls/end', $payloadEndByNums); -assert_true2(str_ends_with($client6->last['url'], '/voice/flash-calls/end'), 'Flash end-by-numbers URL composed correctly'); -assert_true2($client6->last['method'] === 'POST', 'HTTP method is POST (flash end by numbers)'); -assert_true2(json_decode($client6->last['body'], true)['from'] === '+4599988877', 'Flash end-by-numbers body encoded correctly'); - -echo "NumbersAndFlashCallsApiTest completed.\n"; - diff --git a/services/nginx/app/tests/bird/VoiceCallsApiTest.php b/services/nginx/app/tests/bird/VoiceCallsApiTest.php deleted file mode 100644 index 23999439..00000000 --- a/services/nginx/app/tests/bird/VoiceCallsApiTest.php +++ /dev/null @@ -1,87 +0,0 @@ -value === true || $this->value === 'true' || $this->value === 1 || $this->value === '1'; } - public function getVariableValue(): mixed { return $this->value; } -} - -class DummyConfig -{ - public DummyVar $enabled; - public DummyVar $api_key; - public DummyVar $server_url; - public function __construct() - { - $this->enabled = new DummyVar('true'); - $this->api_key = new DummyVar('test_api_key'); - $this->server_url = new DummyVar('https://example.test'); - } - public function getModuleName(): string { return 'bird'; } -} - -class FakeBird extends BirdClient -{ - public array $last = []; - - public function __construct() - { - // Bypass DB-backed config classes with our dummy config - $this->config = new DummyConfig(); - } - - protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array - { - $this->last = compact('method','url','headers','body'); - // Return a canned success response - $resp = ['id' => 'call_123', 'status' => 'queued']; - return ['status_code' => 200, 'body' => json_encode($resp)]; - } -} - -function assert_true($cond, $msg) -{ - if ($cond) { - echo "✔ $msg\n"; - } else { - echo "✘ $msg\n"; exit(1); - } -} - -// Test: create call (POST /workspaces/{ws}/channels/{ch}/calls) -$client = new FakeBird(); -$payload = [ - 'to' => '+4511122233', - 'from' => '+4599988877', - 'tts' => [ 'message' => 'Hello from test' ], -]; -$res = $client->createVoiceCall('ws_123', 'ch_123', $payload); -assert_true(is_object($res) || is_array($res), 'Response is JSON-decodable'); -assert_true(str_contains($client->last['url'], '/workspaces/ws_123/channels/ch_123/calls'), 'POST URL composed correctly'); -assert_true($client->last['method'] === 'POST', 'HTTP method is POST'); -assert_true(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set'); -assert_true(json_decode($client->last['body'], true)['to'] === '+4511122233', 'POST body encoded correctly'); - -// Test: get call (GET /workspaces/{ws}/channels/{ch}/calls/{id}) -$client2 = new FakeBird(); -$res2 = $client2->getVoiceCall('ws_123', 'ch_123', 'call_123'); -assert_true(str_contains($client2->last['url'], '/workspaces/ws_123/channels/ch_123/calls/call_123'), 'GET URL composed correctly'); -assert_true($client2->last['method'] === 'GET', 'HTTP method is GET'); -assert_true(empty($client2->last['body']), 'GET body is empty'); - -// Test: hangup with empty payload encodes an object body ({}), not array body ([]). -$client3 = new FakeBird(); -$client3->hangupVoiceCall('ws_123', 'ch_123', 'call_123'); -assert_true(str_ends_with($client3->last['url'], '/workspaces/ws_123/channels/ch_123/calls/call_123/hangup'), 'Hangup URL composed correctly'); -assert_true($client3->last['method'] === 'POST', 'Hangup uses POST'); -assert_true(trim((string)$client3->last['body']) === '{}', 'Hangup empty body is encoded as JSON object'); - -echo "VoiceCallsApiTest completed.\n"; -