325 lines
9.4 KiB
PHP
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);
|
|
}
|
|
}
|