Files
api/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php
T

768 lines
29 KiB
PHP

<?php
app_require('classes/superuser_system_status_service.php');
use classes\superuser_system_status_service;
function system_status_module_value(mixed $value, string $type = 'string'): array
{
return [
'raw' => $value,
'parsed' => $value,
'type' => $type,
];
}
class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_service
{
public array $httpProbeCalls = [];
public array $nextHttpProbeResult = [
'status' => 'ok',
'status_reason' => 'Probe connectivity confirmed.',
'status_reason_key' => 'http_ok',
'status_reason_params' => ['label' => 'Probe'],
'checked_at' => '2026-04-08T17:00:00+00:00',
'latency_ms' => 12.34,
'http_status' => 200,
];
public ?array $moduleConfigRowsOverride = null;
public bool $backupStoreValidationShouldFail = false;
public string $backupStoreFailureMessage = 'Backup bucket is missing.';
public bool $selfserveBootstrapShouldFail = false;
public string $selfserveBootstrapFailureMessage = 'Schema bootstrap failed.';
public bool $selfserveMinuteProductExistsValue = true;
public ?int $selfserveMinuteProductCheckedId = null;
public ?string $shellyProbeDeviceId = null;
public function collectModulesPublic(bool $force, array &$warnings): array
{
return $this->collectModules($force, $warnings);
}
public function classifyHttpProbeResultPublic(array $httpResponse, string $label): array
{
return $this->classifyHttpProbeResult($httpResponse, $label);
}
public function evaluateRecaptchaProbeResponsePublic(array $httpResponse, string $label): array
{
return $this->evaluateRecaptchaProbeResponse($httpResponse, $label);
}
public function normalizeWarningEntriesPublic(array $warnings): array
{
return $this->normalizeWarningEntries($warnings);
}
public function probeEconomicModulePublic(array $config): array
{
return $this->probeEconomicModule($config);
}
public function probeRecaptchaModulePublic(array $config): array
{
return $this->probeRecaptchaModule($config);
}
public function probeEmailModulePublic(array $config): array
{
return $this->probeEmailModule($config);
}
public function probeBackupsModulePublic(array $config): array
{
return $this->probeBackupsModule($config);
}
public function probeMotorApiModulePublic(array $config): array
{
return $this->probeMotorApiModule($config);
}
public function probeFxRatesApiModulePublic(array $config): array
{
return $this->probeFxRatesApiModule($config);
}
public function probeWeatherApiModulePublic(array $config): array
{
return $this->probeWeatherApiModule($config);
}
public function probeWorkfeedModulePublic(array $config): array
{
return $this->probeWorkfeedModule($config);
}
public function probeGatewayApiModulePublic(array $config): array
{
return $this->probeGatewayApiModule($config);
}
public function probeXlVaskModulePublic(array $config): array
{
return $this->probeXlVaskModule($config);
}
public function probeLimbleModulePublic(array $config): array
{
return $this->probeLimbleModule($config);
}
public function probeLicensePlateRecognizerModulePublic(array $config): array
{
return $this->probeLicensePlateRecognizerModule($config);
}
public function probeShellyModulePublic(array $config): array
{
return $this->probeShellyModule($config);
}
public function probeSelfserveModulePublic(array $config): array
{
return $this->probeSelfserveModule($config);
}
public function probeBirdModulePublic(array $config): array
{
return $this->probeBirdModule($config);
}
protected function performHttpProbe(
string $url,
array $headers,
string $label,
?string $basicAuth = null,
string $method = 'GET',
?string $body = null,
?callable $responseEvaluator = null
): array {
$this->httpProbeCalls[] = [
'url' => $url,
'headers' => $headers,
'label' => $label,
'basic_auth' => $basicAuth,
'method' => $method,
'body' => $body,
'has_evaluator' => $responseEvaluator !== null,
];
return $this->nextHttpProbeResult;
}
protected function loadModuleConfigRows(array $moduleNames): array
{
if ($this->moduleConfigRowsOverride !== null) {
return $this->moduleConfigRowsOverride;
}
return parent::loadModuleConfigRows($moduleNames);
}
protected function validateBackupsStore(): void
{
if ($this->backupStoreValidationShouldFail) {
throw new RuntimeException($this->backupStoreFailureMessage);
}
}
protected function bootstrapSelfserveSchema(): void
{
if ($this->selfserveBootstrapShouldFail) {
throw new RuntimeException($this->selfserveBootstrapFailureMessage);
}
}
protected function selfserveMinuteProductExists(int $productId): bool
{
$this->selfserveMinuteProductCheckedId = $productId;
return $this->selfserveMinuteProductExistsValue;
}
protected function findShellyProbeDeviceId(): ?string
{
return $this->shellyProbeDeviceId;
}
}
beforeEach(function (): void {
$this->previousEconomicApi = $GLOBALS['ECONOMIC_API'] ?? null;
});
afterEach(function (): void {
if ($this->previousEconomicApi === null) {
unset($GLOBALS['ECONOMIC_API']);
return;
}
$GLOBALS['ECONOMIC_API'] = $this->previousEconomicApi;
});
it('reduces overall status using down and degraded precedence', function (): void {
expect(superuser_system_status_service::reduceOverallStatus(['ok', 'configured']))->toBe('ok');
expect(superuser_system_status_service::reduceOverallStatus(['ok', 'not_configured']))->toBe('degraded');
expect(superuser_system_status_service::reduceOverallStatus(['configured', 'down']))->toBe('down');
});
it('classifies runtime usage percentages consistently', function (): void {
expect(superuser_system_status_service::statusFromUsagePercent(42.5))->toBe('ok');
expect(superuser_system_status_service::statusFromUsagePercent(91.0, 90, 99))->toBe('degraded');
expect(superuser_system_status_service::statusFromUsagePercent(99.1, 90, 99))->toBe('down');
expect(superuser_system_status_service::statusFromUsagePercent(null))->toBe('down');
});
it('reuses cached module probes only when the ttl is still valid and force is false', function (): void {
$freshCachedProbe = ['checked_at' => date('c', time() - 15)];
$expiredCachedProbe = ['checked_at' => date('c', time() - 120)];
expect(superuser_system_status_service::shouldReuseCachedModuleProbe($freshCachedProbe, false, time(), 60))->toBeTrue();
expect(superuser_system_status_service::shouldReuseCachedModuleProbe($freshCachedProbe, true, time(), 60))->toBeFalse();
expect(superuser_system_status_service::shouldReuseCachedModuleProbe($expiredCachedProbe, false, time(), 60))->toBeFalse();
});
it('classifies authenticated http probe responses conservatively', function (int $httpStatus, string $expectedStatus, string $reasonFragment, string $expectedReasonKey): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->classifyHttpProbeResultPublic([
'checked_at' => '2026-04-08T17:10:00+00:00',
'latency_ms' => 23.5,
'http_status' => $httpStatus,
'error' => '',
], 'Example API');
expect($result['status'])->toBe($expectedStatus);
expect($result['status_reason'])->toContain($reasonFragment);
expect($result['status_reason_key'])->toBe($expectedReasonKey);
})->with([
'success' => [200, 'ok', 'connectivity confirmed', 'http_ok'],
'unauthorized' => [401, 'down', 'HTTP 401', 'http_status'],
'forbidden' => [403, 'down', 'HTTP 403', 'http_status'],
'rate limited' => [429, 'degraded', 'rate limited', 'http_rate_limited'],
'server error' => [503, 'degraded', 'HTTP 503', 'http_status'],
'no response' => [0, 'down', 'did not return an HTTP response', 'http_no_response'],
]);
it('classifies transport errors as down', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->classifyHttpProbeResultPublic([
'checked_at' => '2026-04-08T17:10:00+00:00',
'latency_ms' => 7.5,
'http_status' => 0,
'error' => 'Connection refused',
], 'Example API');
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('Connection refused');
expect($result['status_reason_key'])->toBe('http_probe_failed');
});
it('normalizes and deduplicates warning entries for snapshots', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$entries = $service->normalizeWarningEntriesPublic([
'Redis is unavailable; module probe caching is bypassed.',
[
'key' => 'redis_cache_bypass',
'params' => [],
'message' => 'Redis is unavailable; module probe caching is bypassed.',
],
[
'key' => 'modules_without_probes',
'params' => ['modules' => 'ocrspace, virkdata', 'module_keys' => ['ocrspace', 'virkdata']],
'message' => 'Some modules expose configuration-only status because no safe read-only probe exists: ocrspace, virkdata.',
],
[
'key' => 'modules_without_probes',
'params' => ['modules' => 'ocrspace, virkdata', 'module_keys' => ['ocrspace', 'virkdata']],
'message' => 'Some modules expose configuration-only status because no safe read-only probe exists: ocrspace, virkdata.',
],
]);
expect($entries)->toHaveCount(3);
expect($entries[0])->toBe([
'key' => null,
'params' => [],
'message' => 'Redis is unavailable; module probe caching is bypassed.',
]);
expect($entries[1]['key'])->toBe('redis_cache_bypass');
expect($entries[2]['params']['module_keys'])->toBe(['ocrspace', 'virkdata']);
});
it('interprets recaptcha probe payloads safely', function (array $payload, string $expectedStatus, string $reasonFragment, string $expectedReasonKey): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->evaluateRecaptchaProbeResponsePublic([
'checked_at' => '2026-04-08T17:10:00+00:00',
'latency_ms' => 11.4,
'http_status' => 200,
'body' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'error' => '',
], 'reCAPTCHA');
expect($result['status'])->toBe($expectedStatus);
expect($result['status_reason'])->toContain($reasonFragment);
expect($result['status_reason_key'])->toBe($expectedReasonKey);
})->with([
'dummy token rejected but credentials valid' => [
['success' => false, 'error-codes' => ['invalid-input-response']],
'ok',
'connectivity confirmed',
'http_ok',
],
'invalid secret is down' => [
['success' => false, 'error-codes' => ['invalid-input-secret']],
'down',
'credentials were rejected',
'recaptcha_credentials_rejected',
],
'unexpected validation errors degrade' => [
['success' => false, 'error-codes' => ['bad-request']],
'degraded',
'unexpected validation errors',
'recaptcha_validation_errors',
],
]);
it('builds the expected http probe requests for newly supported modules', function (
string $methodName,
array $config,
string $expectedUrl,
array $expectedHeaders,
?string $expectedBasicAuth,
string $expectedMethod,
?string $expectedBodyFragment,
bool $expectsEvaluator
): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->{$methodName}($config);
expect($service->httpProbeCalls)->toHaveCount(1);
$call = $service->httpProbeCalls[0];
expect($call['url'])->toBe($expectedUrl);
expect($call['basic_auth'])->toBe($expectedBasicAuth);
expect($call['method'])->toBe($expectedMethod);
expect($call['has_evaluator'])->toBe($expectsEvaluator);
foreach ($expectedHeaders as $expectedHeader) {
expect($call['headers'])->toContain($expectedHeader);
}
if ($expectedBodyFragment !== null) {
expect((string)$call['body'])->toContain($expectedBodyFragment);
} else {
expect($call['body'])->toBeNull();
}
})->with([
'recaptcha' => [
'probeRecaptchaModulePublic',
['secret_key_v2' => system_status_module_value('recaptcha-secret')],
'https://www.google.com/recaptcha/api/siteverify',
['Content-Type: application/x-www-form-urlencoded'],
null,
'POST',
'secret=recaptcha-secret',
true,
],
'email' => [
'probeEmailModulePublic',
['mailersend_api_key' => system_status_module_value('mailersend-key')],
'https://api.mailersend.com/v1/api-quota',
['Authorization: Bearer mailersend-key', 'Accept: application/json'],
null,
'GET',
null,
false,
],
'motorapi' => [
'probeMotorApiModulePublic',
['secret_key' => system_status_module_value('motorapi-key')],
'https://v1.motorapi.dk/usage',
['X-AUTH-TOKEN: motorapi-key'],
null,
'GET',
null,
false,
],
'fxratesapi' => [
'probeFxRatesApiModulePublic',
['secret_key' => system_status_module_value('fxrates-key')],
'https://api.fxratesapi.com/latest?base=EUR&currencies=DKK',
['X-AUTH-TOKEN: fxrates-key'],
null,
'GET',
null,
false,
],
'weatherapi' => [
'probeWeatherApiModulePublic',
['secret_key' => system_status_module_value('weather-key')],
'https://api.weatherapi.com/v1/current.json?key=weather-key&q=Copenhagen',
['Accept: application/json'],
null,
'GET',
null,
false,
],
'workfeed' => [
'probeWorkfeedModulePublic',
[
'api_url' => system_status_module_value('https://api.workfeed.test'),
'CompanyID' => system_status_module_value('company-123'),
'api_key' => system_status_module_value('workfeed-token'),
],
'https://api.workfeed.test/companies/company-123/departments',
['Accept: application/json', 'Authorization: workfeed-token'],
null,
'GET',
null,
false,
],
'gatewayapi' => [
'probeGatewayApiModulePublic',
['api_token' => system_status_module_value('gateway-token')],
'https://gatewayapi.eu/rest/me',
['Authorization: Token gateway-token', 'Accept: application/json'],
null,
'GET',
null,
false,
],
'xlvask' => [
'probeXlVaskModulePublic',
[
'username' => system_status_module_value('xl-user'),
'password' => system_status_module_value('xl-pass'),
],
'https://api.xlwash.com/customers',
['Accept: application/json'],
'xl-user:xl-pass',
'GET',
null,
false,
],
'limble' => [
'probeLimbleModulePublic',
[
'client_id' => system_status_module_value('limble-id'),
'client_secret' => system_status_module_value('limble-secret'),
],
'https://api.limblecmms.com:443/v2/tasks?limit=1&page=1',
['Accept: application/json', 'Content-Type: application/json'],
'limble-id:limble-secret',
'GET',
null,
false,
],
'license plate recognizer' => [
'probeLicensePlateRecognizerModulePublic',
['api_key' => system_status_module_value('lpr-key')],
'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk/info/',
['Authorization: Token lpr-key', 'Accept: application/json'],
null,
'GET',
null,
false,
],
'bird' => [
'probeBirdModulePublic',
[
'server_url' => system_status_module_value('https://api.bird.com'),
'api_key' => system_status_module_value('bird-key'),
'channelId' => system_status_module_value('channel-123'),
'workplaceId' => system_status_module_value('workspace-456'),
],
'https://api.bird.com/workspaces/workspace-456/channels/channel-123/calls?limit=1',
['Authorization: AccessKey bird-key', 'Accept: application/json'],
null,
'GET',
null,
false,
],
]);
it('uses runtime economic credentials for the economic probe', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$GLOBALS['ECONOMIC_API'] = [
'app_secret_token' => 'economic-secret',
'app_access_grant' => 'economic-grant',
];
$service->probeEconomicModulePublic([]);
expect($service->httpProbeCalls)->toHaveCount(1);
$call = $service->httpProbeCalls[0];
expect($call['url'])->toBe('https://restapi.e-conomic.com/layouts/');
expect($call['headers'])->toContain('X-AppSecretToken: economic-secret');
expect($call['headers'])->toContain('X-AgreementGrantToken: economic-grant');
});
it('returns down without attempting economic http calls when runtime credentials are missing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
unset($GLOBALS['ECONOMIC_API']);
$result = $service->probeEconomicModulePublic([]);
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('credentials are missing');
expect($result['status_reason_key'])->toBe('economic_credentials_missing');
expect($service->httpProbeCalls)->toBeEmpty();
});
it('reports backup probe failures from local validation', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupStoreValidationShouldFail = true;
$service->backupStoreFailureMessage = 'Backups bucket is unavailable.';
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('Backups bucket is unavailable');
expect($result['status_reason_key'])->toBe('backup_probe_failed');
});
it('returns configured for shelly when no known device id is available for probing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->probeShellyModulePublic([
'server_url' => system_status_module_value('https://shelly.example'),
'secret_key' => system_status_module_value('shelly-secret'),
]);
expect($result['status'])->toBe('configured');
expect($result['status_reason'])->toContain('no known device id');
expect($result['status_reason_key'])->toBe('shelly_no_device_id');
expect($service->httpProbeCalls)->toBeEmpty();
});
it('builds an authenticated shelly status probe when a known device id exists', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->shellyProbeDeviceId = 'device-123';
$service->probeShellyModulePublic([
'server_url' => system_status_module_value('https://shelly.example'),
'secret_key' => system_status_module_value('shelly-secret'),
]);
expect($service->httpProbeCalls)->toHaveCount(1);
$call = $service->httpProbeCalls[0];
expect($call['url'])->toBe('https://shelly.example/device/status?id=device-123&auth_key=shelly-secret');
expect($call['headers'])->toContain('Accept: application/json');
expect($call['method'])->toBe('GET');
});
it('validates selfserve schema and minute product configuration', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->probeSelfserveModulePublic([
'machine_wash_minutes_included' => system_status_module_value('15'),
'minute_product' => system_status_module_value('77'),
]);
expect($result['status'])->toBe('ok');
expect($service->selfserveMinuteProductCheckedId)->toBe(77);
});
it('reports invalid selfserve minute configuration before touching the schema', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->probeSelfserveModulePublic([
'machine_wash_minutes_included' => system_status_module_value('abc'),
'minute_product' => system_status_module_value('77'),
]);
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('minutes configuration is invalid');
expect($result['status_reason_key'])->toBe('selfserve_minutes_invalid');
expect($service->selfserveMinuteProductCheckedId)->toBeNull();
});
it('reports missing selfserve minute products', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->selfserveMinuteProductExistsValue = false;
$result = $service->probeSelfserveModulePublic([
'machine_wash_minutes_included' => system_status_module_value('15'),
'minute_product' => system_status_module_value('88'),
]);
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('does not exist');
expect($result['status_reason_key'])->toBe('selfserve_minute_product_missing');
expect($result['status_reason_params'])->toBe(['productId' => 88]);
expect($service->selfserveMinuteProductCheckedId)->toBe(88);
});
it('surfaces selfserve bootstrap failures', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->selfserveBootstrapShouldFail = true;
$service->selfserveBootstrapFailureMessage = 'Migration failed.';
$result = $service->probeSelfserveModulePublic([
'machine_wash_minutes_included' => system_status_module_value('15'),
'minute_product' => system_status_module_value('88'),
]);
expect($result['status'])->toBe('down');
expect($result['status_reason'])->toContain('Migration failed');
expect($result['status_reason_key'])->toBe('selfserve_probe_failed');
});
it('adds localization metadata for disabled and missing-config modules', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->moduleConfigRowsOverride = [
'reCAPTCHA' => [
'enabled' => system_status_module_value(false, 'bool'),
],
'Email' => [
'enabled' => system_status_module_value(true, 'bool'),
'mailersend_enabled' => system_status_module_value(false, 'bool'),
],
];
$warnings = [];
$modules = $service->collectModulesPublic(false, $warnings);
$moduleMap = [];
foreach ($modules as $module) {
$moduleMap[$module['key']] = $module;
}
expect($moduleMap['reCAPTCHA']['status'])->toBe('disabled');
expect($moduleMap['reCAPTCHA']['status_reason_key'])->toBe('module_disabled');
expect($moduleMap['email']['status'])->toBe('not_configured');
expect($moduleMap['email']['status_reason_key'])->toBe('email_delivery_not_implemented');
expect($moduleMap['email']['status_reason_params'])->toBe([]);
expect($warnings)->toBeEmpty();
});
it('marks newly supported modules as probe backed and leaves only truly unsupported modules as configuration only', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$GLOBALS['ECONOMIC_API'] = [
'app_secret_token' => 'economic-secret',
'app_access_grant' => 'economic-grant',
];
$service->moduleConfigRowsOverride = [
'economic' => [
'invoiceLayoutNumber' => system_status_module_value('1'),
'paymentTermsNumber' => system_status_module_value('2'),
'adminFeeMonthly' => system_status_module_value('3'),
'adminFeeOrder' => system_status_module_value('4'),
'feeProductId' => system_status_module_value('5'),
],
'Email' => [
'enabled' => system_status_module_value(true, 'bool'),
'mailersend_enabled' => system_status_module_value(true, 'bool'),
'mailersend_api_key' => system_status_module_value('mailersend-key'),
'smtp_from' => system_status_module_value('from@example.com'),
'smtp_from_name' => system_status_module_value('Truck Wash'),
'smtp_reply_to' => system_status_module_value('reply@example.com'),
'smtp_reply_to_name' => system_status_module_value('Reply'),
],
'Backups' => [
'enabled' => system_status_module_value(true, 'bool'),
],
'motorapi' => [
'enabled' => system_status_module_value(true, 'bool'),
'secret_key' => system_status_module_value('motorapi-key'),
],
'fxratesapi' => [
'enabled' => system_status_module_value(true, 'bool'),
'secret_key' => system_status_module_value('fxrates-key'),
],
'weatherapi' => [
'enabled' => system_status_module_value(true, 'bool'),
'secret_key' => system_status_module_value('weather-key'),
],
'workfeed' => [
'enabled' => system_status_module_value(true, 'bool'),
'api_url' => system_status_module_value('https://api.workfeed.test'),
'api_key' => system_status_module_value('workfeed-token'),
'CompanyID' => system_status_module_value('company-123'),
],
'GatewayAPI' => [
'enabled' => system_status_module_value(true, 'bool'),
'api_secret' => system_status_module_value('gateway-secret'),
'api_token' => system_status_module_value('gateway-token'),
'sender' => system_status_module_value('TruckWash'),
],
'xlvask' => [
'enabled' => system_status_module_value(true, 'bool'),
'username' => system_status_module_value('xl-user'),
'password' => system_status_module_value('xl-pass'),
],
'limble' => [
'enabled' => system_status_module_value(true, 'bool'),
'client_id' => system_status_module_value('limble-id'),
'client_secret' => system_status_module_value('limble-secret'),
],
'licenseplaterecognizer' => [
'enabled' => system_status_module_value(true, 'bool'),
'api_key' => system_status_module_value('lpr-key'),
],
'shelly' => [
'enabled' => system_status_module_value(true, 'bool'),
'server_url' => system_status_module_value('https://shelly.example'),
'secret_key' => system_status_module_value('shelly-secret'),
],
'selfserve' => [
'enabled' => system_status_module_value(true, 'bool'),
'machine_wash_minutes_included' => system_status_module_value('15'),
'minute_product' => system_status_module_value('88'),
],
'bird' => [
'enabled' => system_status_module_value(true, 'bool'),
'server_url' => system_status_module_value('https://api.bird.com'),
'api_key' => system_status_module_value('bird-key'),
'channelId' => system_status_module_value('channel-123'),
'workplaceId' => system_status_module_value('workspace-456'),
],
'ocrSpace' => [
'enabled' => system_status_module_value(true, 'bool'),
'api_key' => system_status_module_value('ocr-key'),
],
'virkdata' => [
'enabled' => system_status_module_value(true, 'bool'),
'secret_key' => system_status_module_value('virk-key'),
],
];
$warnings = [];
$modules = $service->collectModulesPublic(false, $warnings);
$moduleMap = [];
foreach ($modules as $module) {
$moduleMap[$module['key']] = $module;
}
expect($moduleMap['email']['probe_supported'])->toBeTrue();
expect($moduleMap['email']['status'])->toBe('ok');
expect($moduleMap['email']['status_reason_key'])->toBe('http_ok');
expect($moduleMap['backups']['probe_supported'])->toBeTrue();
expect($moduleMap['backups']['status'])->toBe('ok');
expect($moduleMap['backups']['status_reason_key'])->toBe('backup_connectivity_confirmed');
expect($moduleMap['motorapi']['probe_supported'])->toBeTrue();
expect($moduleMap['motorapi']['status'])->toBe('ok');
expect($moduleMap['bird']['probe_supported'])->toBeTrue();
expect($moduleMap['bird']['status'])->toBe('ok');
expect($moduleMap['shelly']['probe_supported'])->toBeTrue();
expect($moduleMap['shelly']['status'])->toBe('configured');
expect($moduleMap['shelly']['status_reason_key'])->toBe('shelly_no_device_id');
expect($moduleMap['selfserve']['probe_supported'])->toBeTrue();
expect($moduleMap['selfserve']['status'])->toBe('ok');
expect($moduleMap['selfserve']['status_reason_key'])->toBe('selfserve_configuration_confirmed');
expect($moduleMap['ocrspace']['probe_supported'])->toBeFalse();
expect($moduleMap['ocrspace']['status'])->toBe('configured');
expect($moduleMap['ocrspace']['status_reason_key'])->toBe('safe_probe_unavailable');
expect($moduleMap['virkdata']['probe_supported'])->toBeFalse();
expect($moduleMap['virkdata']['status'])->toBe('configured');
expect($moduleMap['virkdata']['status_reason_key'])->toBe('safe_probe_unavailable');
expect($warnings)->toHaveCount(1);
expect($warnings[0]['key'])->toBe('modules_without_probes');
expect($warnings[0]['params']['module_keys'])->toBe(['ocrspace', 'virkdata']);
expect($warnings[0]['message'])->toContain('ocrspace, virkdata');
});