response; } public function handle($allowedExtensions) { $this->coreShowChannelsCache = null; return $this->CoreShowChannels($allowedExtensions); } } /** * Regression coverage for "a transferred agent stays In Call". * * Scenario: inbound call -> Queue 700 -> Agent 997 (Ahmed) answers -> transfers * to Queue 701 -> Agent 999 (Mustafa) answers. * * Asterisk keeps Ahmed's Local/997@from-queue-;1 / ;2 channels alive for the * whole (transferred) call, so they must never be treated as Ahmed being on a * call. Expected: Ahmed => Available, Mustafa => In Call, counters ignore the * Local legs. */ class CoreShowChannelsTransferTest extends TestCase { private const ALLOWED = array('997', '999', '1054', '99997', '99999'); /** * AmiConnectionTrait::__destruct() calls storage_path(), so give the * reflection-created controller a minimal container to resolve it. * No framework boot / database needed. */ public static function setUpBeforeClass(): void { $basePath = dirname(__DIR__, 2); $app = new \Illuminate\Foundation\Application($basePath); $app->useStoragePath($basePath . '/storage'); \Illuminate\Container\Container::setInstance($app); } /** * Real Asterisk 22 CoreShowChannel field order. */ private function channel(array $fields) { $lines = array(); foreach ($fields as $key => $value) { $lines[] = $key . ': ' . $value; } return "\r\n" . implode("\r\n", $lines) . "\r\n\r\n"; } private function response(array $channels) { $body = "Response: Success\r\nEventList: start\r\nMessage: Channels will follow\r\n"; foreach ($channels as $fields) { $body .= "Event: CoreShowChannel" . $this->channel($fields); } $body .= "Event: CoreShowChannelsComplete\r\nEventList: Complete\r\nListItems: " . count($channels) . "\r\n"; return $body; } /** * A Local/@from-queue/n queue bridge leg - exactly what FreePBX creates. */ private function localQueueLeg($ext, $leg, $application, $context = 'from-queue', $status = 'Up') { return array( 'Channel' => 'Local/' . $ext . '@from-queue-0000002a;' . $leg, 'ChannelState' => 6, 'ChannelStateDesc' => $status, 'CallerIDNum' => '10146645000', 'CallerIDName' => 'Customer', 'ConnectedLineNum' => $ext, 'ConnectedLineName' => 'Agent', 'Language' => 'en', 'AccountCode' => '', 'Context' => $context, 'Exten' => $ext, 'Priority' => 2, 'Uniqueid' => '1790764606.23' . $leg, 'Linkedid' => '1790764606.230', 'Application' => $application, 'ApplicationData' => 'PJSIP/' . $ext, 'Duration' => '00:00:30', 'BridgeId' => 'bridge-0000001', ); } /** * The agent's own device channel. */ private function pjsipChannel($ext, $application, $status = 'Up', $bridgeId = 'bridge-0000001') { return array( 'Channel' => 'PJSIP/' . $ext . '-00000048', 'ChannelState' => 6, 'ChannelStateDesc' => $status, 'CallerIDNum' => '10146645000', 'CallerIDName' => 'Customer', 'ConnectedLineNum' => '10146645000', 'ConnectedLineName' => 'Customer', 'Language' => 'en', 'AccountCode' => '', 'Context' => 'followme-check', 'Exten' => $ext, 'Priority' => 1, 'Uniqueid' => '1790764606.240', 'Linkedid' => '1790764606.230', 'Application' => $application, 'ApplicationData' => '(Outgoing Line)', 'Duration' => '00:00:25', 'BridgeId' => $bridgeId, ); } private function channels(array $channels) { $harness = new CoreShowChannelsHarness(); $harness->response = $this->response($channels); return $harness->handle(self::ALLOWED); } /** * Invoke the real UserApiController::resolveUserStatus() (same resolution the * app/dashboard use) without booting the framework. */ private function resolve($extension, array $channels, array $agents, array $contacts = null) { if ($contacts === null) { $contacts = array(array('extension' => (string) $extension)); } $controller = (new ReflectionClass(UserApiController::class))->newInstanceWithoutConstructor(); $method = new ReflectionMethod(UserApiController::class, 'resolveUserStatus'); $method->setAccessible(true); return $method->invoke($controller, (string) $extension, $contacts, $channels, $agents); } public function test_after_transfer_first_agent_is_available_and_second_agent_is_in_call() { $result = $this->channels(array( // Lingering transfer channels for Ahmed (997) - this is what // CoreShowChannels keeps reporting after the transfer. $this->localQueueLeg('997', 1, 'Dial'), $this->localQueueLeg('997', 2, 'Queue', 'followme-check'), // Mustafa (999) is the agent actually talking to the caller. $this->pjsipChannel('999', 'AppDial'), )); // Counters must ignore the lingering Local legs. $this->assertSame(0, $result['count_active_outbound']); $this->assertSame(1, $result['count_active_inbound']); $extensions = array_column($result['channels'], 'extension'); $this->assertNotContains('997', $extensions, 'Ahmed must not be reported as having an active call.'); $this->assertSame(array('999'), $extensions); $this->assertSame('inbound', $result['channels'][0]['direction']); // QueueStatus owns the Available / In Call state. $agents = array( '997' => array('status' => array('status' => 'Avail', 'time' => 0)), '999' => array('status' => array('status' => 'In Call', 'time' => 0)), ); $this->assertSame('Avail', $this->resolve(997, $result['channels'], $agents)); $this->assertSame('In Call', $this->resolve(999, $result['channels'], $agents)); } public function test_agent_answering_a_queue_call_is_reported_inbound_in_call() { $result = $this->channels(array( $this->localQueueLeg('997', 1, 'Dial'), $this->localQueueLeg('997', 2, 'Dial', 'followme-check'), $this->pjsipChannel('997', 'AppDial'), )); $this->assertSame(1, $result['count_active_inbound']); $this->assertSame(0, $result['count_active_outbound']); $this->assertCount(1, $result['channels']); $this->assertSame('997', $result['channels'][0]['extension']); $this->assertSame('inbound', $result['channels'][0]['direction']); } public function test_outbound_call_is_detected_and_local_legs_are_ignored() { $result = $this->channels(array( array( 'Channel' => 'Local/997@from-internal-0000002b;1', 'ChannelState' => 6, 'ChannelStateDesc' => 'Up', 'CallerIDNum' => '997', 'CallerIDName' => 'Ahmed', 'ConnectedLineNum' => '20123456789', 'ConnectedLineName' => '', 'Language' => 'en', 'AccountCode' => '', 'Context' => 'from-internal', 'Exten' => 's', 'Priority' => 1, 'Uniqueid' => '1790764606.250', 'Linkedid' => '1790764606.250', 'Application' => 'Dial', 'ApplicationData' => 'Local/20123456789@outbound-allroutes', 'Duration' => '00:00:10', 'BridgeId' => 'bridge-0000002', ), $this->pjsipChannel('997', 'Dial', 'Up', ''), )); $this->assertSame(1, $result['count_active_outbound']); $this->assertSame(0, $result['count_active_inbound']); $this->assertCount(1, $result['channels']); $this->assertSame('997', $result['channels'][0]['extension']); $this->assertSame('outbound', $result['channels'][0]['direction']); } public function test_non_call_activity_on_the_agent_device_is_not_counted() { // Agent listening to voicemail: channel Up but not bridged and not // running Dial/AppDial. $result = $this->channels(array( $this->pjsipChannel('997', 'VoiceMailMain', 'Up', ''), $this->localQueueLeg('997', 1, 'Dial'), )); $this->assertSame(0, $result['count_active_inbound']); $this->assertSame(0, $result['count_active_outbound']); $this->assertSame(array(), $result['channels']); } public function test_ringing_device_channel_is_counted_as_inbound() { $result = $this->channels(array( $this->localQueueLeg('997', 2, 'Dial', 'followme-check', 'Ringing'), $this->pjsipChannel('997', 'AppDial', 'Ringing', ''), )); $this->assertSame(1, $result['count_active_inbound']); $this->assertSame(0, $result['count_active_outbound']); $this->assertSame(1, $result['count_waiting']); } public function test_queue_status_wins_over_a_lingering_device_channel() { // Belt and braces: even if a device channel is still reported, the // QueueStatus state decides Available vs In Call. $channels = array($this->pjsipChannel('997', 'AppDial')); $agents = array('997' => array('status' => array('status' => 'Avail', 'time' => 0))); $this->assertSame('Avail', $this->resolve(997, $channels, $agents)); } public function test_non_queue_user_falls_back_to_the_device_channel() { $result = $this->channels(array( $this->pjsipChannel('1054', 'Dial', 'Up', ''), )); $this->assertSame(1, $result['count_active_outbound']); // No QueueStatus entry for this user -> the device channel decides. $this->assertSame('Out Call', $this->resolve(1054, $result['channels'], array())); } public function test_ringing_call_shows_ringing_for_the_targeted_agent() { $result = $this->channels(array( $this->localQueueLeg('997', 2, 'Dial', 'followme-check', 'Ringing'), $this->pjsipChannel('997', 'AppDial', 'Ringing', ''), )); // This is what the Active Calls list renders as Ringing ... $this->assertCount(1, $result['channels']); $this->assertSame('997', $result['channels'][0]['extension']); $this->assertSame('Ringing', $result['channels'][0]['status']); // ... so the Users section must show Ringing as well, even when // QueueStatus already reports the member as busy while the device is // still ringing. foreach (array('Avail', 'In Call', 'Out Call') as $queueState) { $agents = array('997' => array('status' => array('status' => $queueState, 'time' => 0))); $this->assertSame( 'Ringing', $this->resolve(997, $result['channels'], $agents), "QueueStatus state '{$queueState}' must not hide the ringing call." ); } } public function test_paused_state_still_wins_over_ringing() { $channels = array($this->pjsipChannel('997', 'AppDial', 'Ringing', '')); $agents = array('997' => array('status' => array('status' => 'Paused', 'reason' => 'Break', 'time' => 30))); $this->assertSame('Break', $this->resolve(997, $channels, $agents)); } public function test_answered_call_still_uses_the_queue_state() { // Answered (bridged, Up) - the ringing shortcut must not apply. $channels = array($this->pjsipChannel('997', 'AppDial', 'Up', 'bridge-0000001')); $agents = array('997' => array('status' => array('status' => 'In Call', 'time' => 0))); $this->assertSame('In Call', $this->resolve(997, $channels, $agents)); } }