Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ccd7749d0 | ||
|
|
b3ba3c8de5 | ||
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
cea469c95a |
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
|||||||
|
|
||||||
# Copy Nginx configuration file
|
# Copy Nginx configuration file
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ RUN set -eux; \
|
|||||||
|
|
||||||
COPY services/nginx/app/ /var/www/html/
|
COPY services/nginx/app/ /var/www/html/
|
||||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||||
|
|
||||||
|
|||||||
@@ -9448,6 +9448,14 @@ paths:
|
|||||||
license_plate:
|
license_plate:
|
||||||
type: string
|
type: string
|
||||||
description: Required for START command
|
description: Required for START command
|
||||||
|
wash_type:
|
||||||
|
type: string
|
||||||
|
enum: [Manual, Machine]
|
||||||
|
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
|
||||||
|
wash_mode:
|
||||||
|
type: string
|
||||||
|
enum: [manual, machine]
|
||||||
|
description: Lowercase alias for wash_type accepted by backend clients.
|
||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
||||||
|
|||||||
+10
-1
@@ -128,7 +128,16 @@ tar \
|
|||||||
-C services/nginx/app -cf - . \
|
-C services/nginx/app -cf - . \
|
||||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||||
|
|
||||||
|
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||||
|
tar \
|
||||||
|
-cf - \
|
||||||
|
Dockerfile \
|
||||||
|
Dockerfile.coolify-api \
|
||||||
|
services/php/Dockerfile \
|
||||||
|
services/php/php-fpm-pool.conf \
|
||||||
|
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
||||||
|
|
||||||
composer_install
|
composer_install
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc \
|
docker compose $compose_files exec -T php1 sh -lc \
|
||||||
"cd /var/www/html && composer test:ci:$suite"
|
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||||
|
|||||||
@@ -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_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 ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||||
|
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||||
public const MAX_AGE_SECONDS = '86400';
|
public const MAX_AGE_SECONDS = '86400';
|
||||||
|
|
||||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||||
@@ -128,7 +129,9 @@ class cors_policy
|
|||||||
'Access-Control-Allow-Credentials' => 'true',
|
'Access-Control-Allow-Credentials' => 'true',
|
||||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||||
|
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
||||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||||
|
'Timing-Allow-Origin' => $origin,
|
||||||
'Vary' => 'Origin',
|
'Vary' => 'Origin',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
|||||||
|
|
||||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
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<string, float>
|
||||||
|
*/
|
||||||
|
private array $last_timings = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The configuration of the module
|
* The configuration of the module
|
||||||
* @var licenseplaterecognizer_c
|
* @var licenseplaterecognizer_c
|
||||||
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
* API URL
|
* API URL
|
||||||
* @var string
|
* @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
|
public function requireModuleEnabled(): void
|
||||||
{
|
{
|
||||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
if (!$this->runtimeConfig()['enabled']) {
|
||||||
throw new Exception('licenseplaterecognizer module is not 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
|
public function licenseplaterecognizer(string $base64_image): array
|
||||||
{
|
{
|
||||||
$image_processor = new image_processor();
|
return $this->recognizePlate(
|
||||||
|
fn () => $this->buildPlateReaderPayload($base64_image),
|
||||||
//ADD PARAMETER IN REQUEST LIKE regions
|
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
||||||
$data = array(
|
|
||||||
'upload' => $base64_image,
|
|
||||||
//'regions' => 'dk' // Optional
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare new cURL resource
|
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||||
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
{
|
||||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
return $this->recognizePlate(
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||||
curl_setopt($ch, CURLOPT_POST, true);
|
),
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
||||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set HTTP Header for POST request
|
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
{
|
||||||
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
return $this->recognizePlate(
|
||||||
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||||
|
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Submit the POST request and close cURL session handle
|
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
||||||
$result = curl_exec($ch);
|
{
|
||||||
curl_close($ch);
|
return $this->recognizePlate(
|
||||||
// Print the response from the server
|
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||||
if ($result === false) {
|
$this->buildUploadValueFromFile($image_path, $mime_type)
|
||||||
throw new Exception('Error in API request.');
|
)
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$response_data = json_decode($result, true);
|
/**
|
||||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
* @throws Exception
|
||||||
return [
|
*/
|
||||||
'success' => true,
|
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
||||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
{
|
||||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
$started_at = microtime(true);
|
||||||
'raw_response' => $response_data,
|
$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 {
|
if (defined('CURLOPT_TCP_NODELAY')) {
|
||||||
return [
|
$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,
|
'success' => false,
|
||||||
'message' => 'No license plate detected.',
|
'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
|
* @inheritDoc
|
||||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
* @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
|
public function get_usage(): licenseplaterecognizer_info
|
||||||
{
|
{
|
||||||
// Require the module to be enabled
|
|
||||||
$this->requireModuleEnabled();
|
$this->requireModuleEnabled();
|
||||||
|
$api_key = $this->runtimeConfig()['api_key'];
|
||||||
$curl = curl_init();
|
$curl = curl_init();
|
||||||
curl_setopt_array($curl, array(
|
curl_setopt_array($curl, array(
|
||||||
CURLOPT_URL => $this->api_url . '/info/',
|
CURLOPT_URL => $this->api_url . '/info/',
|
||||||
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||||
CURLOPT_HTTPHEADER => array(
|
CURLOPT_HTTPHEADER => [
|
||||||
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
'Authorization: Token ' . $api_key,
|
||||||
),
|
],
|
||||||
));
|
));
|
||||||
$response = curl_exec($curl);
|
$response = curl_exec($curl);
|
||||||
curl_close($curl);
|
curl_close($curl);
|
||||||
@@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
}
|
}
|
||||||
return new licenseplaterecognizer_info($response_data);
|
return new licenseplaterecognizer_info($response_data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class slack implements notification_i
|
|||||||
. "Status: $status";
|
. "Status: $status";
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send_message(string $string, string $module = null): void
|
public function send_message(string $string, ?string $module = null): void
|
||||||
{
|
{
|
||||||
global $SLACK_DEFAULT_WEBHOOK;
|
global $SLACK_DEFAULT_WEBHOOK;
|
||||||
// Format the message if a module is provided
|
// Format the message if a module is provided
|
||||||
@@ -147,7 +147,7 @@ class slack implements notification_i
|
|||||||
|
|
||||||
public function send_customer_registration_notification(int $customer_number): self
|
public function send_customer_registration_notification(int $customer_number): self
|
||||||
{
|
{
|
||||||
$webhook = trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
$webhook = $this->get_customer_registration_webhook_url();
|
||||||
if ($webhook === '') {
|
if ($webhook === '') {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,52 @@ class slack implements notification_i
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
||||||
|
*
|
||||||
|
* @return array{configured:bool,sent:bool,message:string}
|
||||||
|
*/
|
||||||
|
public function test_customer_registration_webhook(): array
|
||||||
|
{
|
||||||
|
$webhook = $this->get_customer_registration_webhook_url();
|
||||||
|
if ($webhook === '') {
|
||||||
|
return [
|
||||||
|
'configured' => false,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->send_webhook_message(
|
||||||
|
$this->format_customer_registration_test(),
|
||||||
|
$webhook
|
||||||
|
);
|
||||||
|
$sent = $this->is_webhook_send_successful($result);
|
||||||
|
|
||||||
|
self::add_log($sent
|
||||||
|
? 'Slack customer registration test webhook sent successfully.'
|
||||||
|
: 'Slack customer registration test webhook failed.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => $sent,
|
||||||
|
'message' => $sent
|
||||||
|
? 'Slack test message sent successfully.'
|
||||||
|
: 'Slack test message failed.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function get_customer_registration_webhook_url(): string
|
||||||
|
{
|
||||||
|
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function is_webhook_send_successful(string $result): bool
|
||||||
|
{
|
||||||
|
return !str_starts_with($result, 'Failed to send message:');
|
||||||
|
}
|
||||||
|
|
||||||
public function format_customer_registration(int $customer_number): string
|
public function format_customer_registration(int $customer_number): string
|
||||||
{
|
{
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||||
@@ -178,4 +224,10 @@ class slack implements notification_i
|
|||||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||||
. "Open in Superuser: $customerUrl";
|
. "Open in Superuser: $customerUrl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function format_customer_registration_test(): string
|
||||||
|
{
|
||||||
|
return "*Truck Wash Slack test*\n"
|
||||||
|
. "Customer registration notifications are configured correctly.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
|
|||||||
* @return array An array containing the plate number and other relevant information.
|
* @return array An array containing the plate number and other relevant information.
|
||||||
*/
|
*/
|
||||||
public function licenseplaterecognizer(string $base64_image): array;
|
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.
|
* Get usage information about the license plate recognizer module.
|
||||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||||
* @see licenseplaterecognizer_info
|
* @see licenseplaterecognizer_info
|
||||||
*/
|
*/
|
||||||
public function get_usage(): licenseplaterecognizer_info;
|
public function get_usage(): licenseplaterecognizer_info;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace email\templates;
|
namespace email\templates;
|
||||||
|
|
||||||
use email\helpers\email_template;use objects\users_o;
|
use email\helpers\email_template;
|
||||||
|
use objects\users_o;
|
||||||
|
|
||||||
class email_template_new_customer
|
class email_template_new_customer
|
||||||
{
|
{
|
||||||
@@ -52,6 +53,7 @@ class email_template_new_customer
|
|||||||
*/
|
*/
|
||||||
public function generate_html(): string
|
public function generate_html(): string
|
||||||
{
|
{
|
||||||
|
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
||||||
ob_start();
|
ob_start();
|
||||||
# Start of the html
|
# Start of the html
|
||||||
?>
|
?>
|
||||||
@@ -73,7 +75,7 @@ class email_template_new_customer
|
|||||||
|
|
||||||
<!-- Intro -->
|
<!-- Intro -->
|
||||||
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
||||||
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
|
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- You can now wash your trucks -->
|
<!-- You can now wash your trucks -->
|
||||||
@@ -185,4 +187,19 @@ class email_template_new_customer
|
|||||||
# End of the html
|
# End of the html
|
||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getCustomerRegistrationLabel(): string
|
||||||
|
{
|
||||||
|
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
|
||||||
|
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
|
||||||
|
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
|
||||||
|
|
||||||
|
$customer->getCustomerEcocomicData($this->customer_number);
|
||||||
|
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
|
||||||
|
if ($corporate_identification_number !== '') {
|
||||||
|
$customer_label .= ' (' . $corporate_identification_number . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $customer_label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class selfserve_lane_command_arguments
|
|||||||
public ?string $license_plate = null;
|
public ?string $license_plate = null;
|
||||||
public ?int $customer_number = null;
|
public ?int $customer_number = null;
|
||||||
public ?int $subuser_id = null;
|
public ?int $subuser_id = null;
|
||||||
|
public ?string $wash_mode = null;
|
||||||
public bool $defer_relay_side_effects = false;
|
public bool $defer_relay_side_effects = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +33,22 @@ class selfserve_lane_command_arguments
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setWashMode(?string $wash_mode): self
|
||||||
|
{
|
||||||
|
$normalized = strtolower(trim((string)$wash_mode));
|
||||||
|
if ($wash_mode === null || $normalized === '') {
|
||||||
|
$this->wash_mode = null;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($normalized, ['manual', 'machine'], true)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->wash_mode = $normalized;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||||
{
|
{
|
||||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||||
@@ -50,6 +67,12 @@ class selfserve_lane_command_arguments
|
|||||||
if (array_key_exists('subuser_id', $params)) {
|
if (array_key_exists('subuser_id', $params)) {
|
||||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('wash_type', $params)) {
|
||||||
|
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('wash_mode', $params)) {
|
||||||
|
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
|
||||||
|
}
|
||||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||||
$this->setDeferRelaySideEffects(filter_var(
|
$this->setDeferRelaySideEffects(filter_var(
|
||||||
$params['defer_relay_side_effects'],
|
$params['defer_relay_side_effects'],
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class selfserve_studio_graph
|
|||||||
{
|
{
|
||||||
private const DEFAULT_PATH_MAX_STATES = 2048;
|
private const DEFAULT_PATH_MAX_STATES = 2048;
|
||||||
private const MAX_PATH_MAX_STATES = 2048;
|
private const MAX_PATH_MAX_STATES = 2048;
|
||||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
|
||||||
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
private const MAX_PATH_SAMPLE_LIMIT = 2048;
|
||||||
|
|
||||||
/** @var array<string,array<int,string>> */
|
/** @var array<string,array<int,string>> */
|
||||||
private array $columnCache = [];
|
private array $columnCache = [];
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||||
{
|
{
|
||||||
$session = $reg !== null
|
$session = $reg !== null
|
||||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||||
@@ -438,7 +438,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
if (!$session->markCompletedIfOpen($orderId)) {
|
if (!$session->markCompletedIfOpen($orderId)) {
|
||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
$this->disableMachineRelayForCompletedWash($laneId);
|
if ($disableRelays) {
|
||||||
|
$this->disableMachineRelayForCompletedWash($laneId);
|
||||||
|
}
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
|||||||
|
|
||||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
||||||
|
|
||||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ Public methods:
|
|||||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
|
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
||||||
|
|
||||||
Key implementation details:
|
Key implementation details:
|
||||||
|
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ trait selfserve_lane_command_t
|
|||||||
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
||||||
* If machine relay is not configured, this is a no-op.
|
* If machine relay is not configured, this is a no-op.
|
||||||
*/
|
*/
|
||||||
protected function setMachineRelayStatusForWashStart(): void
|
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($this->isMachineWashSelectedAndAvailableForStart()) {
|
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
||||||
try {
|
try {
|
||||||
$this->setMachineRelayStatusHard(true);
|
$this->setMachineRelayStatusHard(true);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -194,38 +194,39 @@ trait selfserve_lane_command_t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep the program picker relay aligned with the selected wash mode at START.
|
* Keep the program picker relay aligned with the selected wash mode at START.
|
||||||
* It is ON only when the active self-serve session is allowed to start machine wash.
|
* It is ON only when the customer explicitly selected machine wash.
|
||||||
*/
|
*/
|
||||||
protected function setProgramPickerRelayStatusForWashStart(): void
|
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$shouldEnable = $this->isMachineWashSelectedAndAvailableForStart();
|
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
||||||
|
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
|
||||||
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
|
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Best effort only; wash start must continue.
|
// Best effort only; wash start must continue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
|
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->setMachineProgramPickerRelayStatusHard($this->isMachineServiceSelectedForWashStart());
|
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Best effort only; wash start must continue.
|
// Best effort only; wash start must continue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function isMachineWashSelectedAndAvailableForStart(): bool
|
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->isMachineServiceSelectedForWashStart()) {
|
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +249,39 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
|
{
|
||||||
|
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isMachineServiceSelectedForWashStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
|
{
|
||||||
|
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
||||||
|
&& $this->isMachineServiceSelectedForWashStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
|
{
|
||||||
|
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
|
{
|
||||||
|
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isMachineServiceSelectedForWashStart();
|
||||||
|
}
|
||||||
|
|
||||||
protected function isMachineServiceSelectedForWashStart(): bool
|
protected function isMachineServiceSelectedForWashStart(): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -349,17 +383,24 @@ trait selfserve_lane_command_t
|
|||||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||||
{
|
{
|
||||||
if ($arguments->defer_relay_side_effects) {
|
if ($arguments->defer_relay_side_effects) {
|
||||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart();
|
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->turnOnCleanerRelayForWashStart();
|
$this->turnOnCleanerRelayForWashStart();
|
||||||
$this->setProgramPickerRelayStatusForWashStart();
|
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
||||||
$this->setMachineRelayStatusForWashStart();
|
$this->setMachineRelayStatusForWashStart($arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveSelfServeActionWashModeForStart(): string
|
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
|
||||||
{
|
{
|
||||||
|
if ($arguments !== null && in_array($arguments->wash_mode, [
|
||||||
|
selfserve_studio_actions::MODE_MANUAL,
|
||||||
|
selfserve_studio_actions::MODE_MACHINE,
|
||||||
|
], true)) {
|
||||||
|
return $arguments->wash_mode;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->isMachineServiceSelectedForWashStart()) {
|
if ($this->isMachineServiceSelectedForWashStart()) {
|
||||||
return selfserve_studio_actions::MODE_MACHINE;
|
return selfserve_studio_actions::MODE_MACHINE;
|
||||||
}
|
}
|
||||||
@@ -497,7 +538,8 @@ trait selfserve_lane_command_t
|
|||||||
$this->id,
|
$this->id,
|
||||||
$this->getLicensePlate() ?: null,
|
$this->getLicensePlate() ?: null,
|
||||||
$this->getCustomerNumber() ?: null,
|
$this->getCustomerNumber() ?: null,
|
||||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Session completion must not block STOP flow.
|
// Session completion must not block STOP flow.
|
||||||
@@ -634,7 +676,7 @@ trait selfserve_lane_command_t
|
|||||||
$this->runRelaySideEffectsForWashStart($arguments);
|
$this->runRelaySideEffectsForWashStart($arguments);
|
||||||
$this->runPublishedStudioActions(
|
$this->runPublishedStudioActions(
|
||||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||||
$this->resolveSelfServeActionWashModeForStart(),
|
$this->resolveSelfServeActionWashModeForStart($arguments),
|
||||||
[
|
[
|
||||||
'customer_number' => (int)$customer_number,
|
'customer_number' => (int)$customer_number,
|
||||||
'reg' => $license_plate,
|
'reg' => $license_plate,
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const TOKEN_LENGTH = 32;
|
const TOKEN_LENGTH = 32;
|
||||||
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
$this->setTable('customer_password_reset_keys');
|
$this->setTable('customer_password_reset_keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validTokenWhereClause(): string
|
||||||
|
{
|
||||||
|
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new customer reset key
|
* Add a new customer reset key
|
||||||
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
|
|||||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Query the database for a valid token
|
// Query the database for a valid token using the same clock that writes created_at.
|
||||||
$current_time = date('Y-m-d H:i:s');
|
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
|
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
if ($result->num_rows === 0) {
|
if ($result->num_rows === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
|
|||||||
*/
|
*/
|
||||||
public function isValidToken(): bool
|
public function isValidToken(): bool
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
$created_at = strtotime($this->created_at->value());
|
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||||
$current_time = time();
|
$result = $db->query($sql);
|
||||||
return (
|
return $result->num_rows > 0;
|
||||||
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
|
||||||
($this->deleted_at->value() === null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
|
|||||||
{
|
{
|
||||||
//TODO: Add cache invalidation
|
//TODO: Add cache invalidation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,40 +127,47 @@ class users_o extends db
|
|||||||
|
|
||||||
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
||||||
{
|
{
|
||||||
global $db;
|
|
||||||
// Get the customer data from the external source
|
// Get the customer data from the external source
|
||||||
$economic = new economicCustomers();
|
$economic = new economicCustomers();
|
||||||
$customer_data = $economic->getCustomerId($customer_number);
|
$customer_data = $economic->getCustomerId($customer_number);
|
||||||
// DEBUG: Return the customer data
|
|
||||||
// Check if the customer exists
|
|
||||||
if ($customer_data) {
|
if ($customer_data) {
|
||||||
// Avoid SQL injection
|
return $this->importCustomerFromEconomicCustomerData($customer_data);
|
||||||
$customer_number = $db->escape_string($customer_data->customerNumber);
|
|
||||||
// Double check if the customer exists
|
|
||||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
$this->id = $result->fetch_assoc()['id'];
|
|
||||||
$this->getObjectProperties();
|
|
||||||
} else {
|
|
||||||
// Import the customer
|
|
||||||
$this->add($customer_number, '', 0);
|
|
||||||
// Nullify the password
|
|
||||||
$this->password->nullify();
|
|
||||||
// If the customer has an email address, save it
|
|
||||||
if (isset($customer_data->email)) {
|
|
||||||
$this->email->set($customer_data->email);
|
|
||||||
}
|
|
||||||
// If the customer has a name, save it as the display name
|
|
||||||
if (isset($customer_data->name)) {
|
|
||||||
$this->display_name->set($customer_data->name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Else return false
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function importCustomerFromEconomicCustomerData(object $customer_data): users_o|bool
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (!isset($customer_data->customerNumber) || !is_numeric($customer_data->customerNumber)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer_number = $db->escape_string((string)$customer_data->customerNumber);
|
||||||
|
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$this->id = (int)$result->fetch_assoc()['id'];
|
||||||
|
$this->getObjectProperties();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->add($customer_number, '', 0);
|
||||||
|
$this->password->nullify();
|
||||||
|
|
||||||
|
if (isset($customer_data->email)) {
|
||||||
|
$this->email->set($customer_data->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($customer_data->name)) {
|
||||||
|
$this->display_name->set($customer_data->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -258,7 +265,7 @@ class users_o extends db
|
|||||||
* @param int|null $user_id The user id to add the attribute to
|
* @param int|null $user_id The user id to add the attribute to
|
||||||
* @throws Exception If the user is not selected, and the user_id is null
|
* @throws Exception If the user is not selected, and the user_id is null
|
||||||
*/
|
*/
|
||||||
public function addAttribute(string $attribute, int $user_id = null): void
|
public function addAttribute(string $attribute, ?int $user_id = null): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -272,7 +279,7 @@ class users_o extends db
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteAttribute(string $attribute, int $user_id = null): void
|
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -304,7 +311,7 @@ class users_o extends db
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function doesUserHaveAttribute(string $attribute, int $user_id = null): bool
|
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -518,8 +525,12 @@ class users_o extends db
|
|||||||
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
public function getCustomerEcocomicData(?int $customer_number = null): users_o
|
||||||
{
|
{
|
||||||
|
if ($customer_number !== null && !isset($this->id)) {
|
||||||
|
$this->getUserByCustomerNumber($customer_number);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the customer number is set
|
// Check if the customer number is set
|
||||||
if (!isset($this->customer_number) && $customer_number === null) {
|
if (!isset($this->customer_number) && $customer_number === null) {
|
||||||
return $this;
|
return $this;
|
||||||
@@ -531,7 +542,9 @@ class users_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cachedCustomer = $this->getCached('economic_customer');
|
$cachedCustomer = isset($this->id) && $this->id > 0
|
||||||
|
? $this->getCached('economic_customer')
|
||||||
|
: null;
|
||||||
if (is_object($cachedCustomer)) {
|
if (is_object($cachedCustomer)) {
|
||||||
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
||||||
if ($cachedCustomerNumber === $customer_number) {
|
if ($cachedCustomerNumber === $customer_number) {
|
||||||
@@ -717,7 +730,7 @@ class users_o extends db
|
|||||||
$this->permissions = $perms;
|
$this->permissions = $perms;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUserAttributes(int $user_id = null): array
|
public function getUserAttributes(?int $user_id = null): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -1106,7 +1119,7 @@ class users_o extends db
|
|||||||
* Set the password for the user
|
* Set the password for the user
|
||||||
* @throws Exception If the user is not selected
|
* @throws Exception If the user is not selected
|
||||||
*/
|
*/
|
||||||
public function setPassword(string $password = null): void
|
public function setPassword(?string $password = null): void
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
@@ -9448,6 +9448,14 @@ paths:
|
|||||||
license_plate:
|
license_plate:
|
||||||
type: string
|
type: string
|
||||||
description: Required for START command
|
description: Required for START command
|
||||||
|
wash_type:
|
||||||
|
type: string
|
||||||
|
enum: [Manual, Machine]
|
||||||
|
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
|
||||||
|
wash_mode:
|
||||||
|
type: string
|
||||||
|
enum: [manual, machine]
|
||||||
|
description: Lowercase alias for wash_type accepted by backend clients.
|
||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
||||||
@@ -11657,6 +11665,23 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
||||||
|
|
||||||
|
/slack/config/test:
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Test Slack customer registration webhook
|
||||||
|
operationId: testSlackCustomerRegistrationWebhook
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack customer registration webhook test completed successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||||
|
'400':
|
||||||
|
description: Slack customer registration webhook URL is not configured
|
||||||
|
'502':
|
||||||
|
description: Slack customer registration webhook test failed
|
||||||
|
|
||||||
/backups/config:
|
/backups/config:
|
||||||
get:
|
get:
|
||||||
tags: [Config]
|
tags: [Config]
|
||||||
@@ -15182,6 +15207,14 @@ components:
|
|||||||
example: https://hooks.slack.com/services/...
|
example: https://hooks.slack.com/services/...
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
|
SlackConfigTestResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
sent: { type: boolean }
|
||||||
|
message: { type: string }
|
||||||
|
required: [configured, sent, message]
|
||||||
|
|
||||||
BackupsConfigEntry:
|
BackupsConfigEntry:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15458,6 +15491,14 @@ components:
|
|||||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
||||||
required: [data]
|
required: [data]
|
||||||
|
|
||||||
|
SlackConfigTestResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||||
|
required: [data]
|
||||||
|
|
||||||
BackupsConfigListResponse:
|
BackupsConfigListResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
@@ -18433,10 +18474,10 @@ components:
|
|||||||
path_sample_limit:
|
path_sample_limit:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
maximum: 200
|
maximum: 2048
|
||||||
default: 200
|
default: 2048
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
description: Optional cap for returned path rows. Omitted returns every projected terminal path within the state cap; larger values are capped at 2048.
|
||||||
|
|
||||||
SelfserveStudioPathOutcomesResponse:
|
SelfserveStudioPathOutcomesResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -409,15 +409,7 @@ class authRoute
|
|||||||
* Check if the cvr already exists
|
* Check if the cvr already exists
|
||||||
*/
|
*/
|
||||||
$economic = new economic();
|
$economic = new economic();
|
||||||
$economic_response = ($economic->customers->customers->search([
|
$economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr);
|
||||||
'corporateIdentificationNumber' => (string)$cvr,
|
|
||||||
], [
|
|
||||||
'skipPages' => 0,
|
|
||||||
'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000.
|
|
||||||
])->collection);
|
|
||||||
if (!is_array($economic_response)) {
|
|
||||||
$economic_response = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
||||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
||||||
@@ -448,22 +440,66 @@ class authRoute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the CVR company information used for the e-conomic customer payload.
|
||||||
|
$companyInformation = null;
|
||||||
|
try {
|
||||||
|
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
|
||||||
|
'phase' => 'cvr_lookup',
|
||||||
|
'cvr' => (string)$cvr,
|
||||||
|
'requestedCustomerNumber' => $companyPhone,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string)($companyInformation->name ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
|
||||||
|
'phase' => 'cvr_lookup',
|
||||||
|
'cvr' => (string)$cvr,
|
||||||
|
'requestedCustomerNumber' => $companyPhone,
|
||||||
|
]);
|
||||||
|
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($localUserExists) {
|
if ($localUserExists) {
|
||||||
$response->error('Company phone number already registered', 400);
|
$response->error('Company phone number already registered', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the CVR company information used for the e-conomic customer payload.
|
try {
|
||||||
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
|
$result = $economic->createCustomer(
|
||||||
$name = (string)($companyInformation->name ?? '');
|
(int)$companyPhone,
|
||||||
$result = $economic->createCustomer(
|
$name,
|
||||||
(int)$companyPhone,
|
(int)$cvr,
|
||||||
$name,
|
(string)$invoiceEmail,
|
||||||
(int)$cvr,
|
(int)$companyPhone,
|
||||||
(string)$invoiceEmail,
|
(int)$contactPhone,
|
||||||
(int)$companyPhone,
|
$companyInformation,
|
||||||
(int)$contactPhone,
|
);
|
||||||
$companyInformation,
|
} catch (Exception $exception) {
|
||||||
);
|
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
||||||
|
$economic,
|
||||||
|
(string)$cvr,
|
||||||
|
$companyPhone,
|
||||||
|
(string)$invoiceEmail
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($recoveredCustomer !== null) {
|
||||||
|
$response->success($recoveredCustomer, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [
|
||||||
|
'phase' => 'create',
|
||||||
|
'cvr' => (string)$cvr,
|
||||||
|
'requestedCustomerNumber' => $companyPhone,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
$response->error('Failed to create customer in e-conomic.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
||||||
@@ -495,7 +531,7 @@ class authRoute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bootstrapLocalCustomerOrFail($companyPhone);
|
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
||||||
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||||
$response->success($result, 201);
|
$response->success($result, 201);
|
||||||
});
|
});
|
||||||
@@ -534,7 +570,8 @@ class authRoute
|
|||||||
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
||||||
|
|
||||||
$subject = 'Adgangskode nulstilling';
|
$subject = 'Adgangskode nulstilling';
|
||||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
||||||
|
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
||||||
@@ -758,6 +795,49 @@ class authRoute
|
|||||||
return count($rows) > 0;
|
return count($rows) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array
|
||||||
|
{
|
||||||
|
$economic_response = ($economic->customers->customers->search([
|
||||||
|
'corporateIdentificationNumber' => $cvr,
|
||||||
|
], [
|
||||||
|
'skipPages' => 0,
|
||||||
|
'pageSize' => 1,
|
||||||
|
])->collection);
|
||||||
|
|
||||||
|
return is_array($economic_response) ? $economic_response : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recoverRegistrationAfterCreateFailure(
|
||||||
|
economic $economic,
|
||||||
|
string $cvr,
|
||||||
|
int $customerNumber,
|
||||||
|
string $invoiceEmail
|
||||||
|
): ?object {
|
||||||
|
// The upstream POST can commit before the client receives a validation/transport error.
|
||||||
|
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
||||||
|
try {
|
||||||
|
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
||||||
|
} catch (Exception $searchException) {
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [
|
||||||
|
'phase' => 'create_recovery',
|
||||||
|
'cvr' => $cvr,
|
||||||
|
'requestedCustomerNumber' => $customerNumber,
|
||||||
|
'message' => $searchException->getMessage(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber);
|
||||||
|
if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
||||||
|
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
||||||
|
|
||||||
|
return $matchingEconomicCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||||
{
|
{
|
||||||
foreach ($customers as $customer) {
|
foreach ($customers as $customer) {
|
||||||
@@ -785,19 +865,46 @@ class authRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private function bootstrapLocalCustomerOrFail(int $customerNumber): users_o
|
private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
|
$customer = new users_o();
|
||||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
try {
|
||||||
return $customer;
|
$customer = $customer->getUserByCustomerNumber($customerNumber);
|
||||||
|
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||||
|
return $customer;
|
||||||
|
}
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [
|
||||||
|
'customerNumber' => $customerNumber,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$economicCustomer !== null
|
||||||
|
&& $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber
|
||||||
|
&& method_exists($customer, 'importCustomerFromEconomicCustomerData')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
$importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer);
|
||||||
|
if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) {
|
||||||
|
return $importedCustomer;
|
||||||
|
}
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [
|
||||||
|
'customerNumber' => $customerNumber,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
||||||
'customerNumber' => $customerNumber,
|
'customerNumber' => $customerNumber,
|
||||||
]);
|
]);
|
||||||
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
||||||
|
throw new Exception('Customer was created in e-conomic but could not be imported locally.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -221,6 +221,35 @@ class moduleConfigRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Slack config > TEST */
|
||||||
|
$this->post('/slack/config/test', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('slack_config');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new slack())->test_customer_registration_webhook();
|
||||||
|
if (($result['configured'] ?? false) !== true) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration webhook URL is not configured');
|
||||||
|
$response->error($result['message'] ?? 'Slack customer registration webhook URL is not configured.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($result['sent'] ?? false) !== true) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration test webhook failed');
|
||||||
|
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG_TEST', 'Successfully tested Slack customer registration webhook');
|
||||||
|
$response->success($result);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'slack_config' => 'Test Slack config'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->get('/backups/config', function () {
|
$this->get('/backups/config', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('backups_config');
|
$this->requirePermission('backups_config');
|
||||||
|
|||||||
@@ -2,21 +2,35 @@
|
|||||||
|
|
||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
|
||||||
use classes\image_processor;
|
|
||||||
use classes\licenseplaterecognizer;
|
use classes\licenseplaterecognizer;
|
||||||
use classes\openai;
|
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\router;
|
|
||||||
use classes\upload_store;
|
|
||||||
use Exception;
|
|
||||||
use objects\logs_o;
|
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class moduleScannerRoute
|
class moduleScannerRoute
|
||||||
{
|
{
|
||||||
use route_t;
|
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
|
public function run(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */
|
global /** @var response $response */
|
||||||
@@ -24,27 +38,43 @@ class moduleScannerRoute
|
|||||||
/** Modules > Scanner > License Plate Recognition > POST */
|
/** Modules > Scanner > License Plate Recognition > POST */
|
||||||
$this->post('/modules/scanner/lpr', function () {
|
$this->post('/modules/scanner/lpr', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requireParameters(['base64_image']);
|
$route_started_at = microtime(true);
|
||||||
$base64_image = self::getParameter('base64_image');
|
$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');
|
//self::requirePermission('modules_scanner_lpr');
|
||||||
if (empty($base64_image)) {
|
if ($image_upload === null && $raw_image_upload === null) {
|
||||||
$response->error('Base64 image is required.');}
|
$base64_image = self::getParameter('base64_image');
|
||||||
$uploads = new upload_store();
|
if (!is_string($base64_image) || trim($base64_image) === '') {
|
||||||
$object_name = $uploads->storeTempImageFromBase64(
|
$response->error('Image is required.');
|
||||||
$base64_image
|
}
|
||||||
);
|
}
|
||||||
//echo $object_name;
|
$recognizer = new licenseplaterecognizer(false);
|
||||||
//echo "License Plate Recognition result:\n";
|
try {
|
||||||
// Uncomment the line below to use the actual license plate recognizer.
|
if ($image_upload !== null) {
|
||||||
$lpr_result = (new licenseplaterecognizer())->licenseplaterecognizer($base64_image);
|
$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']) {
|
if ($lpr_result['success']) {
|
||||||
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
||||||
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
||||||
// Make sure the scanned plate is more than 3 characters long.
|
// Make sure the scanned plate is more than 3 characters long.
|
||||||
$scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3;
|
$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) {
|
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
|
// Success
|
||||||
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
||||||
@@ -54,25 +84,296 @@ class moduleScannerRoute
|
|||||||
'reason' => 'no_license_plate_detected',
|
'reason' => 'no_license_plate_detected',
|
||||||
], 200);
|
], 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',
|
'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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1670,9 +1670,13 @@ class moduleSelfServeRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($allow_customer_self_serve) {
|
if ($allow_customer_self_serve) {
|
||||||
$customer_allowed = $requires_active_wash
|
if ($requires_active_wash && $allow_department_active_wash) {
|
||||||
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
$customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
||||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
} else {
|
||||||
|
$customer_allowed = $requires_active_wash
|
||||||
|
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false)
|
||||||
|
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
|
}
|
||||||
|
|
||||||
if ($customer_allowed) {
|
if ($customer_allowed) {
|
||||||
return;
|
return;
|
||||||
@@ -1827,7 +1831,17 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
|
if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$department_id = $this->departmentIdForLane($lane);
|
||||||
|
return $department_id > 0
|
||||||
|
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function canCustomerUseActiveOperationalSelfServeLane(
|
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Tests\Support\Api\ApiServer;
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
it('rejects scanner LPR requests without an image before contacting Plate Recognizer', function (): void {
|
||||||
|
api_test_covers('POST /modules/scanner/lpr', 'failure');
|
||||||
|
|
||||||
|
$response = api_client()->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'
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$path = parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
|
||||||
|
if ($path === '/health') {
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
echo 'ok';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path !== '/v1/plate-reader/') {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => '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;
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ it('allows the customer self-serve start sequence without department access', fu
|
|||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => $reg,
|
'license_plate' => $reg,
|
||||||
|
'wash_type' => 'Manual',
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], $headers)
|
], $headers)
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
@@ -131,6 +132,7 @@ it('marks the active customer session relay-enabled after machine relay enable',
|
|||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => $reg,
|
'license_plate' => $reg,
|
||||||
|
'wash_type' => 'Machine',
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], $headers)
|
], $headers)
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ it('allows customer self-serve permission to execute START without department ac
|
|||||||
'lane_id' => (int)$scenario['lane']['id'],
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => (string)$scenario['vehicle']['reg'],
|
'license_plate' => (string)$scenario['vehicle']['reg'],
|
||||||
|
'wash_type' => 'Manual',
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], api_fixtures()->bearerHeaders($token));
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ it('still allows elevated operators with department access to execute lane comma
|
|||||||
'lane_id' => (int)$scenario['lane']['id'],
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
|
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
|
||||||
|
'wash_type' => 'Manual',
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ return [
|
|||||||
'GET /ping',
|
'GET /ping',
|
||||||
'PUT /order',
|
'PUT /order',
|
||||||
'GET /orders/reference-suggestions',
|
'GET /orders/reference-suggestions',
|
||||||
|
'POST /modules/scanner/lpr',
|
||||||
],
|
],
|
||||||
'happy_only_operations' => [
|
'happy_only_operations' => [
|
||||||
'GET /ping',
|
'GET /ping',
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ final class ApiClient
|
|||||||
return $this->request('POST', $path, $payload, $headers);
|
return $this->request('POST', $path, $payload, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar|null> $fields
|
||||||
|
* @param array<string, string|array{path:string,mime?:string,name?:string}> $files
|
||||||
|
* @param array<string, string> $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
|
public function put(string $path, ?array $payload = null, array $headers = []): ApiResponse
|
||||||
{
|
{
|
||||||
return $this->request('PUT', $path, $payload, $headers);
|
return $this->request('PUT', $path, $payload, $headers);
|
||||||
@@ -102,4 +124,61 @@ final class ApiClient
|
|||||||
|
|
||||||
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string|\CURLFile> $postFields
|
||||||
|
* @param array<string, string> $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ final class ApiTestRuntime
|
|||||||
return new ApiClient($this->baseUrl);
|
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
|
public function fixtures(): ApiFixtures
|
||||||
{
|
{
|
||||||
if ($this->fixtures === null) {
|
if ($this->fixtures === null) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
|
['path' => 'tests/auth/NewCustomerEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use objects\customer_password_reset_keys_o;
|
||||||
|
|
||||||
|
app_require('objects/customer_password_reset_keys_o.php');
|
||||||
|
|
||||||
|
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
|
||||||
|
class PasswordResetTokenExpiryFakeResult
|
||||||
|
{
|
||||||
|
public int $num_rows;
|
||||||
|
|
||||||
|
public function __construct(private readonly array $rows)
|
||||||
|
{
|
||||||
|
$this->num_rows = count($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch_assoc(): ?array
|
||||||
|
{
|
||||||
|
return $this->rows[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('PasswordResetTokenExpiryFakeDb')) {
|
||||||
|
class PasswordResetTokenExpiryFakeDb
|
||||||
|
{
|
||||||
|
public array $queries = [];
|
||||||
|
|
||||||
|
public function __construct(private readonly array $results)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function escape_string(string $string): string
|
||||||
|
{
|
||||||
|
return addslashes($string);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $sql): PasswordResetTokenExpiryFakeResult
|
||||||
|
{
|
||||||
|
$this->queries[] = $sql;
|
||||||
|
|
||||||
|
return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
||||||
|
class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o
|
||||||
|
{
|
||||||
|
public function getObjectProperties(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceSelectedId(int $id): void
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
if ($this->previousDb !== null) {
|
||||||
|
$GLOBALS['db'] = $this->previousDb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($GLOBALS['db']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps password reset tokens valid for 72 hours', function (): void {
|
||||||
|
expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up reset tokens using the database 72 hour validity window', function (): void {
|
||||||
|
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||||
|
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH);
|
||||||
|
$probe = new PasswordResetTokenExpiryProbe();
|
||||||
|
|
||||||
|
$found = $probe->findValidByToken($token);
|
||||||
|
|
||||||
|
expect($found)->toBe($probe)
|
||||||
|
->and($probe->id)->toBe(42)
|
||||||
|
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)')
|
||||||
|
->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same database 72 hour window for the selected token guard', function (): void {
|
||||||
|
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||||
|
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$probe = new PasswordResetTokenExpiryProbe();
|
||||||
|
$probe->forceSelectedId(42);
|
||||||
|
|
||||||
|
expect($probe->isValidToken())->toBeTrue()
|
||||||
|
->and($GLOBALS['db']->queries[0])->toContain('id = 42')
|
||||||
|
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)');
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('renders the new customer welcome template without unselected user access or leaked output', function (): void {
|
||||||
|
$result = run_legacy_script('tests/auth/NewCustomerEmailTemplateTest.php');
|
||||||
|
|
||||||
|
expect($result['exitCode'])->toBe(0, $result['output']);
|
||||||
|
});
|
||||||
@@ -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-Methods'])->toContain('PATCH');
|
||||||
expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace');
|
expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace');
|
||||||
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
|
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['Access-Control-Max-Age'])->toBe('86400');
|
||||||
|
expect($headers['Timing-Allow-Origin'])->toBe('http://localhost:5173');
|
||||||
expect($headers['Vary'])->toBe('Origin');
|
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['status'])->toBe(200);
|
||||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
||||||
expect($preflight['headers']['Content-Type'])->toBe('application/json');
|
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('');
|
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-Origin'])->toBe('https://partner.example.test');
|
||||||
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
||||||
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
||||||
|
expect($headers['Timing-Allow-Origin'])->toBe('https://partner.example.test');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function phpFpmWorkerConfigRepoRoot(): string
|
||||||
|
{
|
||||||
|
$configuredRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
|
||||||
|
if (is_string($configuredRoot) && $configuredRoot !== '') {
|
||||||
|
return rtrim($configuredRoot, DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appRoot = defined('WD') ? WD : dirname(__DIR__, 3);
|
||||||
|
|
||||||
|
return dirname($appRoot, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phpFpmWorkerConfigRepoPath(string $relative): string
|
||||||
|
{
|
||||||
|
return phpFpmWorkerConfigRepoRoot() . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phpFpmWorkerConfigValue(string $poolConfig, string $key): ?int
|
||||||
|
{
|
||||||
|
if (!preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\d+)\s*$/m', $poolConfig, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('configures PHP-FPM with multiple warm request workers', function (): void {
|
||||||
|
$poolPath = phpFpmWorkerConfigRepoPath('services/php/php-fpm-pool.conf');
|
||||||
|
expect(is_file($poolPath))->toBeTrue();
|
||||||
|
|
||||||
|
$poolConfig = (string)file_get_contents($poolPath);
|
||||||
|
|
||||||
|
expect($poolConfig)->toContain('[www]')
|
||||||
|
->and($poolConfig)->toContain('pm = dynamic')
|
||||||
|
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_children'))->toBeGreaterThanOrEqual(8)
|
||||||
|
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.start_servers'))->toBeGreaterThanOrEqual(4)
|
||||||
|
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.min_spare_servers'))->toBeGreaterThanOrEqual(4)
|
||||||
|
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_spare_servers'))->toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the worker pool config into every API PHP image', function (): void {
|
||||||
|
$copyInstruction = 'COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf';
|
||||||
|
$dockerfiles = [
|
||||||
|
phpFpmWorkerConfigRepoPath('Dockerfile'),
|
||||||
|
phpFpmWorkerConfigRepoPath('Dockerfile.coolify-api'),
|
||||||
|
phpFpmWorkerConfigRepoPath('services/php/Dockerfile'),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dockerfiles as $dockerfile) {
|
||||||
|
expect(is_file($dockerfile))->toBeTrue();
|
||||||
|
expect((string)file_get_contents($dockerfile))->toContain($copyInstruction);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
<?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:');
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use routes\moduleScannerRoute;
|
||||||
|
|
||||||
|
app_require('routes/moduleScannerRoute.php');
|
||||||
|
|
||||||
it('returns no-plate LPR results without a failed HTTP status', function (): void {
|
it('returns no-plate LPR results without a failed HTTP status', function (): void {
|
||||||
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
||||||
|
|
||||||
@@ -8,3 +12,124 @@ it('returns no-plate LPR results without a failed HTTP status', function (): voi
|
|||||||
expect($route)->toContain('], 200);');
|
expect($route)->toContain('], 200);');
|
||||||
expect($route)->not->toContain("throw new Exception('License plate extraction failed.')");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,20 +53,23 @@ class SelfserveLaneStartEntranceTimeoutHarness
|
|||||||
$this->relayEvents[] = 'cleaner:on';
|
$this->relayEvents[] = 'cleaner:on';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(): void
|
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
|
unset($arguments);
|
||||||
$this->programPickerRelayCalls++;
|
$this->programPickerRelayCalls++;
|
||||||
$this->relayEvents[] = 'program_picker:selected_service';
|
$this->relayEvents[] = 'program_picker:selected_service';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusForWashStart(): void
|
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
|
unset($arguments);
|
||||||
$this->programPickerRelayCalls++;
|
$this->programPickerRelayCalls++;
|
||||||
$this->relayEvents[] = 'program_picker:eligibility_sync';
|
$this->relayEvents[] = 'program_picker:eligibility_sync';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setMachineRelayStatusForWashStart(): void
|
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||||
{
|
{
|
||||||
|
unset($arguments);
|
||||||
$this->machineRelayCalls++;
|
$this->machineRelayCalls++;
|
||||||
$this->relayEvents[] = 'machine:sync';
|
$this->relayEvents[] = 'machine:sync';
|
||||||
}
|
}
|
||||||
@@ -107,14 +110,30 @@ it('parses deferred relay side effects on start command arguments', function ():
|
|||||||
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
||||||
'license_plate' => 'ab12345',
|
'license_plate' => 'ab12345',
|
||||||
'customer_number' => 12345679,
|
'customer_number' => 12345679,
|
||||||
|
'wash_type' => 'Manual',
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($arguments->license_plate)->toBe('AB12345');
|
expect($arguments->license_plate)->toBe('AB12345');
|
||||||
expect($arguments->customer_number)->toBe(12345679);
|
expect($arguments->customer_number)->toBe(12345679);
|
||||||
|
expect($arguments->wash_mode)->toBe('manual');
|
||||||
expect($arguments->defer_relay_side_effects)->toBeTrue();
|
expect($arguments->defer_relay_side_effects)->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses wash mode aliases on start command arguments', function (): void {
|
||||||
|
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
||||||
|
'wash_mode' => 'machine',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($arguments->wash_mode)->toBe('machine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid wash types on start command arguments', function (): void {
|
||||||
|
expect(fn() => (new selfserve_lane_command_arguments())->setParameters([
|
||||||
|
'wash_type' => 'automatic',
|
||||||
|
]))->toThrow(\InvalidArgumentException::class, 'Invalid wash type: automatic');
|
||||||
|
});
|
||||||
|
|
||||||
it('only syncs program picker from selected service when start asks to defer machine side effects', function (): void {
|
it('only syncs program picker from selected service when start asks to defer machine side effects', function (): void {
|
||||||
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ it('documents property gate lane commands and sanitized gate failure responses',
|
|||||||
|
|
||||||
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
|
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
|
||||||
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
|
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
|
||||||
|
expect($commandPathBlock)->toContain('wash_type:');
|
||||||
|
expect($commandPathBlock)->toContain('wash_mode:');
|
||||||
expect($commandPathBlock)->toContain('Command execution failed');
|
expect($commandPathBlock)->toContain('Command execution failed');
|
||||||
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
|
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
|
||||||
|
|
||||||
|
|||||||
+63
-6
@@ -41,6 +41,8 @@ class SelfserveProgramPickerSelectionHarness
|
|||||||
public object $department_lane;
|
public object $department_lane;
|
||||||
public int $licensePlateReads = 0;
|
public int $licensePlateReads = 0;
|
||||||
public int $customerNumberReads = 0;
|
public int $customerNumberReads = 0;
|
||||||
|
public int $availabilityChecks = 0;
|
||||||
|
public bool $machineAvailable = true;
|
||||||
/** @var array<int,bool> */
|
/** @var array<int,bool> */
|
||||||
public array $programPickerWrites = [];
|
public array $programPickerWrites = [];
|
||||||
/** @var array<string,mixed> */
|
/** @var array<string,mixed> */
|
||||||
@@ -79,29 +81,84 @@ class SelfserveProgramPickerSelectionHarness
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function runDeferredStartRelaySideEffects(): void
|
public function runDeferredStartRelaySideEffects(?string $washType = null): void
|
||||||
{
|
{
|
||||||
$arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects(true);
|
$arguments = (new selfserve_lane_command_arguments())
|
||||||
|
->setDeferRelaySideEffects(true)
|
||||||
|
->setWashMode($washType);
|
||||||
$this->runRelaySideEffectsForWashStart($arguments);
|
$this->runRelaySideEffectsForWashStart($arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function runNormalStartProgramPickerRelay(?string $washType = null): void
|
||||||
|
{
|
||||||
|
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
|
||||||
|
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveStartWashMode(?string $washType = null): string
|
||||||
|
{
|
||||||
|
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
|
||||||
|
return $this->resolveSelfServeActionWashModeForStart($arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
|
{
|
||||||
|
unset($arguments);
|
||||||
|
$this->availabilityChecks++;
|
||||||
|
return $this->machineAvailable;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('turns off the program picker on deferred start when the frontend selected manual wash', function (): void {
|
it('turns off the program picker on deferred start when the frontend selected manual wash', function (): void {
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
$lane = new SelfserveProgramPickerSelectionHarness();
|
||||||
$lane->setSelectedServices([]);
|
$lane->setSelectedServices([]);
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects();
|
$lane->runDeferredStartRelaySideEffects('Manual');
|
||||||
|
|
||||||
expect($lane->programPickerWrites)->toBe([false]);
|
expect($lane->programPickerWrites)->toBe([false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not let backend machine eligibility override a frontend manual wash selection', function (): void {
|
it('does not let backend machine eligibility override a frontend manual wash selection', function (): void {
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
$lane = new SelfserveProgramPickerSelectionHarness();
|
||||||
$lane->setSelectedServices([]);
|
$lane->setSelectedServices(['MACHINE']);
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects();
|
$lane->runDeferredStartRelaySideEffects('Manual');
|
||||||
|
|
||||||
expect($lane->licensePlateReads)->toBe(0)
|
expect($lane->licensePlateReads)->toBe(0)
|
||||||
->and($lane->customerNumberReads)->toBe(0)
|
->and($lane->customerNumberReads)->toBe(0)
|
||||||
->and($lane->programPickerWrites)->toBe([false]);
|
->and($lane->programPickerWrites)->toBe([false])
|
||||||
|
->and($lane->resolveStartWashMode('Manual'))->toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not infer program picker enablement from machine service without a customer machine selection', function (): void {
|
||||||
|
$lane = new SelfserveProgramPickerSelectionHarness();
|
||||||
|
$lane->setSelectedServices(['MACHINE']);
|
||||||
|
|
||||||
|
$lane->runDeferredStartRelaySideEffects();
|
||||||
|
$lane->runNormalStartProgramPickerRelay();
|
||||||
|
|
||||||
|
expect($lane->programPickerWrites)->toBe([false, false])
|
||||||
|
->and($lane->availabilityChecks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps normal start program picker off when the customer selected manual wash', function (): void {
|
||||||
|
$lane = new SelfserveProgramPickerSelectionHarness();
|
||||||
|
$lane->setSelectedServices(['MACHINE']);
|
||||||
|
|
||||||
|
$lane->runNormalStartProgramPickerRelay('Manual');
|
||||||
|
|
||||||
|
expect($lane->programPickerWrites)->toBe([false])
|
||||||
|
->and($lane->availabilityChecks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a frontend machine wash selection when machine service is selected', function (): void {
|
||||||
|
$lane = new SelfserveProgramPickerSelectionHarness();
|
||||||
|
$lane->setSelectedServices(['MACHINE']);
|
||||||
|
|
||||||
|
$lane->runDeferredStartRelaySideEffects('Machine');
|
||||||
|
$lane->runNormalStartProgramPickerRelay('Machine');
|
||||||
|
|
||||||
|
expect($lane->programPickerWrites)->toBe([true, true])
|
||||||
|
->and($lane->availabilityChecks)->toBe(1)
|
||||||
|
->and($lane->resolveStartWashMode('Machine'))->toBe('machine');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ it('wires self-serve property gate command permissions', function (): void {
|
|||||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||||
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
|
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('$this->canCustomerUsePropertyGateForLane($lane, $customer_number)');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('$this->isOwnCustomerContext($customer_number)');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('$this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number)');
|
||||||
|
|
||||||
expect($commandTrait)->not->toBeFalse();
|
expect($commandTrait)->not->toBeFalse();
|
||||||
expect($commandTrait)->toContain('Failed to open property access gate.');
|
expect($commandTrait)->toContain('Failed to open property access gate.');
|
||||||
|
|||||||
@@ -26,6 +26,54 @@ function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_har
|
|||||||
return $reflection->newInstanceWithoutConstructor();
|
return $reflection->newInstanceWithoutConstructor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selfserve_question_tree_simulator(int $questionCount): callable
|
||||||
|
{
|
||||||
|
return function (array $overrides) use ($questionCount): array {
|
||||||
|
$answers = [];
|
||||||
|
foreach ($overrides as $entry) {
|
||||||
|
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$questions = [];
|
||||||
|
foreach (range(1, $questionCount) as $questionId) {
|
||||||
|
$questions[] = [
|
||||||
|
'id' => $questionId,
|
||||||
|
'node_id' => 'question:' . $questionId,
|
||||||
|
'label' => 'Question ' . $questionId,
|
||||||
|
'visible' => true,
|
||||||
|
'answer' => $answers[$questionId] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$complete = count($answers) === $questionCount;
|
||||||
|
$allowed = $complete && !in_array(false, $answers, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allowed' => $allowed,
|
||||||
|
'questions' => [],
|
||||||
|
'tasks' => $allowed ? [
|
||||||
|
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||||
|
] : [],
|
||||||
|
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||||
|
'debug' => [
|
||||||
|
'questions' => $questions,
|
||||||
|
'tasks' => [
|
||||||
|
[
|
||||||
|
'id' => 41,
|
||||||
|
'node_id' => 'task:41',
|
||||||
|
'label' => 'Start machine',
|
||||||
|
'active' => $allowed,
|
||||||
|
'services' => ['MACHINE'],
|
||||||
|
'buttons' => ['start'],
|
||||||
|
'order_priority' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'signal_timeline' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
@@ -1321,52 +1369,20 @@ it('truncates path outcome projection when the state cap is reached', function (
|
|||||||
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies default caps for wide question trees and reports progress', function (): void {
|
it('returns more than 200 projected path cases by default', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
$simulate = function (array $overrides): array {
|
$projection = $service->projectPathOutcomesFromSimulator(selfserve_question_tree_simulator(8));
|
||||||
$answers = [];
|
|
||||||
foreach ($overrides as $entry) {
|
|
||||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$questions = [];
|
expect($projection['truncated'])->toBeFalse()
|
||||||
foreach (range(1, 12) as $questionId) {
|
->and($projection['summary']['state_count'])->toBe(511)
|
||||||
$questions[] = [
|
->and($projection['summary']['terminal_path_count'])->toBe(256)
|
||||||
'id' => $questionId,
|
->and($projection['summary']['path_sample_count'])->toBe(256)
|
||||||
'node_id' => 'question:' . $questionId,
|
->and($projection['paths'])->toHaveCount(256);
|
||||||
'label' => 'Question ' . $questionId,
|
});
|
||||||
'visible' => true,
|
|
||||||
'answer' => $answers[$questionId] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$complete = count($answers) === 12;
|
it('applies the default state cap for wide question trees and reports progress', function (): void {
|
||||||
$allowed = $complete && !in_array(false, $answers, true);
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
$simulate = selfserve_question_tree_simulator(12);
|
||||||
return [
|
|
||||||
'allowed' => $allowed,
|
|
||||||
'questions' => [],
|
|
||||||
'tasks' => $allowed ? [
|
|
||||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
|
||||||
] : [],
|
|
||||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
|
||||||
'debug' => [
|
|
||||||
'questions' => $questions,
|
|
||||||
'tasks' => [
|
|
||||||
[
|
|
||||||
'id' => 41,
|
|
||||||
'node_id' => 'task:41',
|
|
||||||
'label' => 'Start machine',
|
|
||||||
'active' => $allowed,
|
|
||||||
'services' => ['MACHINE'],
|
|
||||||
'buttons' => ['start'],
|
|
||||||
'order_priority' => 1,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'signal_timeline' => [],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
$progressEvents = [];
|
$progressEvents = [];
|
||||||
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
||||||
@@ -1385,10 +1401,10 @@ it('applies default caps for wide question trees and reports progress', function
|
|||||||
->and($projection['summary']['question_count'])->toBe(12)
|
->and($projection['summary']['question_count'])->toBe(12)
|
||||||
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
||||||
->and($projection['summary']['outcome_count'])->toBe(2)
|
->and($projection['summary']['outcome_count'])->toBe(2)
|
||||||
->and($projection['summary']['path_sample_count'])->toBe(200)
|
->and($projection['summary']['path_sample_count'])->toBe(1023)
|
||||||
->and($projection['progress']['complete'])->toBeFalse()
|
->and($projection['progress']['complete'])->toBeFalse()
|
||||||
->and($projection['progress']['percent'])->toBe(99)
|
->and($projection['progress']['percent'])->toBe(99)
|
||||||
->and($projection['paths'])->toHaveCount(200)
|
->and($projection['paths'])->toHaveCount(1023)
|
||||||
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
||||||
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
||||||
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
|
|||||||
|
|
||||||
expect($washFlow)->not->toBeFalse();
|
expect($washFlow)->not->toBeFalse();
|
||||||
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
||||||
|
expect($washFlow)->toContain('bool $disableRelays = true');
|
||||||
|
expect($washFlow)->toContain('if ($disableRelays) {');
|
||||||
|
|
||||||
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
||||||
expect($methodOffset)->not->toBeFalse();
|
expect($methodOffset)->not->toBeFalse();
|
||||||
@@ -108,3 +110,22 @@ it('always dispatches completion relay off for configured machine relays without
|
|||||||
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normal STOP completion skips duplicate completion relay cleanup after STOP already disabled relays', function (): void {
|
||||||
|
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
|
||||||
|
|
||||||
|
expect($commandTrait)->not->toBeFalse();
|
||||||
|
$methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(): void');
|
||||||
|
expect($methodOffset)->not->toBeFalse();
|
||||||
|
|
||||||
|
$methodBody = substr($commandTrait, (int)$methodOffset, 1500);
|
||||||
|
expect($methodBody)->toContain(<<<'PHP'
|
||||||
|
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
||||||
|
$this->id,
|
||||||
|
$this->getLicensePlate() ?: null,
|
||||||
|
$this->getCustomerNumber() ?: null,
|
||||||
|
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
PHP);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ it('registers Slack module config endpoints and customer registration webhook co
|
|||||||
|
|
||||||
expect($routeContent)->not->toBeFalse()
|
expect($routeContent)->not->toBeFalse()
|
||||||
->and($routeContent)->toContain('/slack/config')
|
->and($routeContent)->toContain('/slack/config')
|
||||||
|
->and($routeContent)->toContain('/slack/config/test')
|
||||||
->and($routeContent)->toContain("requirePermission('slack_config')")
|
->and($routeContent)->toContain("requirePermission('slack_config')")
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()");
|
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()")
|
||||||
|
->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()");
|
||||||
|
|
||||||
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
||||||
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
||||||
@@ -24,11 +26,15 @@ it('registers Slack module config endpoints and customer registration webhook co
|
|||||||
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
||||||
->and($slackClassContent)->not->toBeFalse()
|
->and($slackClassContent)->not->toBeFalse()
|
||||||
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
||||||
|
->and($slackClassContent)->toContain('test_customer_registration_webhook')
|
||||||
|
->and($slackClassContent)->toContain('format_customer_registration_test')
|
||||||
->and($slackClassContent)->toContain('format_customer_registration')
|
->and($slackClassContent)->toContain('format_customer_registration')
|
||||||
->and($authRouteContent)->not->toBeFalse()
|
->and($authRouteContent)->not->toBeFalse()
|
||||||
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
||||||
->and($openApiContent)->not->toBeFalse()
|
->and($openApiContent)->not->toBeFalse()
|
||||||
->and($openApiContent)->toContain('/slack/config')
|
->and($openApiContent)->toContain('/slack/config')
|
||||||
|
->and($openApiContent)->toContain('/slack/config/test')
|
||||||
->and($openApiContent)->toContain('SlackConfigListResponse')
|
->and($openApiContent)->toContain('SlackConfigListResponse')
|
||||||
|
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
||||||
->and($openApiContent)->toContain('SlackConfigEntry');
|
->and($openApiContent)->toContain('SlackConfigEntry');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('classes/slack.php');
|
||||||
|
|
||||||
|
use classes\slack;
|
||||||
|
|
||||||
|
final class SlackCustomerRegistrationWebhookFake extends slack
|
||||||
|
{
|
||||||
|
public array $messages = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $webhook,
|
||||||
|
private readonly string $sendResult = 'Message sent successfully. Response: ok'
|
||||||
|
) {
|
||||||
|
// Skip parent config loading for unit isolation.
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function get_customer_registration_webhook_url(): string
|
||||||
|
{
|
||||||
|
return $this->webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send_webhook_message(string $message, string $webhook): string
|
||||||
|
{
|
||||||
|
$this->messages[] = [
|
||||||
|
'message' => $message,
|
||||||
|
'webhook' => $webhook,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not send customer registration test notifications without a saved webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake('');
|
||||||
|
|
||||||
|
$result = $slack->test_customer_registration_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => false,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toBe([])
|
||||||
|
->and($slack->get_log())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends customer registration test notifications to the saved webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake('https://hooks.slack.test/services/secret-token');
|
||||||
|
|
||||||
|
$result = $slack->test_customer_registration_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => true,
|
||||||
|
'message' => 'Slack test message sent successfully.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toHaveCount(1)
|
||||||
|
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/secret-token')
|
||||||
|
->and($slack->messages[0]['message'])->toContain('Truck Wash Slack test')
|
||||||
|
->and($slack->messages[0]['message'])->toContain('Customer registration notifications are configured correctly.')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports customer registration test notification failures without exposing the webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake(
|
||||||
|
'https://hooks.slack.test/services/secret-token',
|
||||||
|
'Failed to send message: cURL error for https://hooks.slack.test/services/secret-token'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $slack->test_customer_registration_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack test message failed.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toHaveCount(1)
|
||||||
|
->and(json_encode($result, JSON_UNESCAPED_SLASHES))->not->toContain('secret-token')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
namespace {
|
||||||
|
if (!defined('WD')) {
|
||||||
|
define('WD', dirname(__DIR__, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace objects {
|
||||||
|
class users_o
|
||||||
|
{
|
||||||
|
public static array $calls = [];
|
||||||
|
private bool $selected = false;
|
||||||
|
public object $economic_customer;
|
||||||
|
|
||||||
|
public function getUserByCustomerNumber(int $customer_number): self
|
||||||
|
{
|
||||||
|
self::$calls[] = 'select:' . $customer_number;
|
||||||
|
$this->selected = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomerName(int $customer_number): ?string
|
||||||
|
{
|
||||||
|
if (!$this->selected) {
|
||||||
|
throw new \RuntimeException('Customer name requested before local customer selection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$calls[] = 'name:' . $customer_number;
|
||||||
|
|
||||||
|
return 'KING FOOD DANMARK A/S';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomerEcocomicData(?int $customer_number = null): self
|
||||||
|
{
|
||||||
|
if (!$this->selected) {
|
||||||
|
throw new \RuntimeException('Economic customer requested before local customer selection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$calls[] = 'economic:' . (int)$customer_number;
|
||||||
|
$this->economic_customer = (object)[
|
||||||
|
'corporateIdentificationNumber' => '12345678',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
require_once WD . '/modules/email/helpers/email_template.php';
|
||||||
|
require_once WD . '/modules/email/templates/email_template_new_customer.php';
|
||||||
|
|
||||||
|
function assert_true(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) {
|
||||||
|
throw new \RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup_buffers_to(int $base_level): string
|
||||||
|
{
|
||||||
|
$output = '';
|
||||||
|
while (ob_get_level() > $base_level) {
|
||||||
|
$output .= (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base_level = ob_get_level();
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$html = (new \email\templates\email_template_new_customer(
|
||||||
|
12345678,
|
||||||
|
'https://truckwash.io/auth/password-reset/mock-token',
|
||||||
|
))->generate_html();
|
||||||
|
$leaked_output = cleanup_buffers_to($base_level);
|
||||||
|
|
||||||
|
assert_true($leaked_output === '', 'Template generation must not leak buffered HTML output.');
|
||||||
|
assert_true(
|
||||||
|
str_contains($html, 'Tak for din registrering af KING FOOD DANMARK A/S (12345678) som kunde hos Truck Wash.'),
|
||||||
|
'Template must render the selected customer name and CVR in the welcome intro.'
|
||||||
|
);
|
||||||
|
assert_true(
|
||||||
|
\objects\users_o::$calls === ['select:12345678', 'name:12345678', 'economic:12345678'],
|
||||||
|
'Template must select the local customer before reading customer details.'
|
||||||
|
);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$leaked_output = cleanup_buffers_to($base_level);
|
||||||
|
fwrite(STDERR, $leaked_output);
|
||||||
|
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\033[32m[PASS]\033[0m New customer email template renders without leaked output.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ namespace classes {
|
|||||||
{
|
{
|
||||||
public static array $mock_collection = [];
|
public static array $mock_collection = [];
|
||||||
public static ?object $mock_create_response = null;
|
public static ?object $mock_create_response = null;
|
||||||
|
public static ?\RuntimeException $mock_create_exception = null;
|
||||||
|
public static array $mock_collection_after_create_exception = [];
|
||||||
public static array $search_calls = [];
|
public static array $search_calls = [];
|
||||||
public static array $create_calls = [];
|
public static array $create_calls = [];
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ namespace classes {
|
|||||||
{
|
{
|
||||||
self::$mock_collection = [];
|
self::$mock_collection = [];
|
||||||
self::$mock_create_response = null;
|
self::$mock_create_response = null;
|
||||||
|
self::$mock_create_exception = null;
|
||||||
|
self::$mock_collection_after_create_exception = [];
|
||||||
self::$search_calls = [];
|
self::$search_calls = [];
|
||||||
self::$create_calls = [];
|
self::$create_calls = [];
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,11 @@ namespace classes {
|
|||||||
'company_information' => $companyInformation,
|
'company_information' => $companyInformation,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (self::$mock_create_exception !== null) {
|
||||||
|
self::$mock_collection = self::$mock_collection_after_create_exception;
|
||||||
|
throw self::$mock_create_exception;
|
||||||
|
}
|
||||||
|
|
||||||
$response = self::$mock_create_response ?? (object)[
|
$response = self::$mock_create_response ?? (object)[
|
||||||
'customerNumber' => (int)$number,
|
'customerNumber' => (int)$number,
|
||||||
];
|
];
|
||||||
@@ -133,9 +142,14 @@ namespace classes {
|
|||||||
public static int $mock_zipcode = 2630;
|
public static int $mock_zipcode = 2630;
|
||||||
public static string $mock_city = 'Taastrup';
|
public static string $mock_city = 'Taastrup';
|
||||||
public static string $mock_website = 'https://demo.test';
|
public static string $mock_website = 'https://demo.test';
|
||||||
|
public static ?\RuntimeException $mock_exception = null;
|
||||||
|
|
||||||
public function getCompanyInformation($cvr, $endpoint, $data): object
|
public function getCompanyInformation($cvr, $endpoint, $data): object
|
||||||
{
|
{
|
||||||
|
if (self::$mock_exception !== null) {
|
||||||
|
throw self::$mock_exception;
|
||||||
|
}
|
||||||
|
|
||||||
$result = new \stdClass();
|
$result = new \stdClass();
|
||||||
$result->name = self::$mock_name;
|
$result->name = self::$mock_name;
|
||||||
$result->address = self::$mock_address;
|
$result->address = self::$mock_address;
|
||||||
@@ -214,6 +228,7 @@ namespace objects {
|
|||||||
{
|
{
|
||||||
public static array $mock_existing_customer_numbers = [];
|
public static array $mock_existing_customer_numbers = [];
|
||||||
public static array $mock_importable_customer_numbers = [];
|
public static array $mock_importable_customer_numbers = [];
|
||||||
|
public static bool $mock_external_lookup_enabled = true;
|
||||||
public static array $interaction_log = [];
|
public static array $interaction_log = [];
|
||||||
|
|
||||||
public int $id = 0;
|
public int $id = 0;
|
||||||
@@ -223,6 +238,7 @@ namespace objects {
|
|||||||
{
|
{
|
||||||
self::$mock_existing_customer_numbers = [];
|
self::$mock_existing_customer_numbers = [];
|
||||||
self::$mock_importable_customer_numbers = [];
|
self::$mock_importable_customer_numbers = [];
|
||||||
|
self::$mock_external_lookup_enabled = true;
|
||||||
self::$interaction_log = [];
|
self::$interaction_log = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +258,8 @@ namespace objects {
|
|||||||
self::$interaction_log[] = 'bootstrap:' . $customerNumber;
|
self::$interaction_log[] = 'bootstrap:' . $customerNumber;
|
||||||
|
|
||||||
$existsLocally = in_array($customerNumber, self::$mock_existing_customer_numbers, true);
|
$existsLocally = in_array($customerNumber, self::$mock_existing_customer_numbers, true);
|
||||||
$canImport = in_array($customerNumber, self::$mock_importable_customer_numbers, true);
|
$canImport = self::$mock_external_lookup_enabled
|
||||||
|
&& in_array($customerNumber, self::$mock_importable_customer_numbers, true);
|
||||||
|
|
||||||
if ($existsLocally || $canImport) {
|
if ($existsLocally || $canImport) {
|
||||||
$this->id = $customerNumber;
|
$this->id = $customerNumber;
|
||||||
@@ -257,6 +274,22 @@ namespace objects {
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function importCustomerFromEconomicCustomerData(object $customerData): self|bool
|
||||||
|
{
|
||||||
|
$customerNumber = (int)($customerData->customerNumber ?? 0);
|
||||||
|
if ($customerNumber <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$interaction_log[] = 'snapshot-import:' . $customerNumber;
|
||||||
|
$this->id = $customerNumber;
|
||||||
|
$this->exists = true;
|
||||||
|
self::$mock_existing_customer_numbers[] = $customerNumber;
|
||||||
|
self::$mock_existing_customer_numbers = array_values(array_unique(self::$mock_existing_customer_numbers));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function exists(): bool
|
public function exists(): bool
|
||||||
{
|
{
|
||||||
return $this->exists;
|
return $this->exists;
|
||||||
@@ -391,6 +424,58 @@ namespace {
|
|||||||
'expected_error' => 'Parameter cvr must be at least 8 characters long',
|
'expected_error' => 'Parameter cvr must be at least 8 characters long',
|
||||||
'expected_status' => 400,
|
'expected_status' => 400,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'CVR lookup failure returns validation error without creating customer',
|
||||||
|
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
|
||||||
|
},
|
||||||
|
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||||
|
'expected_status' => 400,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures must not create e-conomic customers.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures must not send welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures must not send superuser notifications.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures must not send Slack customer registration notifications.');
|
||||||
|
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures should be logged for diagnostics.');
|
||||||
|
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should use the lookup failure log event.');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'CVR lookup without company name returns validation error without creating customer',
|
||||||
|
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\virkdata::$mock_name = '';
|
||||||
|
},
|
||||||
|
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||||
|
'expected_status' => 400,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup responses without a company name must not create e-conomic customers.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup responses without a company name must not send welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup responses without a company name must not send superuser notifications.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup responses without a company name must not send Slack customer registration notifications.');
|
||||||
|
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup responses without a company name should be logged for diagnostics.');
|
||||||
|
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', 'CVR lookup response without a company name should use the invalid response log event.');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'CVR lookup failure takes precedence over local customer number collision',
|
||||||
|
'params' => array_merge($baseParams, ['cvr' => '11111112']),
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\virkdata::$mock_exception = new \RuntimeException('An error occurred');
|
||||||
|
\objects\users_o::$mock_existing_customer_numbers = [12345678];
|
||||||
|
},
|
||||||
|
'expected_error' => 'CVR could not be verified. Please check the CVR number and try again.',
|
||||||
|
'expected_status' => 400,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 0, 'CVR lookup failures with local collisions must not create e-conomic customers.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 0, 'CVR lookup failures with local collisions must not send welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 0, 'CVR lookup failures with local collisions must not send superuser notifications.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'CVR lookup failures with local collisions must not send Slack customer registration notifications.');
|
||||||
|
assert_true(count(\objects\logs_o::$entries) === 1, 'CVR lookup failures with local collisions should be logged once.');
|
||||||
|
assert_true(\objects\logs_o::$entries[0]['event'] === 'AUTH_REGISTER_CVR_LOOKUP_FAILED', 'CVR lookup failure should not be masked by the local duplicate check.');
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Existing company phone with local customer stays blocked',
|
'name' => 'Existing company phone with local customer stays blocked',
|
||||||
'params' => $baseParams,
|
'params' => $baseParams,
|
||||||
@@ -509,6 +594,88 @@ namespace {
|
|||||||
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.');
|
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.');
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Successful registration falls back to the create response when immediate import lookup misses',
|
||||||
|
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\economic::$mock_create_response = (object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Mock Company',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
];
|
||||||
|
\objects\users_o::$mock_external_lookup_enabled = false;
|
||||||
|
},
|
||||||
|
'expected_success' => (object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Mock Company',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
],
|
||||||
|
'expected_status' => 201,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
|
||||||
|
assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Fresh registration must try the standard local bootstrap first.');
|
||||||
|
assert_true(\objects\users_o::$interaction_log[1] === 'snapshot-import:12345678', 'Fresh registration must import from the create response when the immediate lookup misses.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 2, 'Snapshot fallback registration must send two welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Snapshot fallback registration must notify opted-in superusers once.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Snapshot fallback registration must notify Slack once.');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Duplicate create response recovers a just-created e-conomic customer and sends notifications',
|
||||||
|
'params' => $baseParams,
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\economic::$mock_create_exception = new \RuntimeException('e-conomic request failed with HTTP 400: Customer already exists');
|
||||||
|
\classes\economic::$mock_collection_after_create_exception = [
|
||||||
|
(object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Recovered After Create',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
\objects\users_o::$mock_importable_customer_numbers = [12345678];
|
||||||
|
},
|
||||||
|
'expected_success' => (object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Recovered After Create',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
],
|
||||||
|
'expected_status' => 200,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 1, 'Recovery must still record the attempted create call.');
|
||||||
|
assert_true(count(\classes\economic::$search_calls) === 2, 'Recovery must verify the duplicate by searching e-conomic again.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 2, 'Duplicate create recovery must send two welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Duplicate create recovery must notify opted-in superusers once.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Duplicate create recovery must notify Slack once.');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Generic create failure recovers a confirmed just-created e-conomic customer',
|
||||||
|
'params' => $baseParams,
|
||||||
|
'setup' => static function (): void {
|
||||||
|
\classes\economic::$mock_create_exception = new \RuntimeException('e-conomic request failed with HTTP 400: Validation failed. | details={"httpStatusCode":400}');
|
||||||
|
\classes\economic::$mock_collection_after_create_exception = [
|
||||||
|
(object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Recovered After Generic Create Failure',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
\objects\users_o::$mock_importable_customer_numbers = [12345678];
|
||||||
|
},
|
||||||
|
'expected_success' => (object)[
|
||||||
|
'customerNumber' => 12345678,
|
||||||
|
'name' => 'Recovered After Generic Create Failure',
|
||||||
|
'email' => 'test@test.com',
|
||||||
|
],
|
||||||
|
'expected_status' => 200,
|
||||||
|
'assert' => static function (): void {
|
||||||
|
assert_true(count(\classes\economic::$create_calls) === 1, 'Generic create recovery must still record the attempted create call.');
|
||||||
|
assert_true(count(\classes\economic::$search_calls) === 2, 'Generic create recovery must confirm the customer by searching e-conomic again.');
|
||||||
|
assert_true(count(\classes\email::$sent) === 2, 'Generic create recovery must send two welcome emails.');
|
||||||
|
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Generic create recovery must notify opted-in superusers once.');
|
||||||
|
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Generic create recovery must notify Slack once.');
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Fresh create mismatch returns conflict without local bootstrap or email',
|
'name' => 'Fresh create mismatch returns conflict without local bootstrap or email',
|
||||||
'params' => $baseParams,
|
'params' => $baseParams,
|
||||||
@@ -544,6 +711,7 @@ namespace {
|
|||||||
\classes\virkdata::$mock_zipcode = 2630;
|
\classes\virkdata::$mock_zipcode = 2630;
|
||||||
\classes\virkdata::$mock_city = 'Taastrup';
|
\classes\virkdata::$mock_city = 'Taastrup';
|
||||||
\classes\virkdata::$mock_website = 'https://demo.test';
|
\classes\virkdata::$mock_website = 'https://demo.test';
|
||||||
|
\classes\virkdata::$mock_exception = null;
|
||||||
\objects\users_o::reset();
|
\objects\users_o::reset();
|
||||||
\objects\logs_o::reset();
|
\objects\logs_o::reset();
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ WORKDIR /var/www/html
|
|||||||
|
|
||||||
# Copy and enable entrypoint that installs Composer deps on first run
|
# Copy and enable entrypoint that installs Composer deps on first run
|
||||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[www]
|
||||||
|
pm = dynamic
|
||||||
|
pm.max_children = 8
|
||||||
|
pm.start_servers = 4
|
||||||
|
pm.min_spare_servers = 4
|
||||||
|
pm.max_spare_servers = 8
|
||||||
|
pm.max_requests = 500
|
||||||
Reference in New Issue
Block a user