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' '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; }