1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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);
}
}
|