summaryrefslogtreecommitdiffstats
path: root/tests/TestServer.php
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
commit64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch)
treecb4fc56eeb6ecce53854f9c530609cf01ab3349f /tests/TestServer.php
downloadphp-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests/TestServer.php')
-rw-r--r--tests/TestServer.php148
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);
+ }
+}