diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
| commit | 64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch) | |
| tree | cb4fc56eeb6ecce53854f9c530609cf01ab3349f /tests/TestServer.php | |
| download | php-1fd1470890518a68130b3b9058fe1bebddca9f07.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests/TestServer.php')
| -rw-r--r-- | tests/TestServer.php | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/tests/TestServer.php b/tests/TestServer.php new file mode 100644 index 0000000..b0b384f --- /dev/null +++ b/tests/TestServer.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +/** + * A throwaway HTTP server on a random loopback port, run with PHP's built-in + * server. + * + * The suite points a real {@see \Cepis\Client} at this over a real socket. No + * test stubs the transport to exercise the client, so the code path under test + * is the one that runs in production, request construction and error body + * reading included. It doubles as the test that `baseUrl` works: if it did + * not, none of the suite would run. + * + * The server process and the test process share state through two files, since + * they are separate processes: `routes.json`, which the test writes, and + * `calls.log`, which the server appends to. + */ +final class TestServer +{ + public readonly string $url; + + /** @var resource */ + private $process; + + private string $stateDir; + + public function __construct() + { + $this->stateDir = sys_get_temp_dir() . '/cepis-test-' . bin2hex(random_bytes(8)); + if (!mkdir($this->stateDir, 0o700, true) && !is_dir($this->stateDir)) { + throw new \RuntimeException('could not create ' . $this->stateDir); + } + file_put_contents($this->stateDir . '/routes.json', '{}'); + file_put_contents($this->stateDir . '/calls.log', ''); + + $port = self::freePort(); + $this->url = 'http://127.0.0.1:' . $port; + + $process = proc_open( + [PHP_BINARY, '-S', '127.0.0.1:' . $port, __DIR__ . '/server/router.php'], + [ + 0 => ['pipe', 'r'], + 1 => ['file', $this->stateDir . '/server.log', 'a'], + 2 => ['file', $this->stateDir . '/server.log', 'a'], + ], + $pipes, + __DIR__ . '/server', + ['CEPIS_TEST_STATE' => $this->stateDir, 'PATH' => getenv('PATH') ?: '/usr/bin'], + ); + if (!is_resource($process)) { + throw new \RuntimeException('could not start the built-in server'); + } + $this->process = $process; + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + $this->waitUntilListening($port); + } + + /** + * Register the responses for a request URI. + * + * Each request consumes the next entry, which is how retrying is tested; + * the last entry repeats once the list runs out. A route registered under + * `"*"` answers any URI that has none of its own. + * + * @param list<array<string, mixed>> $responses + */ + public function respond(string $uri, array $responses): void + { + $routes = json_decode((string) file_get_contents($this->stateDir . '/routes.json'), true); + $routes = is_array($routes) ? $routes : []; + $routes[$uri] = $responses; + file_put_contents( + $this->stateDir . '/routes.json', + json_encode($routes, JSON_THROW_ON_ERROR), + ); + } + + /** + * Every request URI the server has seen, in order. + * + * @return list<string> + */ + public function calls(): array + { + $raw = (string) file_get_contents($this->stateDir . '/calls.log'); + + return array_values(array_filter(explode("\n", $raw), 'strlen')); + } + + /** + * How many requests so far began with `$prefix`. + */ + public function countCalls(string $prefix): int + { + return count(array_filter( + $this->calls(), + static fn (string $uri): bool => str_starts_with($uri, $prefix), + )); + } + + public function stop(): void + { + if (is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + foreach (['routes.json', 'calls.log', 'server.log'] as $name) { + @unlink($this->stateDir . '/' . $name); + } + @rmdir($this->stateDir); + } + + private function waitUntilListening(int $port): void + { + $deadline = microtime(true) + 15.0; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.2); + if (is_resource($socket)) { + fclose($socket); + + return; + } + usleep(20_000); + } + + throw new \RuntimeException('the built-in server never started listening on ' . $port); + } + + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $code, $message); + if ($socket === false) { + throw new \RuntimeException('could not bind a loopback socket: ' . $message); + } + $name = (string) stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr($name, (int) strrpos($name, ':') + 1); + } +} |