createMock(SettingRepositoryInterface::class); $crypto = $crypto ?? $this->createMock(SettingsCryptoGatewayInterface::class); return new HelpdeskSettingsGateway(new SettingsMetadataGateway($settings), $crypto); } public function testGetAuthModeDefaultsToBasic(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn(null); $gateway = $this->createGateway($settings); $this->assertSame('basic', $gateway->getAuthMode()); } public function testGetAuthModeReturnsOAuth2WhenSet(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn('oauth2'); $gateway = $this->createGateway($settings); $this->assertSame('oauth2', $gateway->getAuthMode()); } public function testSetAuthModeRejectsInvalidMode(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->never())->method('set'); $gateway = $this->createGateway($settings); $this->assertFalse($gateway->setAuthMode('invalid')); } public function testSetODataBaseUrlRejectsHttp(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->never())->method('set'); $gateway = $this->createGateway($settings); $this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com')); } public function testSetODataBaseUrlAcceptsHttps(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with(HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/OData', 'helpdesk.bc_odata_base_url') ->willReturn(true); $gateway = $this->createGateway($settings); $this->assertTrue($gateway->setODataBaseUrl('https://bc.example.com/OData')); } public function testGetBasicPasswordReturnsNullWhenDecryptFails(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn('encrypted-value'); $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); $crypto->method('decryptString')->willThrowException(new \RuntimeException('bad key')); $gateway = $this->createGateway($settings, $crypto); $this->assertNull($gateway->getBasicPassword()); } public function testGetInactivityThresholdDaysDefaultsWhenUnset(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn(null); $gateway = $this->createGateway($settings); $this->assertSame( HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS, $gateway->getInactivityThresholdDays() ); } public function testGetInactivityThresholdDaysClampsToRange(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn('999'); $gateway = $this->createGateway($settings); // Clamped to the max (365). $this->assertSame(365, $gateway->getInactivityThresholdDays()); } public function testGetInactivityThresholdDaysFallsBackOnNonNumeric(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn('not-a-number'); $gateway = $this->createGateway($settings); $this->assertSame( HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS, $gateway->getInactivityThresholdDays() ); } public function testSetInactivityThresholdDaysClampsAndPersists(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with(HelpdeskSettingsGateway::KEY_INACTIVITY_THRESHOLD_DAYS, '7', 'helpdesk.inactivity_threshold_days') ->willReturn(true); $gateway = $this->createGateway($settings); // 1 is below the minimum (7) => clamped up. $this->assertTrue($gateway->setInactivityThresholdDays(1)); } public function testSetBasicPasswordEncryptsAndPersists(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'enc-pw', 'helpdesk.bc_basic_password_enc') ->willReturn(true); $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); $crypto->expects($this->once()) ->method('encryptString') ->with('my-password') ->willReturn('enc-pw'); $gateway = $this->createGateway($settings, $crypto); $this->assertTrue($gateway->setBasicPassword('my-password')); } public function testSetBasicPasswordClearsWhenEmpty(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, null, 'helpdesk.bc_basic_password_enc') ->willReturn(true); $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); $crypto->expects($this->never())->method('encryptString'); $gateway = $this->createGateway($settings, $crypto); $this->assertTrue($gateway->setBasicPassword('')); } public function testSetBasicPasswordReturnsFalseOnEncryptionFailure(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->never())->method('set'); $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); $crypto->method('encryptString')->willThrowException(new \RuntimeException('encryption failed')); $gateway = $this->createGateway($settings, $crypto); $this->assertFalse($gateway->setBasicPassword('my-password')); } public function testBuildEntityUrlConstructsCorrectUrl(): void { $callMap = [ [HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/ODataV4'], [HelpdeskSettingsGateway::KEY_COMPANY_NAME, 'Test Company'], ]; $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string { foreach ($callMap as [$k, $v]) { if ($k === $key) { return $v; } } return null; }); $gateway = $this->createGateway($settings); $url = $gateway->buildEntityUrl('Integration_Customer_Card'); $this->assertSame("https://bc.example.com/ODataV4/Company('Test%20Company')/Integration_Customer_Card", $url); } public function testValidateConfigurationReportsMissingBasicCredentials(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturn(null); $gateway = $this->createGateway($settings); $missing = $gateway->validateConfiguration(); $this->assertNotEmpty($missing); $this->assertContains('BC Basic Auth User', $missing); $this->assertContains('BC Basic Auth Password', $missing); } public function testIsConfiguredReturnsTrueWhenBasicCredentialsPresent(): void { $callMap = [ [HelpdeskSettingsGateway::KEY_AUTH_MODE, 'basic'], [HelpdeskSettingsGateway::KEY_BASIC_USER, 'testuser'], [HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'encrypted'], ]; $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string { foreach ($callMap as [$k, $v]) { if ($k === $key) { return $v; } } return null; }); $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); $crypto->method('decryptString')->willReturn('decrypted-password'); $gateway = $this->createGateway($settings, $crypto); $this->assertTrue($gateway->isConfigured()); } public function testSetOAuthTokenEndpointRejectsHttp(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->never())->method('set'); $gateway = $this->createGateway($settings); $this->assertFalse($gateway->setOAuthTokenEndpoint('http://login.microsoftonline.com/token')); } public function testGetSystemRecommendationsConfigEnvelopeReturnsDefaultsWhenMissing(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ [HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, null], ]); $gateway = $this->createGateway($settings); $envelope = $gateway->getSystemRecommendationsConfigEnvelope(); $this->assertSame('default', $envelope['source']); $this->assertSame(5, $envelope['config']['max_items']); $this->assertTrue($envelope['config']['rules']['stale_open']['enabled']); } public function testGetSystemRecommendationsConfigEnvelopeFallsBackOnInvalidJson(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ [HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, '{invalid'], ]); $gateway = $this->createGateway($settings); $envelope = $gateway->getSystemRecommendationsConfigEnvelope(); $this->assertSame('fallback_invalid_json', $envelope['source']); $this->assertSame(5, $envelope['config']['max_items']); } public function testGetSystemRecommendationsConfigNormalizesValues(): void { $rawConfig = json_encode([ 'version' => 99, 'max_items' => 99, 'rules' => [ 'high_risk_aging' => [ 'enabled' => true, 'priority' => '120', 'codes' => 'MAX, HOCH,', 'min_age_hours' => '12', ], 'customer_backlog' => [ 'enabled' => '0', 'priority' => 0, 'min_open_tickets' => -1, ], ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ [HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, $rawConfig], ]); $gateway = $this->createGateway($settings); $config = $gateway->getSystemRecommendationsConfig(); $this->assertSame(20, $config['max_items']); $this->assertSame(['MAX', 'HOCH'], $config['rules']['high_risk_aging']['codes']); $this->assertSame(12, $config['rules']['high_risk_aging']['min_age_hours']); $this->assertFalse($config['rules']['customer_backlog']['enabled']); $this->assertSame(1, $config['rules']['customer_backlog']['priority']); $this->assertSame(1, $config['rules']['customer_backlog']['min_open_tickets']); } public function testSetSystemRecommendationsConfigPersistsNormalizedJson(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with( HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, $this->callback(static function (mixed $value): bool { if (!is_string($value) || $value === '') { return false; } $decoded = json_decode($value, true); if (!is_array($decoded)) { return false; } return ($decoded['version'] ?? null) === 1 && ($decoded['max_items'] ?? null) === 1 && (($decoded['rules']['high_risk_aging']['codes'] ?? []) === ['MAX', 'HOCH']); }), 'helpdesk.system_recommendations_config' ) ->willReturn(true); $gateway = $this->createGateway($settings); $result = $gateway->setSystemRecommendationsConfig([ 'max_items' => -10, 'rules' => [ 'high_risk_aging' => [ 'codes' => '', ], ], ]); $this->assertTrue($result); } // --- Controlling Risk Config --- public function testGetControllingRiskConfigEnvelopeReturnsDefaultWhenNotSet(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ [HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, null], ]); $gateway = $this->createGateway($settings); $envelope = $gateway->getControllingRiskConfigEnvelope(); $this->assertSame('default', $envelope['source']); $this->assertSame(1, $envelope['config']['version']); $this->assertTrue($envelope['config']['rules']['ticket_volume_increase']['enabled']); $this->assertSame(30, $envelope['config']['rules']['ticket_volume_increase']['threshold_percent']); $this->assertSame(72, $envelope['config']['rules']['avg_resolution_high']['threshold_hours']); } public function testSetControllingRiskConfigNormalizesValues(): void { $settings = $this->createMock(SettingRepositoryInterface::class); $settings->expects($this->once()) ->method('set') ->with( HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, $this->callback(static function (mixed $value): bool { if (!is_string($value) || $value === '') { return false; } $decoded = json_decode($value, true); if (!is_array($decoded)) { return false; } return ($decoded['version'] ?? null) === 1 && ($decoded['rules']['ticket_volume_increase']['threshold_percent'] ?? null) === 50; }), 'helpdesk.controlling_risk_config' ) ->willReturn(true); $gateway = $this->createGateway($settings); $result = $gateway->setControllingRiskConfig([ 'rules' => [ 'ticket_volume_increase' => ['threshold_percent' => 50], ], ]); $this->assertTrue($result); } public function testNormalizeControllingRiskConfigEnforcesMinMax(): void { $rawConfig = json_encode([ 'version' => 1, 'rules' => [ 'ticket_volume_increase' => ['enabled' => true, 'threshold_percent' => 999], 'avg_resolution_high' => ['enabled' => true, 'threshold_hours' => 0], 'open_aging' => ['enabled' => true, 'threshold_hours' => 5000], 'unassigned_ratio' => ['enabled' => true, 'threshold_percent' => 101], ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $settings = $this->createMock(SettingRepositoryInterface::class); $settings->method('getValue')->willReturnMap([ [HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, $rawConfig], ]); $gateway = $this->createGateway($settings); $config = $gateway->getControllingRiskConfig(); // ticket_volume_increase: 999 > 200 → clamped to max 200 $this->assertSame(200, $config['rules']['ticket_volume_increase']['threshold_percent']); // avg_resolution_high: 0 < 1 → clamped to min 1 $this->assertSame(1, $config['rules']['avg_resolution_high']['threshold_hours']); // open_aging: 5000 > 2160 → clamped to max 2160 $this->assertSame(2160, $config['rules']['open_aging']['threshold_hours']); // unassigned_ratio: 101 > 100 → clamped to max 100 $this->assertSame(100, $config['rules']['unassigned_ratio']['threshold_percent']); } }