'test', 'INBOX_SECRET' => 'test-secret', 'INBOX_STORAGE_BACKEND' => $backend, 'INBOX_STORAGE_DIR' => $storageDir, 'INBOX_CORS_ALLOW_ORIGINS' => '*', ]; $server = startServer($env); try { testRootEndpoint($server['baseUrl'], $backend); testUnauthorizedShare($server['baseUrl']); testEmptyBodyRejected($server['baseUrl']); testOptionsRequest($server['baseUrl']); testShareAndInboxFlow($server['baseUrl'], $backend); } finally { stopServer($server['process']); resetDirectory($storageDir); } } function testRootEndpoint(string $baseUrl, string $backend): void { $response = request('GET', $baseUrl . '/'); assertStatus(200, $response, 'root endpoint returns 200'); $body = decodeJson($response); assertTrue(($body['ok'] ?? false) === true, 'root endpoint returns ok=true'); assertSame('share-inbox-service', $body['service'] ?? null, 'root endpoint returns service name'); assertSame($backend, $body['storageBackend'] ?? null, 'root endpoint reports active backend'); } function testUnauthorizedShare(string $baseUrl): void { $response = request( 'POST', $baseUrl . '/share', ['Content-Type: application/json'], '{"type":"url","content":"https://example.com/unauthorized"}' ); assertStatus(401, $response, 'missing secret returns 401'); $body = decodeJson($response); assertSame('Unauthorized', $body['error'] ?? null, 'missing secret returns unauthorized error'); } function testEmptyBodyRejected(string $baseUrl): void { $response = request( 'POST', $baseUrl . '/share', [ 'Content-Type: application/json', 'X-Inbox-Secret: test-secret', ], '' ); assertStatus(400, $response, 'empty body returns 400'); $body = decodeJson($response); assertSame('Request body is empty', $body['error'] ?? null, 'empty body returns clear message'); } function testOptionsRequest(string $baseUrl): void { $response = request('OPTIONS', $baseUrl . '/share'); assertStatus(200, $response, 'options request returns 200'); assertSame('*', headerValue($response['headers'], 'Access-Control-Allow-Origin'), 'options returns CORS origin'); assertSame('GET, POST, OPTIONS', headerValue($response['headers'], 'Access-Control-Allow-Methods'), 'options returns allowed methods'); } function testShareAndInboxFlow(string $baseUrl, string $backend): void { $jsonResponse = request( 'POST', $baseUrl . '/share', [ 'Content-Type: application/json', 'X-Inbox-Secret: test-secret', ], '{"type":"url","content":"https://example.com/' . $backend . '-json"}' ); assertStatus(200, $jsonResponse, 'json share returns 200'); $jsonBody = decodeJson($jsonResponse); assertTrue(isset($jsonBody['id']) && $jsonBody['id'] !== '', 'json share returns id'); usleep(200000); $textResponse = request( 'POST', $baseUrl . '/share', [ 'Content-Type: text/plain; charset=utf-8', 'X-Inbox-Secret: test-secret', ], 'hello ' . $backend ); assertStatus(200, $textResponse, 'text share returns 200'); $inboxResponse = request('GET', $baseUrl . '/inbox?limit=10'); assertStatus(200, $inboxResponse, 'inbox returns 200'); $inbox = decodeJson($inboxResponse); assertSame(2, $inbox['total'] ?? null, 'inbox total matches stored entries'); assertSame(2, count($inbox['items'] ?? []), 'inbox returns two items'); $latest = $inbox['items'][0] ?? []; $older = $inbox['items'][1] ?? []; assertSame('text/plain', $latest['contentType'] ?? null, 'latest item preserves normalized text content type'); assertSame('hello ' . $backend, $latest['body']['text'] ?? null, 'latest item stores text body'); assertSame('application/json', $older['contentType'] ?? null, 'older item preserves json content type'); assertSame('https://example.com/' . $backend . '-json', $older['body']['content'] ?? null, 'older item stores json body'); } function startServer(array $env): array { $port = findFreePort(); $command = escapeshellarg(PHP_BINARY) . ' -S 127.0.0.1:' . $port . ' -t ' . escapeshellarg(PUBLIC_DIR) . ' ' . escapeshellarg(PUBLIC_DIR . '/index.php'); $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($command, $descriptors, $pipes, PROJECT_ROOT, array_merge($_ENV, $env)); if (!is_resource($process)) { throw new RuntimeException('Could not start PHP test server'); } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $baseUrl = 'http://127.0.0.1:' . $port; $deadline = microtime(true) + 5.0; while (microtime(true) < $deadline) { $status = proc_get_status($process); if (!$status['running']) { $stderr = stream_get_contents($pipes[2]); throw new RuntimeException('PHP test server exited early: ' . trim((string) $stderr)); } $response = @file_get_contents($baseUrl . '/'); if ($response !== false) { fclose($pipes[1]); fclose($pipes[2]); return [ 'process' => $process, 'baseUrl' => $baseUrl, ]; } usleep(100000); } stopServer($process); throw new RuntimeException('PHP test server did not become ready in time'); } function stopServer($process): void { if (!is_resource($process)) { return; } $status = proc_get_status($process); if ($status['running']) { proc_terminate($process); usleep(200000); $status = proc_get_status($process); if ($status['running']) { proc_terminate($process, 9); } } proc_close($process); } function findFreePort(): int { for ($port = 18080; $port < 18120; $port++) { $socket = @stream_socket_server('tcp://127.0.0.1:' . $port, $errno, $errstr); if ($socket !== false) { fclose($socket); return $port; } } throw new RuntimeException('Could not find free localhost port for tests'); } function request(string $method, string $url, array $headers = [], string $body = ''): array { $context = stream_context_create([ 'http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body, 'ignore_errors' => true, 'timeout' => 5, ], ]); $responseBody = file_get_contents($url, false, $context); $responseHeaders = $http_response_header ?? []; if ($responseBody === false && $responseHeaders === []) { throw new RuntimeException('HTTP request failed: ' . $method . ' ' . $url); } return [ 'status' => extractStatusCode($responseHeaders), 'headers' => $responseHeaders, 'body' => $responseBody === false ? '' : $responseBody, ]; } function extractStatusCode(array $headers): int { if ($headers === []) { throw new RuntimeException('Missing HTTP response headers'); } if (!preg_match('/\s(\d{3})\s/', $headers[0], $matches)) { throw new RuntimeException('Could not parse HTTP status from response'); } return (int) $matches[1]; } function decodeJson(array $response): array { $decoded = json_decode($response['body'], true); if (!is_array($decoded)) { throw new RuntimeException('Expected JSON object response, got: ' . $response['body']); } return $decoded; } function headerValue(array $headers, string $name): ?string { $prefix = strtolower($name) . ':'; foreach ($headers as $header) { if (str_starts_with(strtolower($header), $prefix)) { return trim(substr($header, strlen($prefix))); } } return null; } function resetDirectory(string $path): void { if (!is_dir($path)) { mkdir($path, 0775, true); return; } $items = scandir($path); if ($items === false) { throw new RuntimeException('Could not read test directory: ' . $path); } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $file = $path . '/' . $item; if (is_file($file) && !unlink($file)) { throw new RuntimeException('Could not remove test file: ' . $file); } } } function assertStatus(int $expected, array $response, string $message): void { assertSame($expected, $response['status'], $message . ' (response body: ' . $response['body'] . ')'); } function assertSame(mixed $expected, mixed $actual, string $message): void { if ($expected !== $actual) { throw new RuntimeException( $message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) ); } } function assertTrue(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }