From 1ccd7749d0e596c4f98bc1cd5513c9307014004e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 12 Jun 2026 21:42:36 +0200 Subject: [PATCH] optimize scanner lpr backend --- services/nginx/app/classes/cors_policy.php | 3 + .../app/classes/licenseplaterecognizer.php | 577 ++++++++++++++++-- .../interfaces/licenseplaterecognizer_i.php | 27 +- .../nginx/app/routes/moduleScannerRoute.php | 373 +++++++++-- .../app/tests/Api/ModuleScannerLprApiTest.php | 299 +++++++++ .../app/tests/Api/api_coverage_manifest.php | 1 + .../nginx/app/tests/Support/Api/ApiClient.php | 79 +++ .../app/tests/Support/Api/ApiTestRuntime.php | 15 + .../Unit/Infrastructure/CorsPolicyTest.php | 5 + .../LicensePlateRecognizerPayloadTest.php | 449 ++++++++++++++ .../Unit/Scanner/ModuleScannerRouteTest.php | 125 ++++ 11 files changed, 1873 insertions(+), 80 deletions(-) create mode 100644 services/nginx/app/tests/Api/ModuleScannerLprApiTest.php create mode 100644 services/nginx/app/tests/Unit/Scanner/LicensePlateRecognizerPayloadTest.php diff --git a/services/nginx/app/classes/cors_policy.php b/services/nginx/app/classes/cors_policy.php index 54e96095..1866668a 100644 --- a/services/nginx/app/classes/cors_policy.php +++ b/services/nginx/app/classes/cors_policy.php @@ -6,6 +6,7 @@ class cors_policy { public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *'; public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; + public const EXPOSED_HEADERS = 'Server-Timing'; public const MAX_AGE_SECONDS = '86400'; private const REQUIRED_ALLOWED_ORIGINS = [ @@ -128,7 +129,9 @@ class cors_policy 'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS, 'Access-Control-Allow-Methods' => self::ALLOWED_METHODS, + 'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS, 'Access-Control-Max-Age' => self::MAX_AGE_SECONDS, + 'Timing-Allow-Origin' => $origin, 'Vary' => 'Origin', ]; } diff --git a/services/nginx/app/classes/licenseplaterecognizer.php b/services/nginx/app/classes/licenseplaterecognizer.php index 3538ff02..3925be58 100644 --- a/services/nginx/app/classes/licenseplaterecognizer.php +++ b/services/nginx/app/classes/licenseplaterecognizer.php @@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c; class licenseplaterecognizer implements licenseplaterecognizer_i { + private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; + private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}'; + private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}'; + private const PLATE_READER_REGIONS = 'dk,de,se,no'; + private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg'; + private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15; + private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1'; + private const RESULT_CACHE_TTL_SECONDS = 10; + private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:'; + private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000; + private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500; + /** + * @var array + */ + private array $last_timings = []; + /** * The configuration of the module * @var licenseplaterecognizer_c @@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i * API URL * @var string */ - private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)'; + private string $api_url; + /** + * @var array{enabled: bool, api_key: string}|null + */ + private ?array $runtime_config = null; - public function __construct() + /** + * @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null + */ + private static ?array $runtime_config_cache = null; + + public function __construct(bool $load_config = true, ?string $api_url = null) { - $this->config = new licenseplaterecognizer_c(); + $this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl()); + + if ($load_config) { + $this->config = new licenseplaterecognizer_c(); + } } @@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i */ public function requireModuleEnabled(): void { - if (!(bool)$this->config->enabled->getVariableValue()) { + if (!$this->runtimeConfig()['enabled']) { throw new Exception('licenseplaterecognizer module is not enabled.'); } } @@ -45,54 +74,516 @@ class licenseplaterecognizer implements licenseplaterecognizer_i */ public function licenseplaterecognizer(string $base64_image): array { - $image_processor = new image_processor(); - - //ADD PARAMETER IN REQUEST LIKE regions - $data = array( - 'upload' => $base64_image, - //'regions' => 'dk' // Optional + return $this->recognizePlate( + fn () => $this->buildPlateReaderPayload($base64_image), + fn () => $this->buildResultCacheKeyFromUploadString($base64_image) ); + } - // Prepare new cURL resource - //$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/'); - $ch = curl_init($this->api_url . '/v1/plate-reader/'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLINFO_HEADER_OUT, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $data); - curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS); + public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array + { + return $this->recognizePlate( + fn () => $this->buildPlateReaderPayloadFromUpload( + $this->buildUploadValueFromBytes($image_data, $mime_type) + ), + fn () => $this->buildResultCacheKeyFromBytes($image_data) + ); + } - // Set HTTP Header for POST request - curl_setopt($ch, CURLOPT_HTTPHEADER, array( - "Authorization: Token " . $this->config->api_key->getVariableValue() + public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array + { + return $this->recognizePlate( + fn () => $this->buildPlateReaderPayloadFromUpload( + $this->buildUploadValueFromBytes($image_data, $mime_type) ) ); + } - // Submit the POST request and close cURL session handle - $result = curl_exec($ch); - curl_close($ch); - // Print the response from the server - if ($result === false) { - throw new Exception('Error in API request.'); - } + public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array + { + return $this->recognizePlate( + fn () => $this->buildPlateReaderPayloadFromUpload( + $this->buildUploadValueFromFile($image_path, $mime_type) + ) + ); + } - $response_data = json_decode($result, true); - if (isset($response_data['results']) && count($response_data['results']) > 0) { - return [ - 'success' => true, - 'license_plate_number' => $response_data['results'][0]['plate'] ?? null, - 'confidence' => $response_data['results'][0]['score'] ?? null, - 'raw_response' => $response_data, + /** + * @throws Exception + */ + private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array + { + $started_at = microtime(true); + $this->last_timings = []; + $result_cache = null; + $result_cache_key = null; + + try { + $config_started_at = microtime(true); + $runtime_config = $this->runtimeConfig(); + if (!$runtime_config['enabled']) { + throw new Exception('licenseplaterecognizer module is not enabled.'); + } + $api_key = $runtime_config['api_key']; + $this->last_timings['config'] = $this->elapsedMs($config_started_at); + + if ($result_cache_key_factory !== null) { + $cache_started_at = microtime(true); + try { + $result_cache = $this->resultCacheStore(); + if ($result_cache !== null) { + $result_cache_key = $result_cache_key_factory(); + if ($result_cache_key !== null) { + $cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key); + if ($cached_result !== null) { + $this->last_timings['cache_hit'] = 1; + return $cached_result; + } + } + } + $this->last_timings['cache_miss'] = 1; + } finally { + $this->last_timings['cache'] = $this->elapsedMs($cache_started_at); + } + } + + $payload_started_at = microtime(true); + $data = $payload_factory(); + $this->last_timings['payload'] = $this->elapsedMs($payload_started_at); + + $ch = curl_init($this->api_url . '/v1/plate-reader/'); + if (!$ch instanceof \CurlHandle) { + throw new Exception('Error initializing API request.'); + } + + $curl_options = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $data, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, + CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS, + CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS, + CURLOPT_NOSIGNAL => true, + CURLOPT_NOPROGRESS => false, + CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(), + CURLOPT_HTTPHEADER => [ + "Authorization: Token " . $api_key, + 'Expect:', + ], ]; - } else { - return [ + if (defined('CURLOPT_TCP_NODELAY')) { + $curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true; + } + curl_setopt_array($ch, $curl_options); + + // Submit the POST request and close cURL session handle + $upstream_started_at = microtime(true); + $result = curl_exec($ch); + $this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at); + $this->recordCurlTimings($ch); + curl_close($ch); + + // Print the response from the server + if ($result === false) { + throw new Exception('Error in API request.'); + } + + $parse_started_at = microtime(true); + $response_data = json_decode($result, true); + $this->last_timings['parse'] = $this->elapsedMs($parse_started_at); + $this->recordResponseTimings($response_data); + + if (isset($response_data['results']) && count($response_data['results']) > 0) { + $recognized_result = [ + 'success' => true, + 'license_plate_number' => $response_data['results'][0]['plate'] ?? null, + 'confidence' => $response_data['results'][0]['score'] ?? null, + ]; + + $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result); + + return $recognized_result; + } + + $recognized_result = [ 'success' => false, 'message' => 'No license plate detected.', - 'raw_response' => $response_data, ]; + $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result); + + return $recognized_result; + } finally { + $this->last_timings['total'] = $this->elapsedMs($started_at); } } + private static function clientDisconnectAbortCallback(): callable + { + return static function (): int { + return connection_aborted() ? 1 : 0; + }; + } + + public function getLastTimings(): array + { + return $this->last_timings; + } + + private function elapsedMs(float $started_at): float + { + return (microtime(true) - $started_at) * 1000; + } + + private static function configuredApiUrl(): string + { + $configured = getenv('PLATE_RECOGNIZER_API_URL'); + if ($configured === false || trim((string)$configured) === '') { + $configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL; + } + + return (string)$configured; + } + + private static function normalizeApiUrl(string $api_url): string + { + $api_url = trim($api_url); + if ($api_url === '') { + return self::DEFAULT_API_URL; + } + + return rtrim($api_url, '/'); + } + + private function recordCurlTimings(\CurlHandle $curl_handle): void + { + $mapping = [ + CURLINFO_NAMELOOKUP_TIME => 'upstream_dns', + CURLINFO_CONNECT_TIME => 'upstream_connect', + CURLINFO_APPCONNECT_TIME => 'upstream_tls', + CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer', + CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb', + CURLINFO_TOTAL_TIME => 'upstream_total', + ]; + + foreach ($mapping as $curl_info_option => $timing_key) { + $value = curl_getinfo($curl_handle, $curl_info_option); + if (!is_numeric($value)) { + continue; + } + + $this->last_timings[$timing_key] = max(0, (float)$value * 1000); + } + } + + private function recordResponseTimings(mixed $response_data): void + { + if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) { + return; + } + + $this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']); + } + + private function buildResultCacheKeyFromUploadString(string $base64_image): string + { + $base64_image = trim($base64_image); + if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) { + $image_data = base64_decode((string)$matches[1], true); + if (is_string($image_data)) { + return $this->buildResultCacheKeyFromBytes($image_data); + } + } + + return $this->buildResultCacheKeyFromBytes($base64_image); + } + + private function buildResultCacheKeyFromBytes(string $image_data): string + { + $context = hash_init('sha256'); + hash_update($context, $this->resultCacheContext()); + hash_update($context, "\0"); + hash_update($context, $image_data); + + return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context); + } + + private function resultCacheContext(): string + { + return self::RESULT_CACHE_CONTEXT; + } + + protected function resultCacheStore(): ?object + { + return $this->runtimeConfigCacheStore(); + } + + private function readRecognitionResultCache(?object $cache, ?string $key): ?array + { + if ($cache === null || $key === null || !method_exists($cache, 'get')) { + return null; + } + + try { + $cached = $cache->get($key); + } catch (\Throwable) { + return null; + } + + if (!is_string($cached) || trim($cached) === '') { + return null; + } + + $decoded = json_decode($cached, true); + if (!is_array($decoded) || !array_key_exists('success', $decoded)) { + return null; + } + + return $decoded; + } + + private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void + { + if ($cache === null || $key === null || !method_exists($cache, 'setEx')) { + return; + } + + try { + $encoded = json_encode($result, JSON_UNESCAPED_SLASHES); + if (is_string($encoded)) { + $cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS); + } + } catch (\Throwable) { + // Scanner result cache is best-effort; Plate Recognizer remains the source of truth. + } + } + + protected function buildPlateReaderPayload(string $base64_image): array + { + return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image)); + } + + protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array + { + return [ + 'upload' => $upload, + 'config' => self::PLATE_READER_CONFIG_JSON, + 'regions' => self::PLATE_READER_REGIONS, + ]; + } + + private function buildUploadValue(string $base64_image): string|\CURLStringFile + { + $base64_image = trim($base64_image); + if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) { + return $base64_image; + } + + $image_data = base64_decode((string)$matches[2], true); + if ($image_data === false || !class_exists(\CURLStringFile::class)) { + return (string)$matches[2]; + } + + return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]); + } + + private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile + { + $mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg'; + if (!str_starts_with($mime_type, 'image/')) { + $mime_type = 'image/jpeg'; + } + + if (!class_exists(\CURLStringFile::class)) { + return $image_data; + } + + return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type); + } + + /** + * @throws Exception + */ + private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile + { + $image_path = trim($image_path); + $mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg'; + if (!str_starts_with($mime_type, 'image/')) { + $mime_type = 'image/jpeg'; + } + + if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) { + throw new Exception('Image upload file is invalid.'); + } + + return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME); + } + + protected function runtimeConfig(): array + { + if ($this->runtime_config !== null) { + return $this->runtime_config; + } + + if ($this->shouldUseSharedRuntimeConfigCache()) { + $cached_config = self::getSharedRuntimeConfigCache(); + if ($cached_config !== null) { + $this->runtime_config = $cached_config; + + return $this->runtime_config; + } + + $cached_config = $this->readRuntimeConfigCacheStore(); + if ($cached_config !== null) { + self::$runtime_config_cache = [ + 'values' => $cached_config, + 'cached_at' => microtime(true), + ]; + $this->runtime_config = $cached_config; + + return $this->runtime_config; + } + } + + $values = $this->readRuntimeModuleConfig(); + + $this->runtime_config = [ + 'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false), + 'api_key' => (string)($values['api_key'] ?? ''), + ]; + + if ($this->shouldUseSharedRuntimeConfigCache()) { + self::$runtime_config_cache = [ + 'values' => $this->runtime_config, + 'cached_at' => microtime(true), + ]; + $this->writeRuntimeConfigCacheStore($this->runtime_config); + } + + return $this->runtime_config; + } + + protected function shouldUseSharedRuntimeConfigCache(): bool + { + return static::class === self::class; + } + + private static function getSharedRuntimeConfigCache(): ?array + { + if (self::$runtime_config_cache === null) { + return null; + } + + $cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at']; + if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) { + self::$runtime_config_cache = null; + + return null; + } + + return self::$runtime_config_cache['values']; + } + + protected function runtimeConfigCacheStore(): ?object + { + return defined('redis') ? constant('redis') : null; + } + + private function readRuntimeConfigCacheStore(): ?array + { + $cache = $this->runtimeConfigCacheStore(); + if ($cache === null || !method_exists($cache, 'get')) { + return null; + } + + try { + $cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY); + } catch (\Throwable) { + return null; + } + + if (!is_string($cached) || trim($cached) === '') { + return null; + } + + $decoded = json_decode($cached, true); + if (!is_array($decoded)) { + return null; + } + + if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) { + return null; + } + + return [ + 'enabled' => $this->parseModuleConfigBool($decoded['enabled']), + 'api_key' => (string)$decoded['api_key'], + ]; + } + + /** + * @param array{enabled: bool, api_key: string} $config + */ + private function writeRuntimeConfigCacheStore(array $config): void + { + $cache = $this->runtimeConfigCacheStore(); + if ($cache === null || !method_exists($cache, 'setEx')) { + return; + } + + try { + $encoded = json_encode($config, JSON_UNESCAPED_SLASHES); + if (is_string($encoded)) { + $cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS); + } + } catch (\Throwable) { + // Scanner config cache is best-effort; DB remains the source of truth. + } + } + + private function parseModuleConfigBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int)$value === 1; + } + + return strtolower(trim((string)$value)) === 'true'; + } + + protected function readRuntimeModuleConfig(): array + { + global $db; + + if ($db instanceof db) { + $module = $db->escape_string('licenseplaterecognizer'); + $result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')"); + $values = []; + + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $variable = (string)($row['variable'] ?? ''); + if ($variable !== '') { + $values[$variable] = (string)($row['value'] ?? ''); + } + } + } + + return $values; + } + + if (!isset($this->config)) { + $this->config = new licenseplaterecognizer_c(); + } + + return [ + 'enabled' => (string)$this->config->enabled->getVariableValue(), + 'api_key' => (string)$this->config->api_key->getVariableValue(), + ]; + } + /** * @inheritDoc * @throws Exception If the module is not enabled or if there is an error in the API request @@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i */ public function get_usage(): licenseplaterecognizer_info { - // Require the module to be enabled $this->requireModuleEnabled(); + $api_key = $this->runtimeConfig()['api_key']; $curl = curl_init(); curl_setopt_array($curl, array( CURLOPT_URL => $this->api_url . '/info/', @@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_CUSTOMREQUEST => 'GET', - CURLOPT_HTTPHEADER => array( - 'Authorization: Token ' . $this->config->api_key->getVariableValue() - ), + CURLOPT_HTTPHEADER => [ + 'Authorization: Token ' . $api_key, + ], )); $response = curl_exec($curl); curl_close($curl); @@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i } return new licenseplaterecognizer_info($response_data); } -} \ No newline at end of file +} diff --git a/services/nginx/app/interfaces/licenseplaterecognizer_i.php b/services/nginx/app/interfaces/licenseplaterecognizer_i.php index 202d7894..03107f3e 100644 --- a/services/nginx/app/interfaces/licenseplaterecognizer_i.php +++ b/services/nginx/app/interfaces/licenseplaterecognizer_i.php @@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i * @return array An array containing the plate number and other relevant information. */ public function licenseplaterecognizer(string $base64_image): array; + + /** + * Get the plate number from raw uploaded image bytes. + * @param string $image_data Raw uploaded image bytes. + * @param string $mime_type The image MIME type. + * @return array An array containing the plate number and other relevant information. + */ + public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array; + + /** + * Get the plate number from raw uploaded image bytes without building an exact-result cache key. + * @param string $image_data Raw uploaded image bytes. + * @param string $mime_type The image MIME type. + * @return array An array containing the plate number and other relevant information. + */ + public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array; + + /** + * Get the plate number from a PHP upload temp file without copying it into memory. + * @param string $image_path The uploaded image temp-file path. + * @param string $mime_type The image MIME type. + * @return array An array containing the plate number and other relevant information. + */ + public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array; + /** * Get usage information about the license plate recognizer module. * @returns licenseplaterecognizer_info An object containing usage statistics and information. * @see licenseplaterecognizer_info */ public function get_usage(): licenseplaterecognizer_info; -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/moduleScannerRoute.php b/services/nginx/app/routes/moduleScannerRoute.php index 1cb3049e..65534e82 100644 --- a/services/nginx/app/routes/moduleScannerRoute.php +++ b/services/nginx/app/routes/moduleScannerRoute.php @@ -2,21 +2,35 @@ namespace routes; -use classes\authentication; -use classes\image_processor; use classes\licenseplaterecognizer; -use classes\openai; use classes\response; -use classes\router; -use classes\upload_store; -use Exception; -use objects\logs_o; use traits\route_t; class moduleScannerRoute { use route_t; + private const LPR_IMAGE_UPLOAD_FIELD = 'image'; + private const LPR_IMAGE_UPLOAD_MAX_BYTES = 4194304; + private const LPR_CLIENT_CAPTURE_MS_FIELD = 'client_capture_ms'; + private const LPR_CLIENT_CAPTURE_MAX_MS = 10000; + private const LPR_CLIENT_DRAW_MS_FIELD = 'client_draw_ms'; + private const LPR_CLIENT_ENCODE_MS_FIELD = 'client_encode_ms'; + private const LPR_CLIENT_FRAME_WIDTH_FIELD = 'client_frame_width'; + private const LPR_CLIENT_FRAME_HEIGHT_FIELD = 'client_frame_height'; + private const LPR_CLIENT_FRAME_BYTES_FIELD = 'client_frame_bytes'; + private const LPR_CLIENT_FRAME_MAX_DIMENSION = 4096; + private const LPR_CLIENT_PREFLIGHT_MS_FIELD = 'client_preflight_ms'; + private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = 'client_visual_fingerprint_ms'; + private const LPR_CLIENT_CAPTURE_MS_HEADER = 'HTTP_X_LPR_CLIENT_CAPTURE_MS'; + private const LPR_CLIENT_DRAW_MS_HEADER = 'HTTP_X_LPR_CLIENT_DRAW_MS'; + private const LPR_CLIENT_ENCODE_MS_HEADER = 'HTTP_X_LPR_CLIENT_ENCODE_MS'; + private const LPR_CLIENT_FRAME_WIDTH_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_WIDTH'; + private const LPR_CLIENT_FRAME_HEIGHT_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_HEIGHT'; + private const LPR_CLIENT_FRAME_BYTES_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_BYTES'; + private const LPR_CLIENT_PREFLIGHT_MS_HEADER = 'HTTP_X_LPR_CLIENT_PREFLIGHT_MS'; + private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER = 'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS'; + public function run(): void { global /** @var response $response */ @@ -24,27 +38,43 @@ class moduleScannerRoute /** Modules > Scanner > License Plate Recognition > POST */ $this->post('/modules/scanner/lpr', function () { global $response; - self::requireParameters(['base64_image']); - $base64_image = self::getParameter('base64_image'); + $route_started_at = microtime(true); + $image_upload = self::getLPRImageUpload(); + $raw_image_upload = $image_upload === null ? self::getLPRRawImageUpload() : null; + $client_timings = self::getLPRClientTimings(); + $base64_image = null; //self::requirePermission('modules_scanner_lpr'); - if (empty($base64_image)) { - $response->error('Base64 image is required.');} - $uploads = new upload_store(); - $object_name = $uploads->storeTempImageFromBase64( - $base64_image - ); - //echo $object_name; - //echo "License Plate Recognition result:\n"; - // Uncomment the line below to use the actual license plate recognizer. - $lpr_result = (new licenseplaterecognizer())->licenseplaterecognizer($base64_image); + if ($image_upload === null && $raw_image_upload === null) { + $base64_image = self::getParameter('base64_image'); + if (!is_string($base64_image) || trim($base64_image) === '') { + $response->error('Image is required.'); + } + } + $recognizer = new licenseplaterecognizer(false); + try { + if ($image_upload !== null) { + $lpr_result = $recognizer->licenseplaterecognizerUploadFile($image_upload['path'], $image_upload['mime_type']); + } elseif ($raw_image_upload !== null) { + $lpr_result = $recognizer->licenseplaterecognizerUploadUncached($raw_image_upload['data'], $raw_image_upload['mime_type']); + } else { + $lpr_result = $recognizer->licenseplaterecognizer((string)$base64_image); + } + } finally { + self::sendLPRServerTiming(array_merge($client_timings, $recognizer->getLastTimings()), $route_started_at); + } if ($lpr_result['success']) { // Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces. $lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number'])); // Make sure the scanned plate is more than 3 characters long. $scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3; - // If the confidence is below 90%, consider it a failure. + // If the confidence is below 90%, treat it as a recoverable scanner miss. if (isset($lpr_result['confidence']) && $lpr_result['confidence'] < 0.9 && !$scannedPlateIsTooShort) { - throw new Exception('License plate recognition confidence too low. Score: ' . $lpr_result['confidence'] . ' Plate: ' . $lpr_result['license_plate_number'] . ' Raw: ' . json_encode($lpr_result['raw_response'])); + $response->response(false, [ + 'message' => 'License plate recognition confidence too low.', + 'reason' => 'low_confidence_license_plate', + 'confidence' => $lpr_result['confidence'], + 'license_plate_number' => $lpr_result['license_plate_number'], + ], 200); } // Success $response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]); @@ -54,25 +84,296 @@ class moduleScannerRoute 'reason' => 'no_license_plate_detected', ], 200); } - exit; - // For future use with OpenAI. - $openai = new openai(); - $registration_numbers_debug = [ - 'EC21233', - 'EC21234', - 'EC21235', - ]; - $random_index = rand(0, count($registration_numbers_debug) - 1); - $object_name = $registration_numbers_debug[$random_index]; - // Attempt to recognize the license plate number from the image. - - //Debug: TODO: Remove this. - $response->success(['success' => true, 'license_plate_number' => $object_name]); - $response->success($openai->lpr($object_name)); }, [ 'modules_scanner_lpr' => 'License Plate Recognition', ] ); } + + private static function getLPRImageUpload(): ?array + { + global $response; + + if (!isset($_FILES[self::LPR_IMAGE_UPLOAD_FIELD]) || !is_array($_FILES[self::LPR_IMAGE_UPLOAD_FIELD])) { + return null; + } + + $file = $_FILES[self::LPR_IMAGE_UPLOAD_FIELD]; + if (is_array($file['error'] ?? null)) { + $response->error('Only one image can be uploaded.', 400); + } + + $upload_error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($upload_error === UPLOAD_ERR_NO_FILE) { + return null; + } + + if ($upload_error !== UPLOAD_ERR_OK) { + $response->error('Image upload failed.', 400); + } + + $size = (int)($file['size'] ?? 0); + if ($size <= 0) { + $response->error('Image upload is empty.', 400); + } + + if ($size > self::LPR_IMAGE_UPLOAD_MAX_BYTES) { + $response->error('Image upload is too large.', 413); + } + + $tmp_name = (string)($file['tmp_name'] ?? ''); + if ($tmp_name === '' || !is_uploaded_file($tmp_name)) { + $response->error('Image upload is invalid.', 400); + } + + if (!is_readable($tmp_name)) { + $response->error('Image upload could not be read.', 400); + } + + $mime_type = self::detectLPRImageMimeType($file, $tmp_name); + if (!str_starts_with($mime_type, 'image/')) { + $response->error('Image upload must be an image.', 400); + } + + return [ + 'path' => $tmp_name, + 'mime_type' => $mime_type, + ]; + } + + private static function getLPRRawImageUpload(): ?array + { + global $response; + + $mime_type = self::getRequestContentType(); + if (!str_starts_with($mime_type, 'image/')) { + return null; + } + + $content_length = isset($_SERVER['CONTENT_LENGTH']) && is_numeric($_SERVER['CONTENT_LENGTH']) + ? (int)$_SERVER['CONTENT_LENGTH'] + : null; + if ($content_length !== null && $content_length > self::LPR_IMAGE_UPLOAD_MAX_BYTES) { + $response->error('Image upload is too large.', 413); + } + + $image_data = file_get_contents('php://input'); + if (!is_string($image_data) || $image_data === '') { + $response->error('Image upload is empty.', 400); + } + + if (strlen($image_data) > self::LPR_IMAGE_UPLOAD_MAX_BYTES) { + $response->error('Image upload is too large.', 413); + } + + return [ + 'data' => $image_data, + 'mime_type' => $mime_type, + ]; + } + + private static function getRequestContentType(): string + { + $content_type = (string)($_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? ''); + $content_type = strtolower(trim(explode(';', $content_type, 2)[0] ?? '')); + + return $content_type; + } + + private static function detectLPRImageMimeType(array $file, string $tmp_name): string + { + $mime_type = trim((string)($file['type'] ?? '')); + if ($mime_type !== '') { + return $mime_type; + } + + if (class_exists(\finfo::class)) { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $detected = $finfo->file($tmp_name); + if (is_string($detected) && $detected !== '') { + return $detected; + } + } + + return 'image/jpeg'; + } + + private static function getLPRClientTimings(): array + { + $timings = []; + $client_capture_ms = self::getNumericClientField( + self::LPR_CLIENT_CAPTURE_MS_FIELD, + self::LPR_CLIENT_CAPTURE_MS_HEADER, + 0, + self::LPR_CLIENT_CAPTURE_MAX_MS + ); + if ($client_capture_ms !== null) { + $timings['client_capture'] = $client_capture_ms; + } + + $client_preflight_ms = self::getNumericClientField( + self::LPR_CLIENT_PREFLIGHT_MS_FIELD, + self::LPR_CLIENT_PREFLIGHT_MS_HEADER, + 0, + self::LPR_CLIENT_CAPTURE_MAX_MS + ); + if ($client_preflight_ms !== null) { + $timings['client_preflight'] = $client_preflight_ms; + } + + $client_draw_ms = self::getNumericClientField( + self::LPR_CLIENT_DRAW_MS_FIELD, + self::LPR_CLIENT_DRAW_MS_HEADER, + 0, + self::LPR_CLIENT_CAPTURE_MAX_MS + ); + if ($client_draw_ms !== null) { + $timings['client_draw'] = $client_draw_ms; + } + + $client_encode_ms = self::getNumericClientField( + self::LPR_CLIENT_ENCODE_MS_FIELD, + self::LPR_CLIENT_ENCODE_MS_HEADER, + 0, + self::LPR_CLIENT_CAPTURE_MAX_MS + ); + if ($client_encode_ms !== null) { + $timings['client_encode'] = $client_encode_ms; + } + + $client_visual_fingerprint_ms = self::getNumericClientField( + self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD, + self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER, + 0, + self::LPR_CLIENT_CAPTURE_MAX_MS + ); + if ($client_visual_fingerprint_ms !== null) { + $timings['client_visual_fingerprint'] = $client_visual_fingerprint_ms; + } + + $client_frame_width = self::getNumericClientField( + self::LPR_CLIENT_FRAME_WIDTH_FIELD, + self::LPR_CLIENT_FRAME_WIDTH_HEADER, + 1, + self::LPR_CLIENT_FRAME_MAX_DIMENSION + ); + if ($client_frame_width !== null) { + $timings['client_frame_width'] = $client_frame_width; + } + + $client_frame_height = self::getNumericClientField( + self::LPR_CLIENT_FRAME_HEIGHT_FIELD, + self::LPR_CLIENT_FRAME_HEIGHT_HEADER, + 1, + self::LPR_CLIENT_FRAME_MAX_DIMENSION + ); + if ($client_frame_height !== null) { + $timings['client_frame_height'] = $client_frame_height; + } + + $client_frame_bytes = self::getNumericClientField( + self::LPR_CLIENT_FRAME_BYTES_FIELD, + self::LPR_CLIENT_FRAME_BYTES_HEADER, + 1, + self::LPR_IMAGE_UPLOAD_MAX_BYTES + ); + if ($client_frame_bytes !== null) { + $timings['client_frame_bytes'] = $client_frame_bytes; + } + + return $timings; + } + + private static function getNumericClientField(string $field, string $server_header, float $min, float $max): ?float + { + $value = $_GET[$field] ?? null; + if ($value === null) { + $value = $_POST[$field] ?? null; + } + if ($value === null) { + $value = $_SERVER[$server_header] ?? null; + } + if (is_array($value) || !is_numeric($value)) { + return null; + } + + $value = (float)$value; + if ($value < $min || $value > $max) { + return null; + } + + return $value; + } + + private static function sendLPRServerTiming(array $timings, float $route_started_at): void + { + if (headers_sent()) { + return; + } + + $parts = []; + $timings['route_total'] = max(0, (microtime(true) - $route_started_at) * 1000); + $timings['local'] = self::getLPRLocalDuration($timings); + if (isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT'])) { + $timings['request_total'] = max(0, (microtime(true) - (float)$_SERVER['REQUEST_TIME_FLOAT']) * 1000); + } + + foreach ([ + 'client_capture', + 'client_preflight', + 'client_visual_fingerprint', + 'client_draw', + 'client_encode', + 'client_frame_width', + 'client_frame_height', + 'client_frame_bytes', + 'config', + 'cache', + 'cache_hit', + 'cache_miss', + 'local', + 'payload', + 'upstream_dns', + 'upstream_connect', + 'upstream_tls', + 'upstream_pretransfer', + 'upstream_ttfb', + 'upstream_total', + 'upstream_processing', + 'upstream', + 'parse', + 'total', + ] as $name) { + if (!isset($timings[$name]) || !is_numeric($timings[$name])) { + continue; + } + + $parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]); + } + foreach (['route_total', 'request_total'] as $name) { + if (!isset($timings[$name]) || !is_numeric($timings[$name])) { + continue; + } + + $parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]); + } + + if ($parts !== []) { + header('Server-Timing: ' . implode(', ', $parts)); + } + } + + private static function getLPRLocalDuration(array $timings): float + { + $upstream = null; + foreach (['upstream', 'upstream_total'] as $name) { + if (isset($timings[$name]) && is_numeric($timings[$name])) { + $upstream = max(0, (float)$timings[$name]); + break; + } + } + + return max(0, (float)$timings['route_total'] - ($upstream ?? 0)); + } } diff --git a/services/nginx/app/tests/Api/ModuleScannerLprApiTest.php b/services/nginx/app/tests/Api/ModuleScannerLprApiTest.php new file mode 100644 index 00000000..44f6ef4a --- /dev/null +++ b/services/nginx/app/tests/Api/ModuleScannerLprApiTest.php @@ -0,0 +1,299 @@ +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; +} diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index f6ffdd09..12b3ff8b 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -26,6 +26,7 @@ return [ 'GET /ping', 'PUT /order', 'GET /orders/reference-suggestions', + 'POST /modules/scanner/lpr', ], 'happy_only_operations' => [ 'GET /ping', diff --git a/services/nginx/app/tests/Support/Api/ApiClient.php b/services/nginx/app/tests/Support/Api/ApiClient.php index b9d79974..a182fa2d 100644 --- a/services/nginx/app/tests/Support/Api/ApiClient.php +++ b/services/nginx/app/tests/Support/Api/ApiClient.php @@ -24,6 +24,28 @@ final class ApiClient return $this->request('POST', $path, $payload, $headers); } + /** + * @param array $fields + * @param array $files + * @param array $headers + */ + public function postMultipart(string $path, array $fields = [], array $files = [], array $headers = []): ApiResponse + { + $postFields = []; + foreach ($fields as $name => $value) { + $postFields[$name] = $value === null ? '' : (string)$value; + } + + foreach ($files as $name => $file) { + $filePath = is_array($file) ? (string)$file['path'] : (string)$file; + $mime = is_array($file) ? (string)($file['mime'] ?? 'application/octet-stream') : 'application/octet-stream'; + $filename = is_array($file) ? (string)($file['name'] ?? basename($filePath)) : basename($filePath); + $postFields[$name] = new \CURLFile($filePath, $mime, $filename); + } + + return $this->requestMultipart('POST', $path, $postFields, $headers); + } + public function put(string $path, ?array $payload = null, array $headers = []): ApiResponse { return $this->request('PUT', $path, $payload, $headers); @@ -102,4 +124,61 @@ final class ApiClient return new ApiResponse($status, $responseHeaders, $decoded, (string)$body); } + + /** + * @param array $postFields + * @param array $headers + */ + private function requestMultipart(string $method, string $path, array $postFields, array $headers = []): ApiResponse + { + $curl = curl_init(); + if ($curl === false) { + throw new RuntimeException('Unable to initialize cURL for API tests.'); + } + + $timeoutSeconds = (int)(getenv('API_TEST_REQUEST_TIMEOUT') ?: self::DEFAULT_TIMEOUT_SECONDS); + if ($timeoutSeconds <= 0) { + $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS; + } + + $responseHeaders = []; + $normalizedHeaders = []; + foreach ($headers as $name => $value) { + $normalizedHeaders[] = $name . ': ' . $value; + } + + curl_setopt_array($curl, [ + CURLOPT_URL => rtrim($this->baseUrl, '/') . $path, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_HTTPHEADER => $normalizedHeaders, + CURLOPT_CONNECTTIMEOUT => $timeoutSeconds, + CURLOPT_TIMEOUT => $timeoutSeconds, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_HEADERFUNCTION => static function ($curlHandle, string $headerLine) use (&$responseHeaders): int { + $length = strlen($headerLine); + $parts = explode(':', $headerLine, 2); + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + + return $length; + }, + ]); + + $body = curl_exec($curl); + if ($body === false) { + $error = curl_error($curl); + curl_close($curl); + throw new RuntimeException('API multipart request failed: ' . $error); + } + + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + $decoded = json_decode($body, true); + + return new ApiResponse($status, $responseHeaders, $decoded, (string)$body); + } } diff --git a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php index f5d7f940..e06e1f4e 100644 --- a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php +++ b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php @@ -81,6 +81,21 @@ final class ApiTestRuntime return new ApiClient($this->baseUrl); } + public function restartServer(): void + { + if ($this->usesExternalBaseUrl) { + return; + } + + if ($this->server !== null) { + $this->server->stop(); + $this->server = null; + } + + $this->baseUrl = self::DEFAULT_BASE_URL; + $this->internalServerPort = null; + } + public function fixtures(): ApiFixtures { if ($this->fixtures === null) { diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php index dee663d2..e6d42ccb 100644 --- a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php @@ -30,7 +30,9 @@ it('builds credential-safe normal CORS response headers for allowed origins', fu expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH'); expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace'); expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control'); + expect($headers['Access-Control-Expose-Headers'])->toContain('Server-Timing'); expect($headers['Access-Control-Max-Age'])->toBe('86400'); + expect($headers['Timing-Allow-Origin'])->toBe('http://localhost:5173'); expect($headers['Vary'])->toBe('Origin'); }); @@ -52,6 +54,8 @@ it('builds preflight CORS response headers for api-v2 release URLs', function () expect($preflight['status'])->toBe(200); expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io'); expect($preflight['headers']['Content-Type'])->toBe('application/json'); + expect($preflight['headers']['Access-Control-Expose-Headers'])->toContain('Server-Timing'); + expect($preflight['headers']['Timing-Allow-Origin'])->toBe('https://api-v2.truckwash.io'); expect($preflight['body'])->toBe(''); }); @@ -71,4 +75,5 @@ it('reflects the request origin for wildcard CORS instead of sending credentiale expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test'); expect($headers['Access-Control-Allow-Credentials'])->toBe('true'); expect($headers['Access-Control-Allow-Origin'])->not->toBe('*'); + expect($headers['Timing-Allow-Origin'])->toBe('https://partner.example.test'); }); diff --git a/services/nginx/app/tests/Unit/Scanner/LicensePlateRecognizerPayloadTest.php b/services/nginx/app/tests/Unit/Scanner/LicensePlateRecognizerPayloadTest.php new file mode 100644 index 00000000..3fc356f1 --- /dev/null +++ b/services/nginx/app/tests/Unit/Scanner/LicensePlateRecognizerPayloadTest.php @@ -0,0 +1,449 @@ +runtimeConfig(); + } + + protected function readRuntimeModuleConfig(): array + { + $this->config_reads++; + + return $this->config_values; + } +} + +class scanner_test_shared_cache_license_plate_recognizer extends licenseplaterecognizer +{ + public static int $config_reads = 0; + public static array $config_values = []; + public static ?scanner_test_runtime_config_cache_store $cache_store = null; + + public function __construct() + { + parent::__construct(false); + } + + public function exposedRuntimeConfig(): array + { + return $this->runtimeConfig(); + } + + protected function shouldUseSharedRuntimeConfigCache(): bool + { + return true; + } + + protected function readRuntimeModuleConfig(): array + { + self::$config_reads++; + + return self::$config_values; + } + + protected function runtimeConfigCacheStore(): ?object + { + return self::$cache_store; + } +} + +class scanner_test_runtime_config_cache_store +{ + public array $store = []; + public array $set_ex_calls = []; + + public function get(string $key): ?string + { + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $expiration): void + { + $this->store[$key] = $value; + $this->set_ex_calls[] = [ + 'key' => $key, + 'value' => $value, + 'expiration' => $expiration, + ]; + } +} + +function reset_license_plate_recognizer_runtime_config_cache(): void +{ + $cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache'); + $cacheProperty->setValue(null, null); + scanner_test_shared_cache_license_plate_recognizer::$config_reads = 0; + scanner_test_shared_cache_license_plate_recognizer::$config_values = []; + scanner_test_shared_cache_license_plate_recognizer::$cache_store = null; +} + +beforeEach(function (): void { + reset_license_plate_recognizer_runtime_config_cache(); +}); + +afterEach(function (): void { + reset_license_plate_recognizer_runtime_config_cache(); +}); + +it('sends data URI images to Plate Recognizer as multipart bytes with fast mode enabled', function (): void { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('buildPlateReaderPayload'); + + $payload = $method->invoke($recognizer, 'data:image/jpeg;base64,' . base64_encode('jpeg-bytes')); + + expect($payload['upload'])->toBeInstanceOf(CURLStringFile::class); + expect($payload['upload']->data)->toBe('jpeg-bytes'); + expect($payload['upload']->postname)->toBe('license-plate.jpg'); + expect($payload['upload']->mime)->toBe('image/jpeg'); + expect(json_decode($payload['config'], true))->toBe([ + 'mode' => 'fast', + 'plates_per_vehicle' => 1, + 'zoom_in_vehicles' => 0, + ]); + expect($payload['regions'])->toBe('dk,de,se,no'); +}); + +it('uses precomputed Plate Reader config strings on the scanner payload hot path', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('PLATE_READER_CONFIG_JSON'); + expect($source)->toContain('RESULT_CACHE_CONTEXT'); + expect($source)->toContain("'config' => self::PLATE_READER_CONFIG_JSON"); + expect($source)->toContain('return self::RESULT_CACHE_CONTEXT;'); + expect($source)->not->toContain("'config' => json_encode(self::PLATE_READER_CONFIG"); + expect($source)->not->toContain("return json_encode([\n 'config' => self::PLATE_READER_CONFIG"); +}); + +it('keeps already raw base64 upload data unchanged', function (): void { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('buildPlateReaderPayload'); + + $payload = $method->invoke($recognizer, 'abc123'); + + expect($payload['upload'])->toBe('abc123'); +}); + +it('does not retain raw upstream Plate Recognizer responses in compact scanner results', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->not->toContain("'raw_response' => \$response_data"); + expect($source)->toContain("'license_plate_number' => \$response_data['results'][0]['plate'] ?? null"); + expect($source)->toContain("'confidence' => \$response_data['results'][0]['score'] ?? null"); + expect($source)->toContain("'message' => 'No license plate detected.'"); +}); + +it('sends multipart route image bytes to Plate Recognizer without base64 wrapping', function (): void { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $uploadMethod = $reflection->getMethod('buildUploadValueFromBytes'); + $payloadMethod = $reflection->getMethod('buildPlateReaderPayloadFromUpload'); + + $upload = $uploadMethod->invoke($recognizer, 'jpeg-bytes', 'image/jpeg'); + $payload = $payloadMethod->invoke($recognizer, $upload); + + expect($payload['upload'])->toBeInstanceOf(CURLStringFile::class); + expect($payload['upload']->data)->toBe('jpeg-bytes'); + expect($payload['upload']->postname)->toBe('license-plate.jpg'); + expect($payload['upload']->mime)->toBe('image/jpeg'); + expect(json_decode($payload['config'], true))->toBe([ + 'mode' => 'fast', + 'plates_per_vehicle' => 1, + 'zoom_in_vehicles' => 0, + ]); + expect($payload['regions'])->toBe('dk,de,se,no'); +}); + +it('sends multipart route upload files to Plate Recognizer without reading bytes into PHP memory', function (): void { + $path = tempnam(sys_get_temp_dir(), 'lpr-upload-'); + expect($path)->not->toBeFalse(); + file_put_contents($path, 'jpeg-bytes'); + + try { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $uploadMethod = $reflection->getMethod('buildUploadValueFromFile'); + $payloadMethod = $reflection->getMethod('buildPlateReaderPayloadFromUpload'); + + $upload = $uploadMethod->invoke($recognizer, $path, 'image/jpeg'); + $payload = $payloadMethod->invoke($recognizer, $upload); + + expect($payload['upload'])->toBeInstanceOf(CURLFile::class); + expect($payload['upload']->getFilename())->toBe($path); + expect($payload['upload']->getPostFilename())->toBe('license-plate.jpg'); + expect($payload['upload']->getMimeType())->toBe('image/jpeg'); + expect(json_decode($payload['config'], true))->toBe([ + 'mode' => 'fast', + 'plates_per_vehicle' => 1, + 'zoom_in_vehicles' => 0, + ]); + expect($payload['regions'])->toBe('dk,de,se,no'); + } finally { + @unlink($path); + } +}); + +it('records Plate Recognizer processing time from the upstream response', function (): void { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('recordResponseTimings'); + + $method->invoke($recognizer, [ + 'processing_time' => 58.184, + ]); + + expect($recognizer->getLastTimings())->toHaveKey('upstream_processing', 58.184); +}); + +it('can skip config object setup and cache runtime config for scanner requests', function (): void { + $recognizer = new scanner_test_license_plate_recognizer([ + 'enabled' => 'true', + 'api_key' => 'test-key', + ]); + $configProperty = new ReflectionProperty(licenseplaterecognizer::class, 'config'); + + expect($configProperty->isInitialized($recognizer))->toBeFalse(); + expect($recognizer->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'test-key', + ]); + expect($recognizer->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'test-key', + ]); + expect($recognizer->config_reads)->toBe(1); +}); + +it('reads runtime config once in the scanner recognition hot path', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('$runtime_config = $this->runtimeConfig();'); + expect($source)->toContain('$api_key = $runtime_config[\'api_key\'];'); + expect($source)->not->toContain('$this->requireModuleEnabled();' . "\n " . '$api_key = $this->runtimeConfig()[\'api_key\'];'); +}); + +it('shares runtime config briefly across scanner recognizer instances', function (): void { + scanner_test_shared_cache_license_plate_recognizer::$config_values = [ + 'enabled' => 'true', + 'api_key' => 'cached-key', + ]; + + $first = new scanner_test_shared_cache_license_plate_recognizer(); + expect($first->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'cached-key', + ]); + + scanner_test_shared_cache_license_plate_recognizer::$config_values = [ + 'enabled' => 'false', + 'api_key' => 'new-key', + ]; + + $second = new scanner_test_shared_cache_license_plate_recognizer(); + expect($second->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'cached-key', + ]); + expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1); +}); + +it('refreshes the shared runtime config cache after the short scanner ttl', function (): void { + $cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache'); + $cacheProperty->setValue(null, [ + 'values' => [ + 'enabled' => true, + 'api_key' => 'stale-key', + ], + 'cached_at' => microtime(true) - 20, + ]); + scanner_test_shared_cache_license_plate_recognizer::$config_values = [ + 'enabled' => 'false', + 'api_key' => 'fresh-key', + ]; + + $recognizer = new scanner_test_shared_cache_license_plate_recognizer(); + + expect($recognizer->exposedRuntimeConfig())->toBe([ + 'enabled' => false, + 'api_key' => 'fresh-key', + ]); + expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1); +}); + +it('uses Redis-backed runtime config cache across scanner requests when available', function (): void { + $cache = new scanner_test_runtime_config_cache_store(); + scanner_test_shared_cache_license_plate_recognizer::$cache_store = $cache; + scanner_test_shared_cache_license_plate_recognizer::$config_values = [ + 'enabled' => 'true', + 'api_key' => 'redis-key', + ]; + + $first = new scanner_test_shared_cache_license_plate_recognizer(); + expect($first->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'redis-key', + ]); + expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1); + expect($cache->set_ex_calls)->toHaveCount(1); + expect($cache->set_ex_calls[0]['expiration'])->toBe(15); + + $cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache'); + $cacheProperty->setValue(null, null); + scanner_test_shared_cache_license_plate_recognizer::$config_values = [ + 'enabled' => 'false', + 'api_key' => 'db-should-not-be-read', + ]; + + $second = new scanner_test_shared_cache_license_plate_recognizer(); + + expect($second->exposedRuntimeConfig())->toBe([ + 'enabled' => true, + 'api_key' => 'redis-key', + ]); + expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1); +}); + +it('treats false runtime config values as disabled', function (): void { + $recognizer = new scanner_test_license_plate_recognizer([ + 'enabled' => 'false', + 'api_key' => 'test-key', + ]); + + expect($recognizer->exposedRuntimeConfig())->toBe([ + 'enabled' => false, + 'api_key' => 'test-key', + ]); + expect(fn () => $recognizer->requireModuleEnabled()) + ->toThrow(Exception::class, 'licenseplaterecognizer module is not enabled.'); +}); + +it('can point Plate Recognizer calls at a local measurement upstream', function (): void { + $recognizer = new licenseplaterecognizer(false, 'http://127.0.0.1:18081/'); + $apiUrlProperty = new ReflectionProperty(licenseplaterecognizer::class, 'api_url'); + + expect($apiUrlProperty->getValue($recognizer))->toBe('http://127.0.0.1:18081'); +}); + +it('disables curl expect continue waits for large multipart uploads', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain("'Expect:'"); +}); + +it('disables tcp write coalescing on scanner Plate Recognizer requests when curl supports it', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('$curl_options = ['); + expect($source)->toContain("defined('CURLOPT_TCP_NODELAY')"); + expect($source)->toContain("\$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;"); + expect($source)->toContain('curl_setopt_array($ch, $curl_options);'); +}); + +it('does not capture outgoing curl headers on the scanner hot path', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->not->toContain('CURLINFO_HEADER_OUT'); + expect($source)->toContain('curl_setopt_array($ch'); +}); + +it('records only selected curl timing fields on the scanner hot path', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('$this->recordCurlTimings($ch);'); + expect($source)->not->toContain('$curl_info = curl_getinfo($ch);'); + expect($source)->toContain('CURLINFO_NAMELOOKUP_TIME'); + expect($source)->toContain('CURLINFO_TOTAL_TIME'); +}); + +it('aborts upstream recognition transfers when the HTTP client disconnects', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('CURLOPT_NOPROGRESS'); + expect($source)->toContain('CURLOPT_XFERINFOFUNCTION'); + expect($source)->toContain('connection_aborted() ? 1 : 0'); +}); + +it('bounds Plate Recognizer scanner uploads with conservative curl timeouts', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('PLATE_READER_CONNECT_TIMEOUT_MS = 1000'); + expect($source)->toContain('PLATE_READER_TOTAL_TIMEOUT_MS = 4500'); + expect($source)->toContain('CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS'); + expect($source)->toContain('CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS'); + expect($source)->toContain('CURLOPT_NOSIGNAL => true'); +}); + +it('uses a short exact-image result cache before calling Plate Recognizer', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('RESULT_CACHE_TTL_SECONDS = 10'); + expect($source)->toContain('RESULT_CACHE_REDIS_KEY_PREFIX'); + expect($source)->toContain('$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);'); + expect($source)->toContain('return $cached_result;'); + expect($source)->toContain('$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);'); + expect($source)->toContain('$this->last_timings[\'cache\']'); + expect($source)->toContain('$this->last_timings[\'cache_hit\'] = 1;'); + expect($source)->toContain('$this->last_timings[\'cache_miss\'] = 1;'); +}); + +it('does not hash multipart upload temp files for result-cache misses', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('public function licenseplaterecognizerUploadFile'); + expect($source)->toContain('return $this->recognizePlate('); + expect($source)->toContain('$this->buildUploadValueFromFile($image_path, $mime_type)'); + expect($source)->not->toContain('buildResultCacheKeyFromFile'); + expect($source)->not->toContain('hash_update_file($context, $image_path)'); + expect($source)->not->toContain('file_get_contents($image_path'); + expect($source)->not->toContain('hash_update($context, $chunk)'); +}); + +it('does not hash raw scanner body bytes on the live camera upload path', function (): void { + $source = file_get_contents(app_path('classes/licenseplaterecognizer.php')); + $route = file_get_contents(app_path('routes/moduleScannerRoute.php')); + + expect($source)->not->toBeFalse(); + expect($route)->not->toBeFalse(); + expect($source)->toContain('public function licenseplaterecognizerUploadUncached'); + expect($source)->toContain('fn () => $this->buildPlateReaderPayloadFromUpload('); + expect($route)->toContain('licenseplaterecognizerUploadUncached($raw_image_upload'); + expect($route)->not->toContain('licenseplaterecognizerUpload($raw_image_upload'); +}); + +it('builds stable result cache keys from equivalent in-memory image bytes', function (): void { + $reflection = new ReflectionClass(licenseplaterecognizer::class); + $recognizer = $reflection->newInstanceWithoutConstructor(); + $bytesMethod = $reflection->getMethod('buildResultCacheKeyFromBytes'); + $uploadStringMethod = $reflection->getMethod('buildResultCacheKeyFromUploadString'); + + $bytesKey = $bytesMethod->invoke($recognizer, 'jpeg-bytes'); + $dataUriKey = $uploadStringMethod->invoke($recognizer, 'data:image/jpeg;base64,' . base64_encode('jpeg-bytes')); + $differentKey = $bytesMethod->invoke($recognizer, 'other-jpeg-bytes'); + + expect($dataUriKey)->toBe($bytesKey); + expect($differentKey)->not->toBe($bytesKey); + expect($bytesKey)->toStartWith('licenseplaterecognizer:result:v1:'); +}); diff --git a/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php index 4fa6380e..10d5b273 100644 --- a/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php +++ b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php @@ -1,5 +1,9 @@ toContain('], 200);'); expect($route)->not->toContain("throw new Exception('License plate extraction failed.')"); }); + +it('returns low-confidence LPR results without a failed HTTP status', function (): void { + $route = file_get_contents(app_path('routes/moduleScannerRoute.php')); + + expect($route)->not->toBeFalse(); + expect($route)->toContain("'reason' => 'low_confidence_license_plate'"); + expect($route)->toContain("'message' => 'License plate recognition confidence too low.'"); + expect($route)->toContain("'confidence' => \$lpr_result['confidence']"); + expect($route)->not->toContain('License plate recognition confidence too low. Score:'); + expect($route)->not->toContain('use Exception;'); +}); + +it('does not store scanner images before LPR recognition', function (): void { + $route = file_get_contents(app_path('routes/moduleScannerRoute.php')); + + expect($route)->not->toBeFalse(); + expect($route)->toContain('new licenseplaterecognizer(false)'); + expect($route)->toContain('getLPRImageUpload()'); + expect($route)->toContain('getLPRRawImageUpload()'); + expect($route)->toContain('$base64_image = null;'); + expect($route)->toContain('if ($image_upload === null && $raw_image_upload === null)'); + expect($route)->toContain('licenseplaterecognizerUploadFile($image_upload'); + expect($route)->toContain('licenseplaterecognizerUploadUncached($raw_image_upload'); + expect($route)->toContain("'path' => \$tmp_name"); + expect($route)->toContain("'data' => \$image_data"); + expect($route)->toContain("file_get_contents('php://input')"); + expect($route)->toContain("str_starts_with(\$mime_type, 'image/')"); + expect($route)->toContain('$recognizer->licenseplaterecognizer((string)$base64_image)'); + expect($route)->toContain('$route_started_at = microtime(true);'); + expect($route)->toContain('$client_timings = self::getLPRClientTimings();'); + expect($route)->toContain('array_merge($client_timings, $recognizer->getLastTimings())'); + expect($route)->toContain("header('Server-Timing: '"); + expect($route)->toContain("'client_capture'"); + expect($route)->toContain("'client_preflight'"); + expect($route)->toContain("'client_visual_fingerprint'"); + expect($route)->toContain("'client_draw'"); + expect($route)->toContain("'client_encode'"); + expect($route)->toContain("'client_frame_width'"); + expect($route)->toContain("'client_frame_height'"); + expect($route)->toContain("'client_frame_bytes'"); + expect($route)->toContain("'cache'"); + expect($route)->toContain("'cache_hit'"); + expect($route)->toContain("'cache_miss'"); + expect($route)->toContain("\$timings['local'] = self::getLPRLocalDuration(\$timings);"); + expect($route)->toContain("'local'"); + expect($route)->toContain("'upstream_dns'"); + expect($route)->toContain("'upstream_connect'"); + expect($route)->toContain("'upstream_tls'"); + expect($route)->toContain("'upstream_pretransfer'"); + expect($route)->toContain("'upstream_ttfb'"); + expect($route)->toContain("'upstream_total'"); + expect($route)->toContain("'upstream_processing'"); + expect($route)->toContain("'route_total'"); + expect($route)->toContain("'request_total'"); + expect($route)->toContain('HTTP_X_LPR_CLIENT_CAPTURE_MS'); + expect($route)->toContain('HTTP_X_LPR_CLIENT_FRAME_BYTES'); + expect($route)->toContain('$value = $_GET[$field] ?? null;'); + expect($route)->toContain("isset(\$_SERVER['REQUEST_TIME_FLOAT'])"); + expect($route)->not->toContain("requireParameters(['base64_image'])"); + expect($route)->not->toContain('file_get_contents($tmp_name)'); + expect($route)->not->toContain('storeTempImageFromBase64'); + expect($route)->not->toContain('new upload_store'); + expect($route)->not->toContain('new openai'); +}); + +it('reads raw scanner client timing metadata from request query parameters', function (): void { + $previousGet = $_GET; + $previousPost = $_POST; + $headerNames = [ + 'HTTP_X_LPR_CLIENT_CAPTURE_MS', + 'HTTP_X_LPR_CLIENT_PREFLIGHT_MS', + 'HTTP_X_LPR_CLIENT_DRAW_MS', + 'HTTP_X_LPR_CLIENT_ENCODE_MS', + 'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS', + 'HTTP_X_LPR_CLIENT_FRAME_WIDTH', + 'HTTP_X_LPR_CLIENT_FRAME_HEIGHT', + 'HTTP_X_LPR_CLIENT_FRAME_BYTES', + ]; + $previousHeaders = []; + foreach ($headerNames as $headerName) { + $previousHeaders[$headerName] = $_SERVER[$headerName] ?? null; + } + + try { + $_GET = [ + 'client_capture_ms' => '12.345', + 'client_preflight_ms' => '1.500', + 'client_draw_ms' => '2.250', + 'client_encode_ms' => '8.500', + 'client_visual_fingerprint_ms' => '0.750', + 'client_frame_width' => '384', + 'client_frame_height' => '216', + 'client_frame_bytes' => '12345', + ]; + $_POST = []; + + $reflection = new ReflectionClass(moduleScannerRoute::class); + $method = $reflection->getMethod('getLPRClientTimings'); + + expect($method->invoke(null))->toMatchArray([ + 'client_capture' => 12.345, + 'client_preflight' => 1.5, + 'client_draw' => 2.25, + 'client_encode' => 8.5, + 'client_visual_fingerprint' => 0.75, + 'client_frame_width' => 384.0, + 'client_frame_height' => 216.0, + 'client_frame_bytes' => 12345.0, + ]); + } finally { + $_GET = $previousGet; + $_POST = $previousPost; + foreach ($previousHeaders as $headerName => $previousValue) { + if ($previousValue === null) { + unset($_SERVER[$headerName]); + } else { + $_SERVER[$headerName] = $previousValue; + } + } + } +});