Files
api/services/nginx/app/tests/Unit/Scanner/LicensePlateRecognizerPayloadTest.php
T

450 lines
18 KiB
PHP

<?php
use classes\licenseplaterecognizer;
class scanner_test_license_plate_recognizer extends licenseplaterecognizer
{
public int $config_reads = 0;
public function __construct(private readonly array $config_values)
{
parent::__construct(false);
}
public function exposedRuntimeConfig(): array
{
return $this->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:');
});