diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2059e50..edb342d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: - name: Run backend coverage run: | mkdir -p coverage - php -d pcov.directory=lib/Service vendor/bin/phpunit \ + php -d pcov.directory=lib vendor/bin/phpunit \ --configuration tests/phpunit.xml \ --coverage-clover coverage/php-clover.xml \ --coverage-text diff --git a/codecov.yml b/codecov.yml index 56f93dc..1cad411 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,9 +15,7 @@ ignore: - "js/**/*" - "tests/**/*" - "vendor/**/*" - - "lib/AppInfo/**/*" - "lib/Controller/**/*" - - "lib/Preview/**/*" - "src/main.ts" - "src/editor/editor-frame.ts" - "src/editor/editor-page.ts" diff --git a/tests/Unit/AppInfo/ApplicationBootstrapTest.php b/tests/Unit/AppInfo/ApplicationBootstrapTest.php new file mode 100644 index 0000000..1c2809b --- /dev/null +++ b/tests/Unit/AppInfo/ApplicationBootstrapTest.php @@ -0,0 +1,38 @@ +createMock(IRegistrationContext::class); + $context->expects(self::once()) + ->method('registerPreviewProvider') + ->with(ElpxPreviewProvider::class, ElpxPreviewProvider::MIME_REGEX); + + (new Application())->register($context); + } + + public function testBootRegistersTheMainInitScript(): void { + $context = $this->createMock(IBootContext::class); + + (new Application())->boot($context); + + self::assertSame( + [[Application::APP_ID, 'exelearning-main']], + Util::$initScripts, + ); + } +} diff --git a/tests/Unit/Preview/ElpxPreviewProviderTest.php b/tests/Unit/Preview/ElpxPreviewProviderTest.php new file mode 100644 index 0000000..7f73404 --- /dev/null +++ b/tests/Unit/Preview/ElpxPreviewProviderTest.php @@ -0,0 +1,124 @@ +zipEntries = $this->createMock(ZipEntryService::class); + $this->permissions = $this->createMock(PermissionService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->provider = new ElpxPreviewProvider( + $this->zipEntries, + $this->permissions, + $this->logger, + ); + } + + public function testReturnsTheRegisteredMimeRegex(): void { + self::assertSame(ElpxPreviewProvider::MIME_REGEX, $this->provider->getMimeType()); + } + + public function testDelegatesRealFilesToPermissionService(): void { + $file = $this->createMock(File::class); + $this->permissions->expects(self::once()) + ->method('isElpxFile') + ->with($file) + ->willReturn(true); + + self::assertTrue($this->provider->isAvailable($file)); + } + + public function testFallbackFileInfoUsesSupportedExtensions(): void { + $elpx = $this->createMock(FileInfo::class); + $elpx->method('getName')->willReturn('Lesson.ELPX'); + $legacy = $this->createMock(FileInfo::class); + $legacy->method('getName')->willReturn('Legacy.ElP'); + $zip = $this->createMock(FileInfo::class); + $zip->method('getName')->willReturn('archive.zip'); + + self::assertTrue($this->provider->isAvailable($elpx)); + self::assertTrue($this->provider->isAvailable($legacy)); + self::assertFalse($this->provider->isAvailable($zip)); + } + + public function testBuildsThumbnailFromPackageScreenshot(): void { + $file = $this->createMock(File::class); + $this->zipEntries->expects(self::once()) + ->method('readEntry') + ->with($file, 'screenshot.png') + ->willReturn('valid-image-bytes'); + + $image = $this->provider->getThumbnail($file, 320, 180); + + self::assertSame(Image::$lastInstance, $image); + self::assertSame('valid-image-bytes', Image::$lastInstance?->data); + self::assertSame([320, 180], Image::$lastInstance?->scaledTo); + } + + public function testFallsBackWhenScreenshotIsMissingOrInvalid(): void { + $file = $this->createMock(File::class); + $this->zipEntries->expects(self::exactly(2)) + ->method('readEntry') + ->willReturnOnConsecutiveCalls(null, 'invalid'); + + $missing = $this->provider->getThumbnail($file, 200, 100); + self::assertNotNull($missing); + self::assertSame([200, 100], Image::$lastInstance?->scaledTo); + + $invalid = $this->provider->getThumbnail($file, 120, 80); + self::assertNotNull($invalid); + self::assertSame([120, 80], Image::$lastInstance?->scaledTo); + } + + public function testReturnsNullWhenFallbackImageIsInvalid(): void { + $file = $this->createMock(File::class); + $this->zipEntries->method('readEntry')->willReturn(null); + Image::$validOverride = false; + + self::assertNull($this->provider->getThumbnail($file, 100, 100)); + } + + public function testLogsPackageReadErrorsAndUsesFallback(): void { + $file = $this->createMock(File::class); + $file->method('getName')->willReturn('broken.elpx'); + $error = new RuntimeException('broken archive'); + $this->zipEntries->method('readEntry')->willThrowException($error); + $this->logger->expects(self::once()) + ->method('debug') + ->with( + 'eXeLearning preview failed', + self::callback(static function (array $context) use ($error): bool { + return ($context['app'] ?? null) === 'exelearning' + && ($context['exception'] ?? null) === $error + && ($context['file'] ?? null) === 'broken.elpx'; + }), + ); + + self::assertNotNull($this->provider->getThumbnail($file, 64, 64)); + } + + public function testLegacyCroppedThumbnailApiReturnsNull(): void { + $file = $this->createMock(File::class); + + self::assertNull($this->provider->getCroppedThumbnail($file, 100, 100, true)); + } +} diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index c4a1623..66f50f5 100644 --- a/tests/bootstrap-standalone.php +++ b/tests/bootstrap-standalone.php @@ -33,7 +33,9 @@ public function __construct(string $appName, array $urlParams = []) {} if (!interface_exists('OCP\\AppFramework\\Bootstrap\\IBootstrap', false)) { eval(' namespace OCP\\AppFramework\\Bootstrap; - interface IRegistrationContext {} + interface IRegistrationContext { + public function registerPreviewProvider(string $class, string $mimeType): void; + } interface IBootContext { public function getServerContainer(); public function getAppContainer(); } interface IBootstrap { public function register(IRegistrationContext $context): void; @@ -44,11 +46,18 @@ public function boot(IBootContext $context): void; if (!interface_exists('OCP\\IPreview', false)) { eval('namespace OCP; interface IPreview {}'); } -if (!interface_exists('OCP\\Files\\Node', false)) { +if (!interface_exists('OCP\\Files\\FileInfo', false)) { eval(' namespace OCP\\Files; - interface Node { + interface FileInfo { public function getName(); + } + '); +} +if (!interface_exists('OCP\\Files\\Node', false)) { + eval(' + namespace OCP\\Files; + interface Node extends FileInfo { public function getMimeType(); public function getPermissions(); } @@ -94,6 +103,55 @@ public function getUserFolder($userId); if (!class_exists('OCP\\Constants', false)) { eval('namespace OCP; class Constants { public const PERMISSION_READ = 1; }'); } +if (!interface_exists('OCP\\IImage', false)) { + eval('namespace OCP; interface IImage {}'); +} +if (!class_exists('OCP\\Image', false)) { + eval(' + namespace OCP; + class Image implements IImage { + public static ?self $lastInstance = null; + public static ?bool $validOverride = null; + public string $data = ""; + public ?array $scaledTo = null; + public function __construct() { self::$lastInstance = $this; } + public function loadFromData(string $data): void { $this->data = $data; } + public function valid(): bool { return self::$validOverride ?? ($this->data !== "" && $this->data !== "invalid"); } + public function scaleDownToFit(int $maxX, int $maxY): void { $this->scaledTo = [$maxX, $maxY]; } + } + '); +} +if (!interface_exists('OCP\\Preview\\IProviderV2', false)) { + eval('namespace OCP\\Preview; interface IProviderV2 {}'); +} +if (!interface_exists('OCP\\Files\\SimpleFS\\ISimpleFile', false)) { + eval('namespace OCP\\Files\\SimpleFS; interface ISimpleFile {}'); +} +if (!interface_exists('Psr\\Log\\LoggerInterface', false)) { + eval(' + namespace Psr\\Log; + interface LoggerInterface { + public function emergency(string|\\Stringable $message, array $context = []): void; + public function alert(string|\\Stringable $message, array $context = []): void; + public function critical(string|\\Stringable $message, array $context = []): void; + public function error(string|\\Stringable $message, array $context = []): void; + public function warning(string|\\Stringable $message, array $context = []): void; + public function notice(string|\\Stringable $message, array $context = []): void; + public function info(string|\\Stringable $message, array $context = []): void; + public function debug(string|\\Stringable $message, array $context = []): void; + public function log($level, string|\\Stringable $message, array $context = []): void; + } + '); +} if (!class_exists('OCP\\Util', false)) { - eval('namespace OCP; class Util { public static function addInitScript(string $app, string $script): void {} public static function addScript(string $app, string $script): void {} }'); + eval(' + namespace OCP; + class Util { + public static array $initScripts = []; + public static array $scripts = []; + public static function addInitScript(string $app, string $script): void { self::$initScripts[] = [$app, $script]; } + public static function addScript(string $app, string $script): void { self::$scripts[] = [$app, $script]; } + public static function reset(): void { self::$initScripts = []; self::$scripts = []; } + } + '); } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 82661ec..70dcf75 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -14,6 +14,8 @@ + ../lib/AppInfo + ../lib/Preview ../lib/Service