Files
nc-talk-ai/tests/unit/Service/AppIconServiceTest.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

243 lines
8.9 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\AppIconService;
use PHPUnit\Framework\TestCase;
use OCP\Files\IAppData;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;
class AppIconServiceTest extends TestCase {
public function testDefaultIconsResolvePerContext(): void {
$settings = new Settings();
$service = $this->createService($settings);
$this->assertSame('/apps/educai/img/app-dark.svg', $service->getSettingsSectionIcon());
$this->assertSame('/apps/educai/img/app.svg', $service->getAppNavigationIcon());
$this->assertSame('https://nextcloud.test/apps/educai/img/app-dark.svg', $service->getReferenceIconUrl());
$this->assertSame([
'black' => '/apps/educai/img/app-dark.svg',
'white' => '/apps/educai/img/app.svg',
], $service->getRuntimeIconUrls());
}
public function testCustomIconsResolvePerContext(): void {
$settings = new Settings();
$settings->setAppIconMode('custom');
$settings->setAppIconBlackUrl('/apps/theming/img/educai-black.svg');
$settings->setAppIconWhiteUrl('https://example.invalid/educai-white.svg');
$service = $this->createService($settings);
$this->assertSame('/apps/theming/img/educai-black.svg', $service->getSettingsSectionIcon());
$this->assertSame('https://example.invalid/educai-white.svg', $service->getAppNavigationIcon());
$this->assertSame('https://nextcloud.test/apps/theming/img/educai-black.svg', $service->getReferenceIconUrl());
}
public function testLegacyIconUrlStillResolves(): void {
$settings = new Settings();
$settings->setAppIconUrl('/apps/theming/img/legacy-educai.svg');
$service = $this->createService($settings);
$this->assertSame('/apps/theming/img/legacy-educai.svg', $service->getSettingsSectionIcon());
$this->assertSame('/apps/theming/img/legacy-educai.svg', $service->getAppNavigationIcon());
}
public function testUploadedIconReferenceResolvesThroughAppRoute(): void {
$settings = new Settings();
$settings->setAppIconMode('custom');
$settings->setAppIconBlackUrl('educai-upload://black');
$settings->setAppIconWhiteUrl('educai-upload://white');
$service = $this->createService($settings);
$this->assertSame('/apps/educai/api/v1/app-icon/black', $service->getSettingsSectionIcon());
$this->assertSame('/apps/educai/api/v1/app-icon/white', $service->getAppNavigationIcon());
$this->assertSame('https://nextcloud.test/apps/educai/api/v1/app-icon/black', $service->getReferenceIconUrl());
$this->assertTrue($service->isUploadedIconReference('educai-upload://black'));
$this->assertSame('educai-upload://white', $service->getUploadedIconReference('white'));
}
public function testUploadedIconRoutesIncludeFileMtimeCacheBuster(): void {
$settings = new Settings();
$settings->setAppIconMode('custom');
$settings->setAppIconBlackUrl('educai-upload://black');
$settings->setAppIconWhiteUrl('educai-upload://white');
$blackFile = $this->createMock(ISimpleFile::class);
$blackFile->method('getSize')->willReturn(1024);
$blackFile->method('getMimeType')->willReturn('image/svg+xml');
$blackFile->method('getName')->willReturn('black.svg');
$blackFile->method('getMTime')->willReturn(1719920101);
$whiteFile = $this->createMock(ISimpleFile::class);
$whiteFile->method('getSize')->willReturn(1024);
$whiteFile->method('getMimeType')->willReturn('image/svg+xml');
$whiteFile->method('getName')->willReturn('white.svg');
$whiteFile->method('getMTime')->willReturn(1719920102);
$folder = $this->createMock(ISimpleFolder::class);
$folder->method('getFile')
->willReturnMap([
['black.svg', $blackFile],
['white.svg', $whiteFile],
]);
$appData = $this->createMock(IAppData::class);
$appData->method('getFolder')
->with('app_icons')
->willReturn($folder);
$service = $this->createService($settings, appData: $appData);
$this->assertSame('/apps/educai/api/v1/app-icon/black?v=1719920101', $service->getSettingsSectionIcon());
$this->assertSame('/apps/educai/api/v1/app-icon/white?v=1719920102', $service->getAppNavigationIcon());
$this->assertSame([
'black' => '/apps/educai/api/v1/app-icon/black?v=1719920101',
'white' => '/apps/educai/api/v1/app-icon/white?v=1719920102',
], $service->getRuntimeIconUrls());
}
public function testUnsupportedLegacyFileReferenceFallsBackToBundleIcon(): void {
$settings = new Settings();
$settings->setAppIconMode('custom');
$settings->setAppIconBlackUrl('nextcloud-fileid://pkienast/12345');
$settings->setAppIconWhiteUrl('/apps/theming/img/educai-white.svg');
$service = $this->createService($settings);
$this->assertSame('/apps/educai/img/app-dark.svg', $service->getSettingsSectionIcon());
$this->assertSame('/apps/theming/img/educai-white.svg', $service->getAppNavigationIcon());
}
public function testConfiguredUploadedIconReferenceResolvesSvgFile(): void {
$settings = new Settings();
$settings->setAppIconMode('custom');
$settings->setAppIconBlackUrl('educai-upload://black');
$file = $this->createMock(ISimpleFile::class);
$file->method('getSize')->willReturn(1024);
$file->method('getMimeType')->willReturn('image/svg+xml');
$file->method('getName')->willReturn('black.svg');
$folder = $this->createMock(ISimpleFolder::class);
$folder->expects($this->once())
->method('getFile')
->with('black.svg')
->willReturn($file);
$appData = $this->createMock(IAppData::class);
$appData->expects($this->once())
->method('getFolder')
->with('app_icons')
->willReturn($folder);
$service = $this->createService($settings, appData: $appData);
$this->assertSame($file, $service->getConfiguredIconFile('black'));
}
public function testStoreUploadedIconCreatesNewSvgFile(): void {
$settings = new Settings();
$content = '<svg viewBox="0 0 1 1"></svg>';
$file = $this->createMock(ISimpleFile::class);
$folder = $this->createMock(ISimpleFolder::class);
$folder->expects($this->once())
->method('fileExists')
->with('black.svg')
->willReturn(false);
$folder->expects($this->once())
->method('newFile')
->with('black.svg', $content)
->willReturn($file);
$appData = $this->createMock(IAppData::class);
$appData->expects($this->once())
->method('getFolder')
->with('app_icons')
->willReturn($folder);
$service = $this->createService($settings, appData: $appData);
$this->assertSame($file, $service->storeUploadedIcon('black', $content));
}
public function testStoreUploadedIconOverwritesExistingSvgFile(): void {
$settings = new Settings();
$content = '<svg viewBox="0 0 1 1"></svg>';
$file = $this->createMock(ISimpleFile::class);
$file->expects($this->once())
->method('putContent')
->with($content);
$folder = $this->createMock(ISimpleFolder::class);
$folder->expects($this->once())
->method('fileExists')
->with('white.svg')
->willReturn(true);
$folder->expects($this->once())
->method('getFile')
->with('white.svg')
->willReturn($file);
$folder->expects($this->never())
->method('newFile');
$appData = $this->createMock(IAppData::class);
$appData->expects($this->once())
->method('getFolder')
->with('app_icons')
->willReturn($folder);
$service = $this->createService($settings, appData: $appData);
$this->assertSame($file, $service->storeUploadedIcon('white', $content));
}
public function testStoreUploadedIconRejectsNonSvgContent(): void {
$service = $this->createService(new Settings());
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('App icon upload must be an SVG file up to 1 MB');
$service->storeUploadedIcon('black', '<html></html>');
}
private function createService(Settings $settings, ?IAppData $appData = null): AppIconService {
$settingsMapper = $this->createMock(SettingsMapper::class);
$settingsMapper->method('getSettings')->willReturn($settings);
$appData ??= $this->createMock(IAppData::class);
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('linkToRoute')
->willReturnCallback(static function (string $routeName, array $arguments = []): string {
if ($routeName === 'educai.app_icon.show') {
$url = '/apps/educai/api/v1/app-icon/' . ($arguments['variant'] ?? 'black');
return isset($arguments['v']) ? $url . '?v=' . $arguments['v'] : $url;
}
return '/apps/educai/';
});
$urlGenerator->method('imagePath')
->willReturnCallback(static function (string $app, string $file): string {
return $app === 'core' ? "/core/img/{$file}" : "/apps/{$app}/img/{$file}";
});
$urlGenerator->method('getAbsoluteURL')
->willReturnCallback(static fn (string $url): string => str_starts_with($url, 'http') ? $url : 'https://nextcloud.test' . $url);
$logger = $this->createMock(LoggerInterface::class);
return new AppIconService(
$settingsMapper,
$appData,
$urlGenerator,
$logger
);
}
}