From 38c8c9c0f3b8c0697448bae372a8c2a97eb7b99f Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:03:43 +0100 Subject: [PATCH 1/9] test: extend stubs for app and preview coverage --- tests/bootstrap-standalone.php | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index c4a1623..14f556d 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,38 @@ 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 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 $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 (!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 = []; } + } + '); } From 02872b5c8a4b6413d630b6f443609b76cdae8a1a Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:04:08 +0100 Subject: [PATCH 2/9] test: cover application bootstrap --- .../Unit/AppInfo/ApplicationBootstrapTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/Unit/AppInfo/ApplicationBootstrapTest.php 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, + ); + } +} From 8d991d2d88055b5c7b5213be60b30eaa41ba4519 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:04:10 +0100 Subject: [PATCH 3/9] test: cover preview provider --- .../Unit/Preview/ElpxPreviewProviderTest.php | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/Unit/Preview/ElpxPreviewProviderTest.php diff --git a/tests/Unit/Preview/ElpxPreviewProviderTest.php b/tests/Unit/Preview/ElpxPreviewProviderTest.php new file mode 100644 index 0000000..273b8ae --- /dev/null +++ b/tests/Unit/Preview/ElpxPreviewProviderTest.php @@ -0,0 +1,115 @@ +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 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)); + } +} From 7632188636e2a83a62a090f306465b43dc3f3cbb Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:04:19 +0100 Subject: [PATCH 4/9] test: include app and preview in PHP coverage --- tests/phpunit.xml | 2 ++ 1 file changed, 2 insertions(+) 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 From 223fc9f042cfc536ee086b8db27cf57414459f75 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:04:21 +0100 Subject: [PATCH 5/9] ci: report app and preview coverage --- codecov.yml | 2 -- 1 file changed, 2 deletions(-) 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" From 7ef9ea882a13762a07195235f4865d132bcf0932 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:05:07 +0100 Subject: [PATCH 6/9] test: stub PSR logger for preview tests --- tests/bootstrap-standalone.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index 14f556d..a944bf1 100644 --- a/tests/bootstrap-standalone.php +++ b/tests/bootstrap-standalone.php @@ -126,6 +126,22 @@ public function scaleDownToFit(int $maxX, int $maxY): void { $this->scaledTo = [ 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; From ea8b3e7bd0f0d791da90a2a2a64bb5bfd74cc995 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:06:10 +0100 Subject: [PATCH 7/9] ci: collect coverage for included PHP layers --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f5690861d2db959744815ad457f65231e8579b6c Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:07:32 +0100 Subject: [PATCH 8/9] test: allow image validity override in preview stub --- tests/bootstrap-standalone.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index a944bf1..66f50f5 100644 --- a/tests/bootstrap-standalone.php +++ b/tests/bootstrap-standalone.php @@ -111,11 +111,12 @@ public function getUserFolder($userId); 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 $this->data !== "" && $this->data !== "invalid"; } + public function valid(): bool { return self::$validOverride ?? ($this->data !== "" && $this->data !== "invalid"); } public function scaleDownToFit(int $maxX, int $maxY): void { $this->scaledTo = [$maxX, $maxY]; } } '); From 43a1605bc2ed1481628357a46cdb313fe1a2eebc Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:07:34 +0100 Subject: [PATCH 9/9] test: cover invalid preview fallback --- tests/Unit/Preview/ElpxPreviewProviderTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Unit/Preview/ElpxPreviewProviderTest.php b/tests/Unit/Preview/ElpxPreviewProviderTest.php index 273b8ae..7f73404 100644 --- a/tests/Unit/Preview/ElpxPreviewProviderTest.php +++ b/tests/Unit/Preview/ElpxPreviewProviderTest.php @@ -22,6 +22,7 @@ final class ElpxPreviewProviderTest extends TestCase { protected function setUp(): void { Image::$lastInstance = null; + Image::$validOverride = null; $this->zipEntries = $this->createMock(ZipEntryService::class); $this->permissions = $this->createMock(PermissionService::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -88,6 +89,14 @@ public function testFallsBackWhenScreenshotIsMissingOrInvalid(): void { 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');