From d9327985496081daf19b7a7a7542927a69bdcd30 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Sat, 31 Oct 2026 12:43:52 +0545 Subject: [PATCH] fix(api): let channels.voice update() send an explicit null agent_id PATCH /v3/channels/voice/{number}'s own docs give agent_id three states: a value binds an agent, null unbinds it, and omitting the key leaves the binding alone. VoiceService::update() builds its request body through Util::removeNulls(), which strips every null field before the request goes out - so calling update(agentID: null) produces the same wire request as not passing agentID at all. There's no way to unbind an agent through this method today. Give agentID its own default, a new Omit marker (same role as the Omit/omit sentinel the Python SDK already uses for this exact problem), instead of null. A real null now survives into the request body; leaving the parameter out still omits the key. Every other field on this method keeps going through removeNulls() unchanged. Confirmed live against the free voice-agent fields that have the same null-is-meaningful contract (no phone number needed to test): the typed update() method 400s today when passed an explicit null, while raw->update() with a plain array already accepts and applies it. This patch brings the typed method in line with what the API and its own docblock already promise. --- src/Core/Omit.php | 18 ++++++ .../Channels/VoiceContract.php | 8 ++- src/Services/Channels/VoiceService.php | 16 +++-- tests/Services/Channels/VoiceTest.php | 59 +++++++++++++++++++ 4 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 src/Core/Omit.php diff --git a/src/Core/Omit.php b/src/Core/Omit.php new file mode 100644 index 000000000..aaaaec4ee --- /dev/null +++ b/src/Core/Omit.php @@ -0,0 +1,18 @@ + $params */ $params = Util::removeNulls( [ - 'agentID' => $agentID, 'callbackURL' => $callbackURL, 'defaultForAppCalls' => $defaultForAppCalls, 'sandbox' => $sandbox, @@ -222,6 +224,12 @@ public function update( ], ); + // agent_id carries meaning as an explicit null (unbind), so it skips removeNulls() + // entirely instead of being stripped alongside every other unset field above. + if (!($agentID instanceof Omit)) { + $params['agentID'] = $agentID; + } + // @phpstan-ignore-next-line argument.type $response = $this->raw->update($number, params: $params, requestOptions: $requestOptions); diff --git a/tests/Services/Channels/VoiceTest.php b/tests/Services/Channels/VoiceTest.php index c9bb06038..540e59ede 100644 --- a/tests/Services/Channels/VoiceTest.php +++ b/tests/Services/Channels/VoiceTest.php @@ -2,6 +2,8 @@ namespace Tests\Services\Channels; +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Mock\Client as MockTransporter; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -71,6 +73,63 @@ public function testUpdate(): void $this->assertInstanceOf(APIResponseOfVoiceNumber::class, $result); } + #[Test] + public function testUpdateSendsExplicitNullToUnbindAgent(): void + { + $transporter = new MockTransporter; + $transporter->setDefaultResponse( + Psr17FactoryDiscovery::findResponseFactory() + ->createResponse() + ->withStatus(200) + ->withHeader('Content-Type', 'application/json') + ->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream( + json_encode(['data' => []], flags: Util::JSON_ENCODE_FLAGS) ?: '' + )) + ); + + $client = new Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->channels->voice->update('+12125550100', agentID: null); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + /** @var array $sent */ + $sent = json_decode((string) $requested->getBody(), true); + $this->assertArrayHasKey('agent_id', $sent); + $this->assertNull($sent['agent_id']); + } + + #[Test] + public function testUpdateOmitsAgentIdWhenNotPassed(): void + { + $transporter = new MockTransporter; + $transporter->setDefaultResponse( + Psr17FactoryDiscovery::findResponseFactory() + ->createResponse() + ->withStatus(200) + ->withHeader('Content-Type', 'application/json') + ->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream( + json_encode(['data' => []], flags: Util::JSON_ENCODE_FLAGS) ?: '' + )) + ); + + $client = new Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->channels->voice->update('+12125550100', status: 'ACTIVE'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + /** @var array $sent */ + $sent = json_decode((string) $requested->getBody(), true); + $this->assertArrayNotHasKey('agent_id', $sent); + } + #[Test] public function testList(): void {