Files
nc-talk-ai/tests/unit/Service/SettingsServiceTest.php
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

666 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Service;
use OCA\EducAI\Db\Settings;
use OCA\EducAI\Db\SettingsMapper;
use OCA\EducAI\Service\CredentialService;
use OCA\EducAI\Service\SettingsService;
use OCA\EducAI\Service\TalkBotRegistrationService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class SettingsServiceTest extends TestCase {
public function testUpdateSettingsTriggersTalkBotRegistrationAfterPersistingSecret(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$persisted = false;
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(function (Settings $updated) use (&$persisted): Settings {
$persisted = true;
return $updated;
});
$credentialService->method('encrypt')
->willReturnMap([
['api-key', 'encrypted-api-key'],
['secret-123', 'encrypted-secret-123'],
]);
$talkBotRegistrationService->expects($this->once())
->method('syncRegistration')
->with('secret-123')
->willReturnCallback(function () use (&$persisted): array {
$this->assertTrue($persisted);
return ['status' => 'registered', 'message' => 'ok'];
});
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
defaultTemperature: SettingsService::DEFAULT_TEMPERATURE,
webhookSecret: ' secret-123 '
);
$this->assertSame($settings, $result);
$this->assertSame('encrypted-secret-123', $result->getWebhookSecret());
}
public function testUpdateSettingsSkipsTalkRegistrationWhenWebhookSecretIsBlank(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->with('api-key')
->willReturn('encrypted-api-key');
$talkBotRegistrationService->expects($this->never())
->method('syncRegistration');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
defaultTemperature: SettingsService::DEFAULT_TEMPERATURE,
webhookSecret: ' '
);
$this->assertSame($settings, $result);
$this->assertNull($result->getWebhookSecret());
}
public function testBlankSecretsKeepExistingEncryptedValues(): void {
$settings = new Settings();
$settings->setApiKey('encrypted-api-key-existing');
$settings->setWebhookSecret('encrypted-webhook-existing');
$settings->setEmbeddingApiKey('encrypted-embedding-existing');
$settings->setDoclingApiKey('encrypted-docling-existing');
$settings->setVisionApiKey('encrypted-vision-existing');
$settings->setSpeechApiKey('encrypted-speech-existing');
$settings->setSecondaryApiKey('encrypted-secondary-existing');
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->expects($this->never())
->method('encrypt');
$talkBotRegistrationService->expects($this->never())
->method('syncRegistration');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
defaultTemperature: SettingsService::DEFAULT_TEMPERATURE,
webhookSecret: ' ',
embeddingApiKey: null,
doclingApiKey: '',
visionApiKey: null,
speechApiKey: '',
secondaryApiKey: ''
);
$this->assertSame('encrypted-api-key-existing', $result->getApiKey());
$this->assertSame('encrypted-webhook-existing', $result->getWebhookSecret());
$this->assertSame('encrypted-embedding-existing', $result->getEmbeddingApiKey());
$this->assertSame('encrypted-docling-existing', $result->getDoclingApiKey());
$this->assertSame('encrypted-vision-existing', $result->getVisionApiKey());
$this->assertSame('encrypted-speech-existing', $result->getSpeechApiKey());
$this->assertSame('encrypted-secondary-existing', $result->getSecondaryApiKey());
}
public function testUpdateSettingsPersistsSecondaryEndpointFallbackAndTimeouts(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->willReturnMap([
['api-key', 'encrypted-api-key'],
['secondary-key', 'encrypted-secondary-key'],
]);
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://primary.example.invalid/v1/chat/completions',
defaultModel: 'primary:model-a',
secondaryApiEndpoint: 'https://secondary.example.invalid/v1/chat/completions',
secondaryApiKey: 'secondary-key',
fallbackModel: 'secondary:model-b',
llmChatTimeout: 120,
llmStreamTimeout: 300,
llmModelsTimeout: 30
);
$this->assertSame('https://secondary.example.invalid/v1/chat/completions', $result->getSecondaryApiEndpoint());
$this->assertSame('encrypted-secondary-key', $result->getSecondaryApiKey());
$this->assertSame('secondary:model-b', $result->getFallbackModel());
$this->assertSame(120, $result->getLlmChatTimeout());
$this->assertSame(300, $result->getLlmStreamTimeout());
$this->assertSame(30, $result->getLlmModelsTimeout());
}
public function testSettingsJsonMasksSecondaryApiKeyAndIncludesLlmTimeouts(): void {
$settings = new Settings();
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiKey('encrypted-secondary-key');
$settings->setFallbackModel('secondary:model-b');
$settings->setAppIconUrl('https://example.invalid/educai.svg');
$settings->setLlmChatTimeout(120);
$settings->setLlmStreamTimeout(300);
$settings->setLlmModelsTimeout(30);
$json = $settings->jsonSerialize();
$this->assertSame('https://secondary.example.invalid/v1/chat/completions', $json['secondary_api_endpoint']);
$this->assertSame('***', $json['secondary_api_key']);
$this->assertSame('secondary:model-b', $json['fallback_model']);
$this->assertSame('https://example.invalid/educai.svg', $json['app_icon_url']);
$this->assertSame('default', $json['app_icon_mode']);
$this->assertNull($json['app_icon_black_url']);
$this->assertNull($json['app_icon_white_url']);
$this->assertSame(120, $json['llm_chat_timeout']);
$this->assertSame(300, $json['llm_stream_timeout']);
$this->assertSame(30, $json['llm_models_timeout']);
}
public function testUpdateSettingsPersistsAndClearsAppIconUrl(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->exactly(2))
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->exactly(2))
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->with('api-key')
->willReturn('encrypted-api-key');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconUrl: ' /apps/theming/img/custom-educai.svg '
);
$this->assertSame('/apps/theming/img/custom-educai.svg', $result->getAppIconUrl());
$this->assertSame('custom', $result->getAppIconMode());
$this->assertSame('/apps/theming/img/custom-educai.svg', $result->getAppIconBlackUrl());
$this->assertSame('/apps/theming/img/custom-educai.svg', $result->getAppIconWhiteUrl());
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconUrl: ''
);
$this->assertNull($result->getAppIconUrl());
$this->assertSame('default', $result->getAppIconMode());
$this->assertNull($result->getAppIconBlackUrl());
$this->assertNull($result->getAppIconWhiteUrl());
}
public function testUpdateSettingsPersistsCustomAppIconVariants(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconMode: 'custom',
appIconBlackUrl: ' /apps/theming/img/educai-black.svg ',
appIconWhiteUrl: 'https://example.invalid/educai-white.svg'
);
$this->assertSame('custom', $result->getAppIconMode());
$this->assertNull($result->getAppIconPreset());
$this->assertSame('/apps/theming/img/educai-black.svg', $result->getAppIconBlackUrl());
$this->assertSame('https://example.invalid/educai-white.svg', $result->getAppIconWhiteUrl());
$this->assertSame('/apps/theming/img/educai-black.svg', $result->getAppIconUrl());
}
public function testUpdateSettingsRejectsNextcloudFileAppIconReferences(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->never())
->method('update');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('App icon URL must be an http(s) URL, an absolute Nextcloud path, or an uploaded SVG reference');
$service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconMode: 'custom',
appIconBlackUrl: 'nextcloud-fileid://pkienast/12345',
appIconWhiteUrl: 'educai-upload://white'
);
}
public function testUpdateSettingsPersistsUploadedAppIconReferences(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconMode: 'custom',
appIconBlackUrl: ' educai-upload://black ',
appIconWhiteUrl: 'educai-upload://white'
);
$this->assertSame('custom', $result->getAppIconMode());
$this->assertSame('educai-upload://black', $result->getAppIconBlackUrl());
$this->assertSame('educai-upload://white', $result->getAppIconWhiteUrl());
$this->assertSame('educai-upload://black', $result->getAppIconUrl());
}
public function testUpdateSettingsRejectsCustomAppIconWithoutBothVariants(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->never())
->method('update');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Custom app icon mode requires both black and white icon URLs');
$service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconMode: 'custom',
appIconBlackUrl: '/apps/theming/img/educai-black.svg',
appIconWhiteUrl: ''
);
}
public function testUpdateSettingsRejectsRemovedAppIconPresetMode(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->never())
->method('update');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('App icon mode must be default or custom');
$service->updateSettings(
apiProvider: 'custom',
apiKey: '',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconMode: 'preset',
appIconBlackUrl: '/apps/theming/img/not-real-black.svg',
appIconWhiteUrl: '/apps/theming/img/not-real-white.svg'
);
}
public function testUpdateSettingsRejectsInvalidAppIconUrl(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->never())
->method('update');
$credentialService->method('encrypt')
->with('api-key')
->willReturn('encrypted-api-key');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('App icon URL must be an http(s) URL, an absolute Nextcloud path, or an uploaded SVG reference');
$service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
appIconUrl: 'javascript:alert(1)'
);
}
public function testBlankOptionalFallbackFieldsAreCleared(): void {
$settings = new Settings();
$settings->setEmbeddingApiEndpoint('https://example.invalid/embeddings');
$settings->setEmbeddingModel('embedding-model');
$settings->setDoclingApiEndpoint('https://example.invalid/docling');
$settings->setVisionApiEndpoint('https://example.invalid/vision');
$settings->setVisionModel('vision-model');
$settings->setSpeechApiEndpoint('https://example.invalid/speech');
$settings->setSpeechModel('speech-model');
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->with('api-key')
->willReturn('encrypted-api-key');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
embeddingApiEndpoint: '',
embeddingModel: '',
doclingApiEndpoint: '',
visionApiEndpoint: '',
visionModel: '',
speechApiEndpoint: '',
speechModel: ''
);
$this->assertNull($result->getEmbeddingApiEndpoint());
$this->assertNull($result->getEmbeddingModel());
$this->assertNull($result->getDoclingApiEndpoint());
$this->assertNull($result->getVisionApiEndpoint());
$this->assertNull($result->getVisionModel());
$this->assertNull($result->getSpeechApiEndpoint());
$this->assertNull($result->getSpeechModel());
}
public function testUpdateSettingsPersistsEmbeddingRateLimitModeAndCustomValues(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->with('api-key')
->willReturn('encrypted-api-key');
$talkBotRegistrationService->expects($this->never())
->method('syncRegistration');
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
embeddingRateLimitMode: 'custom',
embeddingRateLimitSecond: 4,
embeddingRateLimitMinute: 120,
embeddingRateLimitHour: 2400,
embeddingRateLimitDay: 4800
);
$this->assertSame('custom', $result->getEmbeddingRateLimitMode());
$this->assertSame(4, $result->getEmbeddingRateLimitSecond());
$this->assertSame(120, $result->getEmbeddingRateLimitMinute());
$this->assertSame(2400, $result->getEmbeddingRateLimitHour());
$this->assertSame(4800, $result->getEmbeddingRateLimitDay());
}
public function testUpdateSettingsStoresDedicatedDoclingApiKeyEncrypted(): void {
$settings = new Settings();
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$talkBotRegistrationService = $this->createMock(TalkBotRegistrationService::class);
$mapper->expects($this->once())
->method('getSettings')
->willReturn($settings);
$mapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (Settings $updated): Settings => $updated);
$credentialService->method('encrypt')
->willReturnMap([
['api-key', 'encrypted-api-key'],
['docling-key', 'encrypted-docling-key'],
]);
$service = new SettingsService(
$mapper,
$credentialService,
$talkBotRegistrationService,
$this->createMock(LoggerInterface::class)
);
$result = $service->updateSettings(
apiProvider: 'custom',
apiKey: 'api-key',
apiEndpoint: 'https://example.invalid/v1',
defaultModel: 'model-a',
doclingEnabled: true,
doclingApiEndpoint: 'https://docling.example.invalid/v1/documents/convert',
doclingApiKey: 'docling-key'
);
$this->assertTrue($result->getDoclingEnabled());
$this->assertSame('https://docling.example.invalid/v1/documents/convert', $result->getDoclingApiEndpoint());
$this->assertSame('encrypted-docling-key', $result->getDoclingApiKey());
}
public function testDoclingConfigPrefersDedicatedApiKeyAndFallsBackToMainKey(): void {
$settings = new Settings();
$settings->setDoclingEnabled(true);
$settings->setDoclingApiEndpoint('https://docling.example.invalid/v1/documents/convert');
$settings->setApiKey('encrypted-api-key');
$settings->setDoclingApiKey('encrypted-docling-key');
$mapper = $this->createMock(SettingsMapper::class);
$credentialService = $this->createMock(CredentialService::class);
$service = new SettingsService(
$mapper,
$credentialService,
$this->createMock(TalkBotRegistrationService::class),
$this->createMock(LoggerInterface::class)
);
$mapper->method('getSettings')->willReturn($settings);
$credentialService->method('decrypt')
->willReturnMap([
['encrypted-api-key', 'api-key'],
['encrypted-docling-key', 'docling-key'],
['', ''],
]);
$config = $service->getDoclingConfig();
$this->assertSame('docling-key', $config['api_key']);
$settings->setDoclingApiKey(null);
$config = $service->getDoclingConfig();
$this->assertSame('api-key', $config['api_key']);
}
}