2026-03-17 15:47:32 +01:00

325 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
const PROJECT_ROOT = __DIR__ . '/..';
const PUBLIC_DIR = PROJECT_ROOT . '/src/public';
runSuite('ndjson');
runSuite('sqlite');
echo "All tests passed.\n";
function runSuite(string $backend): void
{
$storageDir = PROJECT_ROOT . '/var/test-' . $backend;
resetDirectory($storageDir);
$env = [
'APP_ENV' => '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);
}
}