diff --git a/src/Adapter/ServerQuery/Event.php b/src/Adapter/ServerQuery/Event.php index e6c49e85..d9bb1a45 100644 --- a/src/Adapter/ServerQuery/Event.php +++ b/src/Adapter/ServerQuery/Event.php @@ -54,7 +54,9 @@ public function __construct(StringHelper $evt, Host $con = null) throw new AdapterException("invalid notification event format"); } - list($type, $data) = $evt->split(TeamSpeak3::SEPARATOR_CELL, 2); + $parts = $evt->split(TeamSpeak3::SEPARATOR_CELL, 2); + $type = $parts[0]; + $data = $parts[1] ?? null; if (empty($data)) { throw new AdapterException("invalid notification event data"); diff --git a/src/Node/Node.php b/src/Node/Node.php index d8f739aa..e99e392b 100644 --- a/src/Node/Node.php +++ b/src/Node/Node.php @@ -152,7 +152,7 @@ public function iconGetName(string $key): StringHelper * @param string $prefix * @return string */ - public function getClass(string $prefix = "ts3_"): string + public function getClass(?string $prefix = "ts3_"): string { if ($this instanceof Channel && $this->isSpacer()) { return $prefix . "spacer"; @@ -160,7 +160,9 @@ public function getClass(string $prefix = "ts3_"): string return $prefix . "query"; } - return $prefix . StringHelper::factory(get_class($this))->section("_", 2)->toLower(); + $class = StringHelper::factory(str_replace("\\", "/", get_class($this)))->split("/"); + + return $prefix . array_pop($class)->toLower(); } /** diff --git a/src/Node/Server.php b/src/Node/Server.php index 5effa8bf..ba7a29d1 100644 --- a/src/Node/Server.php +++ b/src/Node/Server.php @@ -2565,7 +2565,14 @@ protected function fetchNodeList() foreach ($this->channelList() as $channel) { if ($channel["pid"] == 0) { - $this->nodeList[] = $channel; + try { + $channel->count(); + $this->nodeList[] = $channel; + } catch (ServerQueryException $e) { + if ($e->getCode() != 0xA08) { + throw $e; + } + } } } } diff --git a/src/Transport/TCP.php b/src/Transport/TCP.php index a55e150d..b591ab3d 100644 --- a/src/Transport/TCP.php +++ b/src/Transport/TCP.php @@ -36,11 +36,14 @@ public function connect(): void if (empty($this->config["ssh"])) { $address = "tcp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port; $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => true, "verify_peer" => false, "verify_peer_name" => false]]; + $errno = 0; + $errstr = ''; - $this->stream = @stream_socket_client($address, $errno, $errstr, $this->config["timeout"], STREAM_CLIENT_CONNECT, stream_context_create($options)); + $this->stream = $this->openSocket($address, $errno, $errstr, $this->config["timeout"], $options); if ($this->stream === false) { - throw new TransportException(StringHelper::factory($errstr)->toUtf8()->toString(), $errno); + $message = $errstr ?: "failed to connect to server '$host:$port'"; + throw new TransportException(StringHelper::factory($message)->toUtf8()->toString(), $errno); } if (!empty($this->config["tls"])) { @@ -68,6 +71,16 @@ public function connect(): void @stream_set_blocking($this->stream, $blocking ? 1 : 0); } + /** + * Opens the TCP socket. + * + * @return mixed + */ + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + return @stream_socket_client($address, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, stream_context_create($options)); + } + /** * Disconnects from a remote server. * diff --git a/src/Transport/UDP.php b/src/Transport/UDP.php index 6d825f08..e9965940 100644 --- a/src/Transport/UDP.php +++ b/src/Transport/UDP.php @@ -31,17 +31,33 @@ public function connect(): void $address = "udp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port; $timeout = (int)$this->config["timeout"]; + $errno = 0; + $errstr = ''; - $this->stream = @stream_socket_client($address, $errno, $errstr, $timeout); + $this->stream = $this->openSocket($address, $errno, $errstr, $timeout); if ($this->stream === false) { - throw new TransportException(StringHelper::factory($errstr)->toUtf8()->toString(), $errno); + $message = $errstr ?: "failed to connect to server '$host:$port'"; + throw new TransportException(StringHelper::factory($message)->toUtf8()->toString(), $errno); } @stream_set_timeout($this->stream, $timeout); @stream_set_blocking($this->stream, $this->config["blocking"] ? 1 : 0); } + /** + * Opens the datagram socket. + * + * Kept separate from connect() so transports can specialize connection + * creation without duplicating configuration and stream setup. + * + * @return mixed + */ + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout): mixed + { + return @stream_socket_client($address, $errno, $errstr, $timeout); + } + /** * Disconnects from a remote server. * diff --git a/src/Viewer/Json.php b/src/Viewer/Json.php index c27284e3..7bf00498 100644 --- a/src/Viewer/Json.php +++ b/src/Viewer/Json.php @@ -6,6 +6,7 @@ use PlanetTeamSpeak\TeamSpeak3Framework\Node\Channel; use PlanetTeamSpeak\TeamSpeak3Framework\Node\ChannelGroup; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Client; +use PlanetTeamSpeak\TeamSpeak3Framework\Node\Host; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Node; use PlanetTeamSpeak\TeamSpeak3Framework\Node\Server; use PlanetTeamSpeak\TeamSpeak3Framework\Node\ServerGroup; @@ -291,7 +292,7 @@ protected function getProps(): stdClass { $props = new stdClass(); - if (is_a($this->currObj, Node::class)) { + if (is_a($this->currObj, Host::class)) { $this->id = 0; $this->icon = 0; $props->version = $this->currObj->version("version")->toString(); diff --git a/src/Viewer/Text.php b/src/Viewer/Text.php index 922335b1..d99b86e1 100644 --- a/src/Viewer/Text.php +++ b/src/Viewer/Text.php @@ -19,6 +19,16 @@ class Text implements ViewerInterface */ protected string $pattern = "%0%1 %2\n"; + /** + * The node currently being rendered. + */ + protected ?Node $currObj = null; + + /** + * The current node's ancestor sibling state. + */ + protected array $currSib = []; + /** * Returns the code needed to display a node in a TeamSpeak 3 viewer. * diff --git a/tests/Adapter/ServerQuery/EventTest.php b/tests/Adapter/ServerQuery/EventTest.php new file mode 100644 index 00000000..f4b44f45 --- /dev/null +++ b/tests/Adapter/ServerQuery/EventTest.php @@ -0,0 +1,63 @@ +assertSame('textmessage', $event->getType()->toString()); + $this->assertSame(7, $event['clid']); + $this->assertSame('Hello World', $event->getData()['msg']->toString()); + $this->assertSame('clid=7 msg=Hello\\sWorld', $event->getMessage()->toString()); + } + + public function testRejectsInvalidEventFormat(): void + { + $this->expectException(AdapterException::class); + $this->expectExceptionMessage('invalid notification event format'); + + new Event(new StringHelper('error id=0 msg=ok')); + } + + public function testRejectsEventWithoutData(): void + { + $this->expectException(AdapterException::class); + $this->expectExceptionMessage('invalid notification event data'); + + new Event(new StringHelper('notifytextmessage')); + } + + public function testRejectsUnknownOffsets(): void + { + $event = new Event(new StringHelper('notifytextmessage clid=7')); + + $this->expectException(ServerQueryException::class); + $this->expectExceptionMessage('invalid parameter'); + $event['missing']; + } + + public function testEventsAreReadOnlyButCanForgetAnOffset(): void + { + $event = new Event(new StringHelper('notifytextmessage clid=7')); + + try { + $event['clid'] = 8; + $this->fail('Expected event mutation to fail.'); + } catch (NodeException) { + $this->assertTrue(isset($event['clid'])); + } + + unset($event['clid']); + $this->assertFalse(isset($event['clid'])); + } +} diff --git a/tests/Adapter/ServerQuery/ReplyTest.php b/tests/Adapter/ServerQuery/ReplyTest.php index 3339663d..c7937920 100644 --- a/tests/Adapter/ServerQuery/ReplyTest.php +++ b/tests/Adapter/ServerQuery/ReplyTest.php @@ -77,11 +77,20 @@ public function testToString() public function testToLines() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $lines = $reply->toLines(); + $this->assertCount(3, $lines); + $this->assertSame('clid=1 cid=1 client_database_id=1 client_nickname=serveradmin from [::1]:59642 client_type=1', $lines[0]->toString()); } public function testToTable() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $table = $reply->toTable(); + $this->assertCount(3, $table); + $this->assertSame('clid=1', $table[0][0]->toString()); + $this->assertSame('client_nickname=serveradmin from [::1]:59642', $table[0][3]->toString()); } /** @@ -108,39 +117,58 @@ public function testToArray() public function testToAssocArray() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); + + $clients = $reply->toAssocArray('clid'); + $this->assertSame(2, $clients[2]['clid']); + $this->assertSame('Unknown from [::1]:59766', $clients[3]['client_nickname']->toString()); } public function testToList() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST_EXTENDED_SINGLE), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame(63, $reply->toList()['clid']); } public function testToObjectArray() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_CLIENTLIST_EXTENDED_SINGLE), new StringHelper(static::$S_ERROR_OK)]); + + $objects = $reply->toObjectArray(); + $this->assertCount(1, $objects); + $this->assertSame(63, $objects[0]->clid); } public function testGetCommandString() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)], 'serverlist'); + + $this->assertSame('serverlist', $reply->getCommandString()->toString()); } public function testGetNotifyEvents() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame([], $reply->getNotifyEvents()); } public function testGetErrorProperty() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper('error id=256 msg=failed extra_msg=details return_code=return')], '', null, false); + + $this->assertSame(256, $reply->getErrorProperty('id')); + $this->assertSame('failed', $reply->getErrorProperty('msg')->toString()); + $this->assertSame('fallback', $reply->getErrorProperty('missing', 'fallback')); } public function testFetchError() { - $this->expectNotToPerformAssertions(); - //$this->assertInstanceOf(\TeamSpeak3_Adapter_ServerQuery_Reply::class, $reply); - //$this->assertInternalType(PHPUnit_IsType::TYPE_INT, $reply->getErrorProperty('id')); - //$this->assertEquals(0, $reply->getErrorProperty('id')); - //$this->assertInternalType(PHPUnit_IsType::TYPE_STRING, $reply->getErrorProperty('msg')); - //$this->assertEquals('ok', $reply->getErrorProperty('msg')); + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(256); + $this->expectExceptionMessage('failed (details)'); + + new Reply([new StringHelper(static::$S_SERVERLIST), new StringHelper('error id=256 msg=failed extra_msg=details')]); } public function testFetchReply() { - $this->expectNotToPerformAssertions(); + $reply = new Reply([new StringHelper(static::$S_WELCOME_L1), new StringHelper(static::$S_SERVERLIST), new StringHelper(static::$S_ERROR_OK)]); + + $this->assertSame(static::$E_SERVERLIST, $reply->toString()->toString()); } } diff --git a/tests/Adapter/ServerQueryTest.php b/tests/Adapter/ServerQueryTest.php index 5bdf3aeb..e12a116d 100644 --- a/tests/Adapter/ServerQueryTest.php +++ b/tests/Adapter/ServerQueryTest.php @@ -48,4 +48,45 @@ public function testLogin() $reply = $serverQuery->request($query); $this->assertEquals("ok", $reply->getErrorProperty('msg')->toString()); } + + public function testPrepareEscapesArgumentsAndBuildsListCells(): void + { + $serverQuery = $this->createMockServerQuery(); + + $this->assertSame( + 'command name=Hello\\sWorld enabled=1 disabled=0 first=one second=three|first=two', + $serverQuery->prepare('command', [ + 'name' => 'Hello World', + 'enabled' => true, + 'disabled' => false, + 'ignored' => null, + 'first' => ['one', 'two'], + 'second' => ['three', null], + ]) + ); + } + + public function testRequestRejectsBlockedCommands(): void + { + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x100); + $this->expectExceptionMessage('command not found'); + + $this->createMockServerQuery()->request('help'); + } + + public function testRequestTracksCountTimestampAndRuntime(): void + { + $serverQuery = $this->createMockServerQuery(); + $this->assertSame(0, $serverQuery->getQueryCount()); + $this->assertNull($serverQuery->getQueryLastTimestamp()); + + $serverQuery->request('login serveradmin secret'); + + $this->assertSame(1, $serverQuery->getQueryCount()); + $this->assertIsInt($serverQuery->getQueryLastTimestamp()); + $this->assertGreaterThanOrEqual(0, $serverQuery->getQueryRuntime()); + $this->assertSame('0.0.0.0', $serverQuery->getTransportHost()); + $this->assertSame('9987', $serverQuery->getTransportPort()); + } } diff --git a/tests/Exception/TeamSpeak3ExceptionTest.php b/tests/Exception/TeamSpeak3ExceptionTest.php new file mode 100644 index 00000000..b45b7fb7 --- /dev/null +++ b/tests/Exception/TeamSpeak3ExceptionTest.php @@ -0,0 +1,29 @@ +assertSame(42, $exception->getRawCode()); + $this->assertSame('original message', $exception->getRawMessage()); + $this->assertNotSame('', $exception->getSender()); + } + + public function testCustomMessagesCanBeRegisteredAndRemoved(): void + { + TeamSpeak3Exception::registerCustomMessage(4242, 'Error %code: %mesg'); + $exception = new TeamSpeak3Exception('details', 4242); + $this->assertSame('Error 4242: details', $exception->getMessage()); + TeamSpeak3Exception::unregisterCustomMessage(4242); + + $this->expectException(TeamSpeak3Exception::class); + TeamSpeak3Exception::unregisterCustomMessage(4242); + } +} diff --git a/tests/Helper/ConvertTest.php b/tests/Helper/ConvertTest.php index 72b20f16..42b0be46 100644 --- a/tests/Helper/ConvertTest.php +++ b/tests/Helper/ConvertTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use PlanetTeamSpeak\TeamSpeak3Framework\Helper\Convert; +use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3; class ConvertTest extends TestCase { @@ -230,32 +231,55 @@ public function testConvertSecondsToHumanReadable() public function testConvertCodecIDToHumanReadable() { - $this->expectNotToPerformAssertions(); - // @todo: Find logical / comprehensive test for checking codec names + foreach ([ + TeamSpeak3::CODEC_SPEEX_NARROWBAND => 'Speex Narrowband', TeamSpeak3::CODEC_SPEEX_WIDEBAND => 'Speex Wideband', + TeamSpeak3::CODEC_SPEEX_ULTRAWIDEBAND => 'Speex Ultra-Wideband', TeamSpeak3::CODEC_CELT_MONO => 'CELT Mono', + TeamSpeak3::CODEC_OPUS_VOICE => 'Opus Voice', TeamSpeak3::CODEC_OPUS_MUSIC => 'Opus Music', + ] as $codec => $name) { + $this->assertSame($name, Convert::codec($codec)); + } + $this->assertSame('Unknown', Convert::codec(-1)); } public function testConvertGroupTypeIDToHumanReadable() { - $this->expectNotToPerformAssertions(); - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::GROUP_DBTYPE_TEMPLATE => 'Template', TeamSpeak3::GROUP_DBTYPE_REGULAR => 'Regular', TeamSpeak3::GROUP_DBTYPE_SERVERQUERY => 'ServerQuery'] as $type => $name) { + $this->assertSame($name, Convert::groupType($type)); + } + $this->assertSame('Unknown', Convert::groupType(-1)); } public function testConvertPermTypeIDToHumanReadable() { - $this->expectNotToPerformAssertions(); - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::PERM_TYPE_SERVERGROUP => 'Server Group', TeamSpeak3::PERM_TYPE_CLIENT => 'Client', TeamSpeak3::PERM_TYPE_CHANNEL => 'Channel', TeamSpeak3::PERM_TYPE_CHANNELGROUP => 'Channel Group', TeamSpeak3::PERM_TYPE_CHANNELCLIENT => 'Channel Client'] as $type => $name) { + $this->assertSame($name, Convert::permissionType($type)); + } + $this->assertSame('Unknown', Convert::permissionType(-1)); } public function testConvertPermCategoryIDToHumanReadable() { - $this->expectNotToPerformAssertions(); - // @todo: Find logical / comprehensive test for checking codec names + foreach ([ + TeamSpeak3::PERM_CAT_GLOBAL => 'Global', TeamSpeak3::PERM_CAT_GLOBAL_INFORMATION => 'Global / Information', TeamSpeak3::PERM_CAT_GLOBAL_SERVER_MGMT => 'Global / Virtual Server Management', TeamSpeak3::PERM_CAT_GLOBAL_ADM_ACTIONS => 'Global / Administration', TeamSpeak3::PERM_CAT_GLOBAL_SETTINGS => 'Global / Settings', + TeamSpeak3::PERM_CAT_SERVER => 'Virtual Server', TeamSpeak3::PERM_CAT_SERVER_INFORMATION => 'Virtual Server / Information', TeamSpeak3::PERM_CAT_SERVER_ADM_ACTIONS => 'Virtual Server / Administration', TeamSpeak3::PERM_CAT_SERVER_SETTINGS => 'Virtual Server / Settings', + TeamSpeak3::PERM_CAT_CHANNEL => 'Channel', TeamSpeak3::PERM_CAT_CHANNEL_INFORMATION => 'Channel / Information', TeamSpeak3::PERM_CAT_CHANNEL_CREATE => 'Channel / Create', TeamSpeak3::PERM_CAT_CHANNEL_MODIFY => 'Channel / Modify', TeamSpeak3::PERM_CAT_CHANNEL_DELETE => 'Channel / Delete', TeamSpeak3::PERM_CAT_CHANNEL_ACCESS => 'Channel / Access', + TeamSpeak3::PERM_CAT_GROUP => 'Group', TeamSpeak3::PERM_CAT_GROUP_INFORMATION => 'Group / Information', TeamSpeak3::PERM_CAT_GROUP_CREATE => 'Group / Create', TeamSpeak3::PERM_CAT_GROUP_MODIFY => 'Group / Modify', TeamSpeak3::PERM_CAT_GROUP_DELETE => 'Group / Delete', + TeamSpeak3::PERM_CAT_CLIENT => 'Client', TeamSpeak3::PERM_CAT_CLIENT_INFORMATION => 'Client / Information', TeamSpeak3::PERM_CAT_CLIENT_ADM_ACTIONS => 'Client / Admin', TeamSpeak3::PERM_CAT_CLIENT_BASICS => 'Client / Basics', TeamSpeak3::PERM_CAT_CLIENT_MODIFY => 'Client / Modify', TeamSpeak3::PERM_CAT_FILETRANSFER => 'File Transfer', TeamSpeak3::PERM_CAT_NEEDED_MODIFY_POWER => 'Grant', + ] as $category => $name) { + $this->assertSame($name, Convert::permissionCategory($category)); + } + $this->assertSame('Unknown', Convert::permissionCategory(-1)); } public function testConvertLogLevelIDToHumanReadable() { - $this->expectNotToPerformAssertions(); - // @todo: Find logical / comprehensive test for checking codec names + foreach ([TeamSpeak3::LOGLEVEL_CRITICAL => 'CRITICAL', TeamSpeak3::LOGLEVEL_ERROR => 'ERROR', TeamSpeak3::LOGLEVEL_DEBUG => 'DEBUG', TeamSpeak3::LOGLEVEL_WARNING => 'WARNING', TeamSpeak3::LOGLEVEL_INFO => 'INFO'] as $level => $name) { + $this->assertSame($name, Convert::logLevel($level)); + $this->assertEquals($level, Convert::logLevel(strtolower($name))); + } + $this->assertSame('DEVELOP', Convert::logLevel(-1)); + $this->assertEquals(TeamSpeak3::LOGLEVEL_ERROR, Convert::logLevel('error')); + $this->assertEquals(TeamSpeak3::LOGLEVEL_DEVEL, Convert::logLevel('unexpected')); } public function testConvertLogEntryToArray() diff --git a/tests/Helper/ProfilerTest.php b/tests/Helper/ProfilerTest.php new file mode 100644 index 00000000..8f45cec8 --- /dev/null +++ b/tests/Helper/ProfilerTest.php @@ -0,0 +1,41 @@ +assertTrue($timer->isRunning()); + $this->assertGreaterThanOrEqual(0, $timer->getRuntime()); + $this->assertTrue($timer->isRunning()); + + $timer->stop(); + $this->assertFalse($timer->isRunning()); + $this->assertGreaterThanOrEqual(0, $timer->getMemUsage()); + $this->assertGreaterThanOrEqual(0, $timer->getMemUsage(true)); + + $timer->start(); + $this->assertTrue($timer->isRunning()); + } + + public function testProfilerCreatesStartsStopsAndReturnsNamedTimers(): void + { + Profiler::init('named'); + $timer = Profiler::get('named'); + $this->assertInstanceOf(Timer::class, $timer); + + Profiler::stop('named'); + $this->assertFalse($timer->isRunning()); + Profiler::start('named'); + $this->assertTrue($timer->isRunning()); + + Profiler::stop('created-on-stop'); + $this->assertInstanceOf(Timer::class, Profiler::get('created-on-stop')); + } +} diff --git a/tests/Helper/UriTest.php b/tests/Helper/UriTest.php index fcdd3a4d..a06c1153 100644 --- a/tests/Helper/UriTest.php +++ b/tests/Helper/UriTest.php @@ -96,8 +96,10 @@ public function testConstructInvalidScheme() public function testParseURI() { - $this->expectNotToPerformAssertions(); - // @todo: No reachable path results in error. Implement if found. + $this->expectException(HelperException::class); + $this->expectExceptionMessage('invalid URI supplied'); + + new Uri('serverquery://[::1'); } /** diff --git a/tests/Node/HostTest.php b/tests/Node/HostTest.php new file mode 100644 index 00000000..614da399 --- /dev/null +++ b/tests/Node/HostTest.php @@ -0,0 +1,94 @@ +createHost(); + $servers = $host->serverList(['virtualserver_status' => 'online']); + + $this->assertCount(1, $servers); + $this->assertInstanceOf(Server::class, $servers[1]); + $this->assertSame('Main Server', $host->serverGetByName('Main Server')->toString()); + $this->assertSame('offline-uid', $host->serverGetByUid('offline-uid')->getProperty('virtualserver_unique_identifier')->toString()); + $this->assertSame(9987, $host->serverGetPortById(1)); + } + + public function testCachesVersionAndWhoamiValues(): void + { + $host = $this->createHost(); + $this->assertSame('3.13.7', $host->version('version')->toString()); + $this->assertSame('Linux', $host->version('platform')->toString()); + $this->assertSame(1, $host->serverSelectedId()); + $this->assertSame(9987, $host->serverSelectedPort()); + + $host->whoamiSet('client_nickname', 'tester'); + $this->assertSame('tester', $host->whoamiGet('client_nickname')->toString()); + $this->assertSame('fallback', $host->whoamiGet('missing', 'fallback')); + } + + public function testServerLookupReportsUnknownNames(): void + { + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x400); + $this->createHost()->serverGetByName('missing'); + } + + public function testServerStatusAndIdentityHelpers(): void + { + $server = new Server($this->createHost(), [ + 'virtualserver_id' => 7, 'virtualserver_name' => 'Offline', 'virtualserver_status' => 'offline', + 'virtualserver_clientsonline' => 5, 'virtualserver_queryclientsonline' => 1, 'virtualserver_maxclients' => 10, + 'virtualserver_flag_password' => 0, + ]); + + $this->assertTrue($server->isOffline()); + $this->assertSame(0, $server->clientCount()); + $this->assertSame('ts3_h_s7', $server->getUniqueId()); + $this->assertSame('server_open', $server->getIcon()); + $this->assertSame('$', $server->getSymbol()); + } +} + +class FixtureServerQuery extends ServerQuery +{ + public function __construct() + { + } + + public function __destruct() + { + } + + public function request(string $cmd, bool $throw = true): Reply + { + $response = match (explode(' ', $cmd)[0]) { + 'version' => 'version=3.13.7 platform=Linux', + 'whoami' => 'virtualserver_id=1 virtualserver_port=9987 client_nickname=serveradmin', + 'serverlist' => 'virtualserver_id=1 virtualserver_port=9987 virtualserver_name=Main\\sServer virtualserver_status=online virtualserver_unique_identifier=main-uid|virtualserver_id=2 virtualserver_port=9988 virtualserver_name=Offline virtualserver_status=offline virtualserver_unique_identifier=offline-uid', + default => '', + }; + + return new Reply([new StringHelper($response), new StringHelper('error id=0 msg=ok')], $cmd, null, $throw); + } + + public function prepare(string $cmd, array $params = []): string + { + return $cmd; + } +} diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php new file mode 100644 index 00000000..d79d1bce --- /dev/null +++ b/tests/Node/NodeTest.php @@ -0,0 +1,93 @@ + 'root', 'traffic_bytes_sent' => 1024, 'connection_packets_sent' => 1234, + 'connection_packetloss_total' => '0.125', 'virtualserver_uptime' => 90, + 'virtualserver_version' => '3.0.13.6 [Build: 1478594913]', 'client_icon_id' => -1, + ]); + + $info = $node->getInfo(false, true); + $this->assertSame('1 KiB', $info['traffic_bytes_sent']); + $this->assertSame('1.234', $info['connection_packets_sent']); + $this->assertSame('12.50%', $info['connection_packetloss_total']); + $this->assertSame('0D 00:01:30', $info['virtualserver_uptime']); + $this->assertSame('4294967295', $info['client_icon_id']->toString()); + $this->assertTrue($node->iconIsLocal('client_icon_id') === false); + $this->assertSame('/icon_4294967295', $node->iconGetName('client_icon_id')->toString()); + } + + public function testImplementsArrayAccessAndRecursiveIteration(): void + { + $child = new TestNode(2, ['name' => 'child']); + $node = new TestNode(1, ['name' => 'root'], [$child]); + + $this->assertSame('root', $node['name']); + $this->assertSame(1, $node->count()); + $this->assertTrue($node->valid()); + $this->assertSame(0, $node->key()); + $this->assertSame($child, $node->current()); + $this->assertTrue($node->hasChildren() === false); + $this->assertFalse($node->hasNext()); + $node->next(); + $this->assertFalse($node->valid()); + $node->rewind(); + unset($node['name']); + $this->assertFalse(isset($node['name'])); + } + + public function testRejectsMissingPropertiesAndReadOnlyMutation(): void + { + $node = new TestNode(1, []); + + $this->expectException(NodeException::class); + $node['missing']; + } + + public function testRendersItsTreeWithTextViewer(): void + { + $node = new TestNode(1, ['name' => 'root'], [new TestNode(2, ['name' => 'child'])]); + + $this->assertSame("* root\n\\-* child\n", $node->getViewer(new Text())); + } +} + +class TestNode extends Node +{ + public function __construct(int $id, array $info, array $children = []) + { + $this->nodeId = $id; + $this->nodeInfo = $info; + $this->nodeList = $children; + } + + public function getUniqueId(): string + { + return 'test_' . $this->nodeId; + } + + public function getIcon(): string + { + return 'test'; + } + + public function getSymbol(): string + { + return '*'; + } + + public function __toString(): string + { + return $this->nodeInfo['name'] ?? 'unnamed'; + } +} diff --git a/tests/Node/ServerViewerPermissionTest.php b/tests/Node/ServerViewerPermissionTest.php new file mode 100644 index 00000000..0f46ef50 --- /dev/null +++ b/tests/Node/ServerViewerPermissionTest.php @@ -0,0 +1,92 @@ + 1, + 'virtualserver_name' => 'Server', + ]); + $server->setChannels([ + new PermissionAwareChannel($server, ['cid' => 1, 'pid' => 0, 'channel_name' => 'Visible'], false), + new PermissionAwareChannel($server, ['cid' => 2, 'pid' => 0, 'channel_name' => 'Restricted'], true), + ]); + + $viewer = $server->getViewer(new Text()); + + $this->assertStringContainsString('Visible', $viewer); + $this->assertStringNotContainsString('Restricted', $viewer); + } + + public function testViewerDoesNotHideUnexpectedChannelErrors(): void + { + $server = new PermissionAwareServer(new Host(new HostTestFixtureQuery()), [ + 'virtualserver_id' => 1, + 'virtualserver_name' => 'Server', + ]); + $server->setChannels([ + new PermissionAwareChannel($server, ['cid' => 1, 'pid' => 0, 'channel_name' => 'Broken'], false, 0x300), + ]); + + $this->expectException(ServerQueryException::class); + $this->expectExceptionCode(0x300); + $server->getViewer(new Text()); + } +} + +class HostTestFixtureQuery extends \PlanetTeamSpeak\TeamSpeak3Framework\Adapter\ServerQuery +{ + public function __construct() + { + } + + public function __destruct() + { + } +} + +class PermissionAwareServer extends Server +{ + private array $channels = []; + + public function setChannels(array $channels): void + { + $this->channels = $channels; + } + + public function channelList(array $filter = []): array + { + return $this->channels; + } +} + +class PermissionAwareChannel extends Channel +{ + public function __construct(Server $server, array $info, private bool $restricted, private ?int $errorCode = null) + { + parent::__construct($server, $info); + } + + public function count(): int + { + if ($this->errorCode !== null) { + throw new ServerQueryException('unexpected channel failure', $this->errorCode); + } + + if ($this->restricted) { + throw new ServerQueryException('insufficient client permissions', 0xA08); + } + + return 0; + } +} diff --git a/tests/TeamSpeak3Test.php b/tests/TeamSpeak3Test.php new file mode 100644 index 00000000..c654da83 --- /dev/null +++ b/tests/TeamSpeak3Test.php @@ -0,0 +1,30 @@ +assertInstanceOf(Host::class, $node); + $this->assertSame('127.0.0.1', $node->getParent()->getTransportHost()); + $this->assertSame('10011', $node->getParent()->getTransportPort()); + } + + public function testEscapePatternsAndDumpAreAvailable(): void + { + $patterns = TeamSpeak3::getEscapePatterns(); + $this->assertArrayHasKey(' ', $patterns); + $this->assertSame('\\s', $patterns[' ']); + + $dump = TeamSpeak3::dump(['value' => ''], false); + $this->assertStringContainsString("array(1)", $dump); + $this->assertStringContainsString("", $dump); + } +} diff --git a/tests/Transport/TCPTest.php b/tests/Transport/TCPTest.php index 98f5b1bc..cac9dcb4 100644 --- a/tests/Transport/TCPTest.php +++ b/tests/Transport/TCPTest.php @@ -179,9 +179,15 @@ public function testConnectBadHost() */ public function testConnectHostRefuseConnection() { - $transport = new TCP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = new class (['host' => '127.0.0.1', 'port' => 12345]) extends TCP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $errno = 111; + $errstr = 'Connection refused'; + + return false; + } + }; $this->expectException(TransportException::class); $this->expectExceptionMessage('Connection refused'); $transport->connect(); diff --git a/tests/Transport/UDPTest.php b/tests/Transport/UDPTest.php index a53ffc62..6d4300c6 100644 --- a/tests/Transport/UDPTest.php +++ b/tests/Transport/UDPTest.php @@ -9,6 +9,16 @@ class UDPTest extends TestCase { + private function createConnectedTransport(): UDP + { + return new class (['host' => '127.0.0.1', 'port' => 12345]) extends UDP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout): mixed + { + return fopen('php://temp', 'r+'); + } + }; + } + /** * @throws TransportException */ @@ -95,9 +105,7 @@ public function testGetStream() */ public function testConnect() { - $transport = new UDP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = $this->createConnectedTransport(); $transport->connect(); $this->assertIsResource($transport->getStream()); } @@ -125,9 +133,7 @@ public function testConnectBadHost() */ public function testDisconnect() { - $transport = new UDP( - ['host' => '127.0.0.1', 'port' => 12345] - ); + $transport = $this->createConnectedTransport(); $transport->connect(); $this->assertIsResource($transport->getStream()); $transport->disconnect(); diff --git a/tests/Viewer/ViewerTest.php b/tests/Viewer/ViewerTest.php new file mode 100644 index 00000000..6de241ed --- /dev/null +++ b/tests/Viewer/ViewerTest.php @@ -0,0 +1,129 @@ + '127.0.0.1', 'port' => 10011])); + + return new ViewerServer($host, [ + 'virtualserver_id' => 1, + 'virtualserver_name' => 'Example ', + 'virtualserver_status' => 'online', + 'virtualserver_clientsonline' => 4, + 'virtualserver_queryclientsonline' => 1, + 'virtualserver_maxclients' => 32, + 'virtualserver_uptime' => 90, + 'virtualserver_icon_id' => 100, + 'virtualserver_welcomemessage' => ' Welcome ', + 'virtualserver_hostmessage' => '', + 'virtualserver_version' => '3.0.13.6 [Build: 1478594913]', + 'virtualserver_platform' => new \PlanetTeamSpeak\TeamSpeak3Framework\Helper\StringHelper('Linux'), + 'virtualserver_flag_password' => 1, + 'virtualserver_autostart' => 1, + 'virtualserver_weblist_enabled' => 1, + 'virtualserver_ask_for_privilegekey' => 1, + ]); + } + + public function testHtmlViewerRendersEscapedServerInformationAndIcons(): void + { + $html = (new Html('icons/'))->fetchObject($this->createServer()); + + $this->assertStringContainsString("id='ts3_h_s1'", $html); + $this->assertStringContainsString('Example <Server>', $html); + $this->assertStringContainsString("icons/server_pass.png", $html); + $this->assertStringContainsString("icons/group_icon_100.png", $html); + $this->assertStringContainsString('Clients: 3/32', $html); + } + + public function testJsonViewerProducesStructuredServerData(): void + { + $data = []; + $viewer = new Json($data); + $this->assertSame('', $viewer->fetchObject($this->createServer())); + + $this->assertCount(1, $data); + $this->assertSame('ts3_s1', $data[0]->ident); + $this->assertSame('server', $data[0]->class); + $this->assertSame('Example ', $data[0]->name); + $this->assertSame(31, $data[0]->props->flags); + $this->assertSame('Welcome', $data[0]->props->welcmsg); + $this->assertSame('Linux', $data[0]->props->platform); + $this->assertSame('server-pass', $data[0]->image); + $this->assertJsonStringEqualsJsonString(json_encode($data), $viewer->toString()); + } + + public function testViewersRenderChannelStateAndMetadata(): void + { + $channel = new ViewerChannel($this->createServer(), [ + 'cid' => 2, 'pid' => 0, 'channel_name' => 'Music', 'channel_topic' => ' Topic ', + 'channel_codec' => 5, 'channel_codec_quality' => 10, 'channel_icon_id' => 200, + 'channel_maxclients' => 10, 'channel_maxfamilyclients' => -1, + 'total_clients' => 2, 'total_clients_family' => -1, + 'channel_flag_default' => 1, 'channel_flag_password' => 1, + 'channel_flag_permanent' => 1, 'channel_flag_semi_permanent' => 0, + 'channel_needed_talk_power' => 5, + ]); + + $html = (new Html('icons/'))->fetchObject($channel, [true]); + $this->assertStringContainsString("id='ts3_h_s1_ch2'", $html); + $this->assertStringContainsString('channel_flag_music.png', $html); + $this->assertStringContainsString('channel_flag_moderated.png', $html); + + $data = []; + (new Json($data))->fetchObject($channel, [true]); + $this->assertSame('ts3_c2', $data[0]->ident); + $this->assertSame('ts3_s1', $data[0]->parent); + $this->assertSame('Music', $data[0]->name); + $this->assertSame('Music', $data[0]->props->path); + $this->assertSame('Topic', $data[0]->props->topic); + $this->assertSame(119, $data[0]->props->flags); + } +} + +class ViewerServer extends Server +{ + public function count(): int + { + return 0; + } + + public function clientCount(): int + { + return 3; + } + + public function channelGetLevel(int $cid): int + { + return 0; + } + + public function channelGetPathway(int $cid): string + { + return 'Music'; + } + + public function channelIsSpacer(Channel $channel): bool + { + return false; + } +} + +class ViewerChannel extends Channel +{ + public function count(): int + { + return 0; + } +}