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 = ''; $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 = ''; $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', ''); } 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 ); } }