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