476 lines
19 KiB
PHP
476 lines
19 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('normalizes license plate recognizer info responses decoded as arrays or objects', function (): void {
|
|
$arrayInfo = new \licenseplaterecognizer\helpers\licenseplaterecognizer_info([
|
|
'version' => '1.54.0',
|
|
'usage' => ['calls' => '98'],
|
|
'license_key' => 'license-key',
|
|
'webhooks' => [['id' => 'webhook-1']],
|
|
'total_calls' => '2500',
|
|
]);
|
|
$objectInfo = new \licenseplaterecognizer\helpers\licenseplaterecognizer_info((object)[
|
|
'version' => '1.55.0',
|
|
'usage' => (object)['calls' => 7],
|
|
'license_key' => 'license-key-2',
|
|
'webhooks' => [],
|
|
'total_calls' => 100,
|
|
]);
|
|
|
|
expect($arrayInfo->version)->toBe('1.54.0');
|
|
expect($arrayInfo->usage->calls)->toBe(98);
|
|
expect($arrayInfo->license_key)->toBe('license-key');
|
|
expect($arrayInfo->webhooks)->toHaveCount(1);
|
|
expect($arrayInfo->total_calls)->toBe(2500);
|
|
expect($objectInfo->version)->toBe('1.55.0');
|
|
expect($objectInfo->usage->calls)->toBe(7);
|
|
expect($objectInfo->total_calls)->toBe(100);
|
|
});
|
|
|
|
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:');
|
|
});
|