<?phpnamespace Tests;use App\Chat\ChatServer;use App\Chat\RoomStore;use App\Chat\TempId;use App\Net\TcpServer;use PHPUnit\Framework\TestCase;/** The real thing: a server on a TCP port of this machine, two clients talking through it. */final class TcpServerTest extends TestCase{ private TcpServer $server; private string $address; private string $file; protected function setUp(): void { if (PHP_INT_SIZE === 4 || getenv('JUMPHP_WEB_TERMINAL')) { self::markTestSkipped('no listening socket in a browser'); } $this->file = sys_get_temp_dir() . '/caramail-tcp-' . bin2hex(random_bytes(4)) . '.json'; $this->server = new TcpServer(new ChatServer(new RoomStore($this->file))); $this->address = $this->server->listen('127.0.0.1', 0); $this->wsAddress = $this->server->listenWebSocket('127.0.0.1', 0); } private string $wsAddress; /** A masked client frame. */ private static function wsFrame(int $opcode, string $payload): string { $mask = random_bytes(4); $masked = ''; for ($i = 0; $i < strlen($payload); $i++) { $masked .= $payload[$i] ^ $mask[$i % 4]; } $len = strlen($payload); return chr(0x80 | $opcode) . ($len < 126 ? chr(0x80 | $len) : chr(0x80 | 126) . pack('n', $len)) . $mask . $masked; } /** @return list<array{0: int, 1: string}> the server's frames read until one matches $until */ private function wsRead($client, string &$buffer, \Closure $until): array { $frames = []; for ($i = 0; $i < 200; $i++) { $this->server->pollOnce(0.01); $buffer .= (string)fread($client, 65536); while (strlen($buffer) >= 2) { $len = ord($buffer[1]) & 0x7F; $pos = 2; if ($len === 126) { if (strlen($buffer) < 4) { break; } $len = unpack('n', substr($buffer, 2, 2))[1]; $pos = 4; } if (strlen($buffer) < $pos + $len) { break; } $frames[] = $frame = [ord($buffer[0]) & 0x0F, substr($buffer, $pos, $len)]; $buffer = (string)substr($buffer, $pos + $len); if ($until($frame)) { return $frames; } } } self::fail('no expected frame; got ' . json_encode($frames)); } protected function tearDown(): void { @unlink($this->file); } /** @return resource */ private function client() { $c = stream_socket_client('tcp://' . $this->address, $errno, $error, 2); self::assertNotFalse($c, $error); stream_set_blocking($c, false); return $c; } /** Runs the server until $client got a line starting with $code; returns that line. */ private function waitFor($client, string $code, string &$buffer): string { for ($i = 0; $i < 200; $i++) { $this->server->pollOnce(0.01); $buffer .= (string)fread($client, 65536); foreach (explode("\n", $buffer) as $n => $line) { if (str_starts_with($line, $code . "\t")) { $buffer = implode("\n", array_slice(explode("\n", $buffer), $n + 1)); return $line; } } } self::fail("no $code frame; got: " . json_encode($buffer)); } public function testAWebSocketClientChatsWithATcpClient(): void { $ws = stream_socket_client('tcp://' . $this->wsAddress, $errno, $error, 2); stream_set_blocking($ws, false); fwrite($ws, "GET /ws HTTP/1.1\r\nHost: chat\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"); $buf = ''; for ($i = 0; $i < 100 && !str_contains($buf, "\r\n\r\n"); $i++) { $this->server->pollOnce(0.01); $buf .= (string)fread($ws, 65536); } self::assertStringContainsString('s3pPLMBiTxaQ9kYGzzhZRbK+xOo=', $buf, 'the RFC 6455 accept key'); $buf = (string)substr($buf, strpos($buf, "\r\n\r\n") + 4); $this->wsRead($ws, $buf, fn ($f) => $f[0] === 1 && str_contains($f[1], 'hello')); fwrite($ws, self::wsFrame(1, '{"t":"join","room":"CHAT"}')); $this->wsRead($ws, $buf, fn ($f) => $f[0] === 1 && str_contains($f[1], '"room"')); $frames = $this->wsRead($ws, $buf, fn ($f) => $f[0] === 2 && str_contains($f[1], 'NewCaraChat')); self::assertSame("\x00100\t" . ChatServer::BANNER . "\n", end($frames)[1], 'peer 0 + the banner frame'); fwrite($ws, self::wsFrame(2, "\x00" . "0\t0\t" . TempId::make('Web', 30, 0, 'Nice') . "\n20\tAccueil\t0\n")); $this->wsRead($ws, $buf, fn ($f) => $f[0] === 2 && str_starts_with($f[1], "\x0020\t")); $tcp = $this->client(); $tb = ''; $this->waitFor($tcp, '100', $tb); fwrite($tcp, "0\t0\t" . TempId::make('Tcp', 40, 1, 'Brest') . "\n20\tAccueil\t0\n70\t0100Coucou le web\n"); $frames = $this->wsRead($ws, $buf, fn ($f) => $f[0] === 2 && str_contains($f[1], 'Coucou le web')); self::assertStringStartsWith("\x0070\t", end($frames)[1]); fclose($ws); fclose($tcp); } public function testTwoClientsChatInARoomOverTcp(): void { $a = $this->client(); $b = $this->client(); $ba = $bb = ''; self::assertSame("100\t" . ChatServer::BANNER, $this->waitFor($a, '100', $ba)); $this->waitFor($b, '100', $bb); fwrite($a, "0\t0\t" . TempId::make('Alice', 24, 1, 'Lyon') . "\n"); $this->waitFor($a, '56', $ba); // a frame cut in two writes is put back together fwrite($b, "0\t0\t" . TempId::make('Bob', 30, 0, 'Paris')); $this->server->pollOnce(0.01); fwrite($b, "\n"); $this->waitFor($b, '56', $bb); fwrite($a, "20\tAccueil\t0\n"); $this->waitFor($a, '20', $ba); fwrite($b, "20\tAccueil\t0\n"); $this->waitFor($b, '20', $bb); fwrite($b, "70\t0100Bonsoir\n"); self::assertStringEndsWith("\t0\tBob\t0100Bonsoir\t", $this->waitFor($a, '70', $ba)); self::assertSame(2, $this->server->clientCount()); fclose($b); self::assertStringStartsWith("85\t1\t", $this->waitFor($a, '85', $ba)); self::assertSame(1, $this->server->clientCount()); fclose($a); }}