Files
api/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRelayExecutionTimerTest.php
T

256 lines
9.1 KiB
PHP

<?php
function edge_gateway_relay_timer_free_port(): int
{
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
if ($socket === false) {
throw new RuntimeException('Unable to allocate test port: ' . $errstr);
}
$address = (string)stream_socket_get_name($socket, false);
fclose($socket);
return (int)substr(strrchr($address, ':'), 1);
}
/**
* @return array{process:resource,pipes:array<int,resource>}
*/
function edge_gateway_relay_timer_start_php_server(int $port, string $router, array $environment = []): array
{
$command = escapeshellarg(PHP_BINARY) . ' -S 127.0.0.1:' . $port . ' ' . escapeshellarg($router);
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$processEnvironment = [];
foreach (array_merge($_ENV, $_SERVER, $environment) as $key => $value) {
if (is_scalar($value) || $value === null) {
$processEnvironment[(string)$key] = (string)$value;
}
}
$process = proc_open(
$command,
$descriptors,
$pipes,
app_path(),
$processEnvironment
);
if (!is_resource($process)) {
throw new RuntimeException('Unable to start PHP test server');
}
foreach ($pipes as $pipe) {
stream_set_blocking($pipe, false);
}
$deadline = microtime(true) + 5.0;
do {
set_error_handler(static fn(): bool => true);
$connection = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
restore_error_handler();
if (is_resource($connection)) {
fclose($connection);
return ['process' => $process, 'pipes' => $pipes];
}
usleep(50000);
} while (microtime(true) < $deadline);
edge_gateway_relay_timer_stop_php_server(['process' => $process, 'pipes' => $pipes]);
throw new RuntimeException('Timed out waiting for PHP test server on port ' . $port);
}
/**
* @param array{process:resource,pipes:array<int,resource>}|null $server
*/
function edge_gateway_relay_timer_stop_php_server(?array $server): void
{
if ($server === null) {
return;
}
foreach ($server['pipes'] as $pipe) {
if (is_resource($pipe)) {
fclose($pipe);
}
}
if (is_resource($server['process'])) {
proc_terminate($server['process']);
proc_close($server['process']);
}
}
/**
* @return array{status:int,body:array<string,mixed>,raw:string}
*/
function edge_gateway_relay_timer_post_json(int $port, string $path, array $payload): array
{
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", [
'Content-Type: application/json',
'X-TruckWash-Worker-Token: worker-token',
]) . "\r\n",
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES),
'ignore_errors' => true,
'timeout' => 5,
],
]);
$raw = file_get_contents('http://127.0.0.1:' . $port . $path, false, $context);
if ($raw === false) {
throw new RuntimeException('Worker request failed');
}
$status = 0;
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
$status = (int)$matches[1];
break;
}
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Worker returned invalid JSON: ' . $raw);
}
return ['status' => $status, 'body' => $decoded, 'raw' => $raw];
}
function edge_gateway_relay_timer_fake_shelly_router(string $directory): string
{
$router = $directory . DIRECTORY_SEPARATOR . 'fake-shelly.php';
file_put_contents($router, <<<'PHP'
<?php
$logPath = (string)getenv('SHELLY_REQUEST_LOG');
if ($logPath !== '') {
file_put_contents($logPath, (string)($_SERVER['REQUEST_URI'] ?? '/') . PHP_EOL, FILE_APPEND);
}
header('Content-Type: application/json; charset=utf-8');
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
if (str_contains($path, '/rpc/Switch.Set') || str_contains($path, '/rpc/Switch.GetStatus')) {
echo json_encode(['output' => true], JSON_UNESCAPED_SLASHES);
return;
}
if (preg_match('#/relay/\d+#', $path) === 1) {
echo json_encode(['ison' => true], JSON_UNESCAPED_SLASHES);
return;
}
echo json_encode(['ok' => true], JSON_UNESCAPED_SLASHES);
PHP);
return $router;
}
it('passes direct relay switch timers through the LAN worker and prefers legacy timers for Gen1 relays', function (): void {
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-timer-' . bin2hex(random_bytes(6));
mkdir($tempDir, 0777, true);
$logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log';
$fakeServer = null;
$workerServer = null;
try {
$fakePort = edge_gateway_relay_timer_free_port();
$workerPort = edge_gateway_relay_timer_free_port();
$fakeServer = edge_gateway_relay_timer_start_php_server(
$fakePort,
edge_gateway_relay_timer_fake_shelly_router($tempDir),
['SHELLY_REQUEST_LOG' => $logPath]
);
$workerServer = edge_gateway_relay_timer_start_php_server(
$workerPort,
app_path('resources/edge-gateway-agent/lan-worker.php'),
['TRUCKWASH_WORKER_TOKEN' => 'worker-token']
);
$response = edge_gateway_relay_timer_post_json($workerPort, '/relay/switch', [
'local_ip' => '127.0.0.1:' . $fakePort,
'channel' => 0,
'on' => true,
'timer' => 3,
'device_generation' => 1,
]);
$requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
expect($response['status'])->toBe(200)
->and($response['body']['on'])->toBeTrue()
->and($requests[0] ?? null)->toBe('/relay/0?turn=on&timer=3');
} finally {
edge_gateway_relay_timer_stop_php_server($workerServer);
edge_gateway_relay_timer_stop_php_server($fakeServer);
array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []);
@rmdir($tempDir);
}
});
it('caps batch relay switch timers and sends Gen3 relays through Shelly RPC first', function (): void {
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-batch-' . bin2hex(random_bytes(6));
mkdir($tempDir, 0777, true);
$logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log';
$fakeServer = null;
$workerServer = null;
try {
$fakePort = edge_gateway_relay_timer_free_port();
$workerPort = edge_gateway_relay_timer_free_port();
$fakeServer = edge_gateway_relay_timer_start_php_server(
$fakePort,
edge_gateway_relay_timer_fake_shelly_router($tempDir),
['SHELLY_REQUEST_LOG' => $logPath]
);
$workerServer = edge_gateway_relay_timer_start_php_server(
$workerPort,
app_path('resources/edge-gateway-agent/lan-worker.php'),
['TRUCKWASH_WORKER_TOKEN' => 'worker-token']
);
$response = edge_gateway_relay_timer_post_json($workerPort, '/relay/batch-switch', [
'batch_id' => 'batch-1',
'commands' => [[
'target' => 'EXIT',
'relay_id' => 'relay-out',
'local_ip' => '127.0.0.1:' . $fakePort,
'channel' => 0,
'on' => true,
'toggle_after' => 999,
'device_model' => 'S3SW-001X8EU',
]],
]);
$requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
expect($response['status'])->toBe(200)
->and($response['body']['results'][0]['ok'])->toBeTrue()
->and($response['body']['results'][0]['relay_id'])->toBe('relay-out')
->and($requests[0] ?? null)->toBe('/rpc/Switch.Set?id=0&on=true&toggle_after=5');
} finally {
edge_gateway_relay_timer_stop_php_server($workerServer);
edge_gateway_relay_timer_stop_php_server($fakeServer);
array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []);
@rmdir($tempDir);
}
});
it('keeps PHP edge-agent relay fallback propagation aligned with LAN worker timer handling', function (): void {
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($agentSource)->toContain('private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;')
->and($agentSource)->toContain('$workerPayload[\'toggle_after\'] = $toggleAfter;')
->and($agentSource)->toContain('$workerPayload[\'device_generation\'] = $deviceGeneration;')
->and($agentSource)->toContain('private function resolveRelayToggleAfterSeconds(array $request): ?int')
->and($agentSource)->toContain('private function resolveShellyCommandGeneration(array $request): ?int')
->and($agentSource)->toContain('$deviceGeneration === 1')
->and($agentSource)->toContain('rawurlencode((string)$toggleAfter)')
->and($agentSource)->toContain("'&timer='");
});