$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 array $backupHealthSummary = [ 'latest_verified_backup' => ['backup_uuid' => 'verified-backup'], 'latest_verified_age_seconds' => 60, 'fresh' => true, 'encryption' => ['available' => true, 'key_id' => 'test-key'], 'verification_required' => true, 'restore_enabled' => false, ]; 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 evaluateLicensePlateRecognizerProbeResponsePublic(array $httpResponse, string $label): array { return $this->evaluateLicensePlateRecognizerProbeResponse($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 backupHealthSummary(): array { return $this->backupHealthSummary; } 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¤cies=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, true, ], '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('evaluates license plate recognizer usage and quota health from the info payload', function ( string $body, string $expectedStatus, string $expectedReasonKey, ?array $expectedUsage ): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $result = $service->evaluateLicensePlateRecognizerProbeResponsePublic([ 'checked_at' => '2026-04-08T17:00:00+00:00', 'latency_ms' => 7.5, 'http_status' => 200, 'body' => $body, 'error' => '', ], 'License Plate Recognizer'); expect($result['status'])->toBe($expectedStatus); expect($result['status_reason_key'])->toBe($expectedReasonKey); expect(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('SECRET-LICENSE-KEY'); if ($expectedUsage === null) { expect($result)->not->toHaveKey('usage'); return; } expect($result['usage'])->toMatchArray($expectedUsage); })->with([ 'normal usage' => [ json_encode([ 'version' => '1.54.0', 'usage' => ['calls' => 98], 'license_key' => 'SECRET-LICENSE-KEY', 'webhooks' => [], 'total_calls' => 2500, ]), 'ok', 'licenseplaterecognizer_usage_available', [ 'provider' => 'licenseplaterecognizer', 'calls_used' => 98, 'quota_calls' => 2500, 'calls_remaining' => 2402, 'usage_percent' => 3.92, 'version' => '1.54.0', ], ], 'near quota' => [ json_encode([ 'version' => '1.54.0', 'usage' => ['calls' => 2250], 'total_calls' => 2500, ]), 'degraded', 'licenseplaterecognizer_quota_near_limit', [ 'calls_used' => 2250, 'quota_calls' => 2500, 'calls_remaining' => 250, 'usage_percent' => 90.0, ], ], 'exhausted quota' => [ json_encode([ 'version' => '1.54.0', 'usage' => ['calls' => 2500], 'total_calls' => 2500, ]), 'down', 'licenseplaterecognizer_quota_exhausted', [ 'calls_used' => 2500, 'quota_calls' => 2500, 'calls_remaining' => 0, 'usage_percent' => 100.0, ], ], 'unreadable payload' => [ 'not-json', 'degraded', 'licenseplaterecognizer_usage_unreadable', null, ], 'missing quota' => [ json_encode([ 'version' => '1.54.0', 'usage' => ['calls' => 10], ]), 'degraded', 'licenseplaterecognizer_usage_missing', null, ], ]); it('carries license plate recognizer usage from probe results into module status rows', function (): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $service->nextHttpProbeResult = [ 'status' => 'ok', 'status_reason' => 'License Plate Recognizer usage and quota are available.', 'status_reason_key' => 'licenseplaterecognizer_usage_available', 'status_reason_params' => ['used' => '98', 'quota' => '2500', 'remaining' => '2402', 'percent' => '3.9'], 'checked_at' => '2026-04-08T17:00:00+00:00', 'usage' => [ 'provider' => 'licenseplaterecognizer', 'calls_used' => 98, 'quota_calls' => 2500, 'calls_remaining' => 2402, 'usage_percent' => 3.92, 'version' => '1.54.0', ], ]; $service->moduleConfigRowsOverride = [ 'licenseplaterecognizer' => [ 'enabled' => system_status_module_value(true, 'bool'), 'api_key' => system_status_module_value('lpr-key'), ], ]; $warnings = []; $modules = $service->collectModulesPublic(false, $warnings); $moduleMap = []; foreach ($modules as $module) { $moduleMap[$module['key']] = $module; } expect($moduleMap['licenseplaterecognizer']['usage'])->toMatchArray([ 'provider' => 'licenseplaterecognizer', 'calls_used' => 98, 'quota_calls' => 2500, 'calls_remaining' => 2402, 'usage_percent' => 3.92, ]); }); 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('reports backups down when encryption key is missing', function (): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $service->backupHealthSummary['encryption'] = [ 'available' => false, 'error' => 'BACKUP_ENCRYPTION_KEY_V1 is not configured.', ]; $result = $service->probeBackupsModulePublic([]); expect($result['status'])->toBe('down'); expect($result['status_reason_key'])->toBe('backup_encryption_key_missing'); }); it('reports backups degraded when no verified backup is available', function (): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $service->backupHealthSummary['latest_verified_backup'] = null; $service->backupHealthSummary['fresh'] = false; $result = $service->probeBackupsModulePublic([]); expect($result['status'])->toBe('degraded'); expect($result['status_reason_key'])->toBe('backup_no_verified_backup'); }); it('reports backups degraded when the latest verified backup is stale', function (): void { $service = new SuperuserSystemStatusServiceProbeDouble(); $service->backupHealthSummary['fresh'] = false; $service->backupHealthSummary['latest_verified_age_seconds'] = 7200; $result = $service->probeBackupsModulePublic([]); expect($result['status'])->toBe('degraded'); expect($result['status_reason_key'])->toBe('backup_latest_verified_stale'); }); 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'), 'invoiceDiscountLayoutNumber' => system_status_module_value('6'), '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'); });