Files
api/services/nginx/app/tests/Api/ModuleScannerLprApiTest.php
T

300 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use Tests\Support\Api\ApiServer;
usesApiSuite();
it('rejects scanner LPR requests without an image before contacting Plate Recognizer', function (): void {
api_test_covers('POST /modules/scanner/lpr', 'failure');
$response = api_client()->post('/modules/scanner/lpr', []);
$response->assertStatus(400)->assertSuccess(false)->assertMessage('Image is required.');
});
it('accepts multipart scanner images and forwards them to Plate Recognizer as a temp-file upload', function (): void {
api_test_covers('POST /modules/scanner/lpr', 'happy');
if (trim((string)getenv('API_TEST_BASE_URL')) !== '') {
$this->markTestSkipped('This scanner LPR test requires the self-started API server so PLATE_RECOGNIZER_API_URL can be isolated.');
}
$fakePlateRecognizer = ScannerLprFakePlateRecognizerServer::start();
$previousPlateRecognizerUrl = scanner_lpr_api_get_env('PLATE_RECOGNIZER_API_URL');
scanner_lpr_api_set_env('PLATE_RECOGNIZER_API_URL', $fakePlateRecognizer->url());
api_test_runtime()->restartServer();
$imagePath = tempnam(sys_get_temp_dir(), 'scanner-lpr-api-');
expect($imagePath)->not->toBeFalse();
file_put_contents($imagePath, 'jpeg-camera-bytes');
try {
api_fixtures()->setModuleConfig('licenseplaterecognizer', 'enabled', 'true', 'bool');
api_fixtures()->setModuleConfig('licenseplaterecognizer', 'api_key', 'scanner-api-test-key', 'string');
api_test_runtime()->redis()?->del(['licenseplaterecognizer:runtime_config:v1']);
$response = api_client()->postMultipart('/modules/scanner/lpr', [
'client_capture_ms' => '12.345',
'client_frame_width' => '300',
'client_frame_height' => '225',
'client_frame_bytes' => (string)strlen('jpeg-camera-bytes'),
], [
'image' => [
'path' => $imagePath,
'mime' => 'image/jpeg',
'name' => 'camera-frame.jpg',
],
]);
$response->assertStatus(200)->assertSuccess();
expect($response->data())->toMatchArray([
'success' => true,
'license_plate_number' => 'AB12345',
]);
expect($response->headers['server-timing'] ?? '')
->toContain('lpr_client_capture')
->toContain('lpr_client_frame_width')
->toContain('lpr_client_frame_height')
->toContain('lpr_client_frame_bytes')
->toContain('lpr_local')
->toContain('lpr_payload')
->toContain('lpr_upstream_dns')
->toContain('lpr_upstream_connect')
->toContain('lpr_upstream')
->toContain('lpr_upstream_total')
->toContain('lpr_upstream_processing')
->toContain('lpr_total')
->toContain('lpr_route_total')
->toContain('lpr_request_total');
$capture = $fakePlateRecognizer->capture();
expect($capture['method'] ?? null)->toBe('POST');
expect($capture['authorization'] ?? null)->toBe('Token scanner-api-test-key');
expect($capture['expect'] ?? null)->toBeNull();
expect(json_decode((string)($capture['post']['config'] ?? ''), true))->toBe([
'mode' => 'fast',
'plates_per_vehicle' => 1,
'zoom_in_vehicles' => 0,
]);
expect($capture['post']['regions'] ?? null)->toBe('dk,de,se,no');
expect($capture['post']['client_capture_ms'] ?? null)->toBeNull();
expect($capture['post']['client_frame_width'] ?? null)->toBeNull();
expect($capture['post']['client_frame_height'] ?? null)->toBeNull();
expect($capture['post']['client_frame_bytes'] ?? null)->toBeNull();
expect($capture['files']['upload'] ?? [])->toMatchArray([
'name' => 'license-plate.jpg',
'type' => 'image/jpeg',
'size' => strlen('jpeg-camera-bytes'),
'error' => UPLOAD_ERR_OK,
'contents' => base64_encode('jpeg-camera-bytes'),
]);
} finally {
@unlink($imagePath);
$fakePlateRecognizer->stop();
scanner_lpr_api_set_env('PLATE_RECOGNIZER_API_URL', $previousPlateRecognizerUrl);
api_test_runtime()->restartServer();
}
});
final class ScannerLprFakePlateRecognizerServer
{
private mixed $process = null;
private function __construct(
private readonly string $directory,
private readonly string $capturePath,
private readonly int $port,
) {
}
public static function start(): self
{
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'scanner-lpr-fake-' . bin2hex(random_bytes(6));
if (!mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new RuntimeException('Unable to create fake Plate Recognizer directory.');
}
$capturePath = $directory . DIRECTORY_SEPARATOR . 'capture.json';
$routerPath = $directory . DIRECTORY_SEPARATOR . 'router.php';
file_put_contents($routerPath, <<<'PHP'
<?php
$path = parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
if ($path === '/health') {
header('Content-Type: text/plain');
echo 'ok';
return;
}
if ($path !== '/v1/plate-reader/') {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not found']);
return;
}
$upload = $_FILES['upload'] ?? [];
$tmpName = is_array($upload) ? (string)($upload['tmp_name'] ?? '') : '';
$capture = [
'method' => $_SERVER['REQUEST_METHOD'] ?? null,
'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null,
'expect' => $_SERVER['HTTP_EXPECT'] ?? null,
'post' => $_POST,
'files' => [
'upload' => [
'name' => is_array($upload) ? ($upload['name'] ?? null) : null,
'type' => is_array($upload) ? ($upload['type'] ?? null) : null,
'size' => is_array($upload) ? ($upload['size'] ?? null) : null,
'error' => is_array($upload) ? ($upload['error'] ?? null) : null,
'contents' => $tmpName !== '' && is_file($tmpName) ? base64_encode((string)file_get_contents($tmpName)) : null,
],
],
];
file_put_contents((string)getenv('SCANNER_LPR_FAKE_CAPTURE_PATH'), json_encode($capture, JSON_UNESCAPED_SLASHES));
header('Content-Type: application/json');
echo json_encode([
'processing_time' => 58.184,
'results' => [['plate' => 'ab12345', 'score' => 0.98]],
], JSON_UNESCAPED_SLASHES);
PHP);
$port = ApiServer::findAvailablePort('127.0.0.1');
$server = new self($directory, $capturePath, $port);
$server->startProcess($routerPath);
$server->waitUntilReady();
return $server;
}
public function url(): string
{
return sprintf('http://127.0.0.1:%d', $this->port);
}
public function capture(): array
{
$contents = is_file($this->capturePath) ? file_get_contents($this->capturePath) : false;
expect($contents)->not->toBeFalse();
$decoded = json_decode((string)$contents, true);
expect($decoded)->toBeArray();
return $decoded;
}
public function stop(): void
{
if (is_resource($this->process)) {
proc_terminate($this->process);
usleep(250000);
$status = proc_get_status($this->process);
if (($status['running'] ?? false) && function_exists('posix_kill')) {
@posix_kill((int)$status['pid'], 9);
}
proc_close($this->process);
$this->process = null;
}
$this->removeDirectory($this->directory);
}
private function startProcess(string $routerPath): void
{
$command = sprintf(
'%s -S %s %s',
escapeshellarg((string)(PHP_BINARY ?: 'php')),
escapeshellarg('127.0.0.1:' . $this->port),
escapeshellarg($routerPath),
);
$environment = array_merge(getenv() ?: [], [
'SCANNER_LPR_FAKE_CAPTURE_PATH' => $this->capturePath,
]);
$descriptorSpec = [
0 => ['pipe', 'r'],
1 => ['file', $this->directory . DIRECTORY_SEPARATOR . 'stdout.log', 'a'],
2 => ['file', $this->directory . DIRECTORY_SEPARATOR . 'stderr.log', 'a'],
];
$this->process = proc_open($command, $descriptorSpec, $pipes, $this->directory, $environment);
if (!is_resource($this->process)) {
throw new RuntimeException('Unable to start fake Plate Recognizer server.');
}
if (isset($pipes[0]) && is_resource($pipes[0])) {
fclose($pipes[0]);
}
}
private function waitUntilReady(): void
{
$deadline = microtime(true) + 5;
$lastError = 'Timed out waiting for fake Plate Recognizer.';
while (microtime(true) < $deadline) {
$curl = curl_init($this->url() . '/health');
if ($curl === false) {
throw new RuntimeException('Unable to initialize fake Plate Recognizer health check.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 250,
CURLOPT_TIMEOUT_MS => 500,
]);
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($body === false) {
$lastError = curl_error($curl);
}
curl_close($curl);
if ($status === 200 && $body === 'ok') {
return;
}
usleep(100000);
}
throw new RuntimeException($lastError);
}
private function removeDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}
foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
if (is_file($path)) {
@unlink($path);
}
}
@rmdir($directory);
}
}
function scanner_lpr_api_get_env(string $key): ?string
{
$value = getenv($key);
return $value === false ? null : (string)$value;
}
function scanner_lpr_api_set_env(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}