From d1c64a94f5299a546b9d0a47a25a83c76913cfd3 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Wed, 31 Dec 2025 10:26:25 -0600 Subject: [PATCH 1/5] updates tests --- .../Cms/Services/Category/CreatorTest.php | 34 +++++++++ .../Cms/Services/Category/UpdaterTest.php | 62 +++++++++++++++++ tests/Unit/Cms/Services/EmailVerifierTest.php | 66 ++++++++++++++++++ tests/Unit/Cms/Services/Event/UpdaterTest.php | 30 ++++++++ .../Services/EventCategory/UpdaterTest.php | 29 ++++++++ tests/Unit/Cms/Services/Page/UpdaterTest.php | 33 +++++++++ .../Cms/Services/PasswordResetterTest.php | 66 ++++++++++++++++++ tests/Unit/Cms/Services/User/CreatorTest.php | 46 +++++++++++++ tests/Unit/Cms/Services/User/UpdaterTest.php | 69 +++++++++++++++++++ 9 files changed, 435 insertions(+) diff --git a/tests/Unit/Cms/Services/Category/CreatorTest.php b/tests/Unit/Cms/Services/Category/CreatorTest.php index 48df159..825bb71 100644 --- a/tests/Unit/Cms/Services/Category/CreatorTest.php +++ b/tests/Unit/Cms/Services/Category/CreatorTest.php @@ -155,4 +155,38 @@ public function testAllowsEmptyDescription(): void $this->assertEquals( '', $result->getDescription() ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + + $creator = new Creator( $categoryRepository ); + + $this->assertInstanceOf( Creator::class, $creator ); + } + + public function testConstructorWithEventEmitter(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $categoryRepository + ->method( 'create' ) + ->willReturnArgument( 0 ); + + // Event emitter should emit CategoryCreatedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\CategoryCreatedEvent::class ) ); + + $creator = new Creator( $categoryRepository, null, $eventEmitter ); + + $dto = $this->createDto( + name: 'Technology', + slug: 'technology' + ); + + $creator->create( $dto ); + } } diff --git a/tests/Unit/Cms/Services/Category/UpdaterTest.php b/tests/Unit/Cms/Services/Category/UpdaterTest.php index c7815e0..4db37b5 100644 --- a/tests/Unit/Cms/Services/Category/UpdaterTest.php +++ b/tests/Unit/Cms/Services/Category/UpdaterTest.php @@ -204,4 +204,66 @@ public function testAllowsEmptyDescription(): void $this->assertEquals( '', $result->getDescription() ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + + $updater = new Updater( $categoryRepository ); + + $this->assertInstanceOf( Updater::class, $updater ); + } + + public function testConstructorWithEventEmitter(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $category = new Category(); + $category->setId( 1 ); + $category->setName( 'Test' ); + + $categoryRepository + ->method( 'findById' ) + ->willReturn( $category ); + + $categoryRepository + ->method( 'update' ); + + // Event emitter should emit CategoryUpdatedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\CategoryUpdatedEvent::class ) ); + + $updater = new Updater( $categoryRepository, null, $eventEmitter ); + + $dto = $this->createDto( + id: 1, + name: 'Updated Name', + slug: 'updated-slug' + ); + + $updater->update( $dto ); + } + + public function testThrowsExceptionWhenCategoryNotFound(): void + { + $this->_mockCategoryRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 999 ) + ->willReturn( null ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Category with ID 999 not found' ); + + $dto = $this->createDto( + id: 999, + name: 'Test', + slug: 'test' + ); + + $this->_updater->update( $dto ); + } } diff --git a/tests/Unit/Cms/Services/EmailVerifierTest.php b/tests/Unit/Cms/Services/EmailVerifierTest.php index dacde4d..bc89de3 100644 --- a/tests/Unit/Cms/Services/EmailVerifierTest.php +++ b/tests/Unit/Cms/Services/EmailVerifierTest.php @@ -342,4 +342,70 @@ public function testCleanupExpiredTokens(): void $this->assertEquals( 5, $result ); } + + public function testVerifyEmailWhenUserNotFound(): void + { + $plainToken = bin2hex( random_bytes( 32 ) ); + $hashedToken = hash( 'sha256', $plainToken ); + $userId = 999; + + // Create mock token + $token = new EmailVerificationToken( $userId, $hashedToken, 60 ); + + // Repository finds token + $this->_tokenRepository + ->expects( $this->once() ) + ->method( 'findByToken' ) + ->with( $hashedToken ) + ->willReturn( $token ); + + // Repository doesn't find user (user was deleted) + $this->_userRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( $userId ) + ->willReturn( null ); + + $result = $this->_manager->verifyEmail( $plainToken ); + + $this->assertFalse( $result ); + } + + public function testConstructorWithCustomRandom(): void + { + $mockRandom = $this->createMock( \Neuron\Core\System\IRandom::class ); + + $mockRandom + ->expects( $this->once() ) + ->method( 'string' ) + ->with( 64, 'hex' ) + ->willReturn( str_repeat( 'a', 64 ) ); + + $user = new User(); + $user->setId( 1 ); + $user->setEmail( 'test@example.com' ); + $user->setUsername( 'testuser' ); + + $tokenRepository = $this->createMock( \Neuron\Cms\Repositories\IEmailVerificationTokenRepository::class ); + $userRepository = $this->createMock( \Neuron\Cms\Repositories\IUserRepository::class ); + + $tokenRepository + ->method( 'deleteByUserId' ); + + $tokenRepository + ->expects( $this->once() ) + ->method( 'create' ) + ->with( $this->isInstanceOf( EmailVerificationToken::class ) ); + + $manager = new EmailVerifier( + $tokenRepository, + $userRepository, + $this->_settings, + $this->_basePath, + $this->_verificationUrl, + $mockRandom + ); + + $manager->sendVerificationEmail( $user ); + } } diff --git a/tests/Unit/Cms/Services/Event/UpdaterTest.php b/tests/Unit/Cms/Services/Event/UpdaterTest.php index dfedfc6..ec6d569 100644 --- a/tests/Unit/Cms/Services/Event/UpdaterTest.php +++ b/tests/Unit/Cms/Services/Event/UpdaterTest.php @@ -412,4 +412,34 @@ public function test_update_changes_status_from_draft_to_published(): void $this->assertEquals( Event::STATUS_PUBLISHED, $event->getStatus() ); } + + public function test_constructor_sets_properties_correctly(): void + { + $eventRepository = $this->createMock( IEventRepository::class ); + $categoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $updater = new Updater( $eventRepository, $categoryRepository ); + + $this->assertInstanceOf( Updater::class, $updater ); + } + + public function test_update_throws_exception_when_event_not_found(): void + { + $this->eventRepository->expects( $this->once() ) + ->method( 'findById' ) + ->with( 999 ) + ->willReturn( null ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Event with ID 999 not found' ); + + $dto = $this->createDto( + id: 999, + title: 'New Title', + startDate: '2025-07-01 14:00:00', + status: Event::STATUS_PUBLISHED + ); + + $this->updater->update( $dto ); + } } diff --git a/tests/Unit/Cms/Services/EventCategory/UpdaterTest.php b/tests/Unit/Cms/Services/EventCategory/UpdaterTest.php index f82b1b7..1e710fe 100644 --- a/tests/Unit/Cms/Services/EventCategory/UpdaterTest.php +++ b/tests/Unit/Cms/Services/EventCategory/UpdaterTest.php @@ -280,4 +280,33 @@ public function test_update_calls_repository_update(): void $this->updater->update( $dto ); } + + public function test_constructor_sets_properties_correctly(): void + { + $categoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $updater = new Updater( $categoryRepository ); + + $this->assertInstanceOf( Updater::class, $updater ); + } + + public function test_update_throws_exception_when_category_not_found(): void + { + $this->categoryRepository->expects( $this->once() ) + ->method( 'findById' ) + ->with( 999 ) + ->willReturn( null ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Category with ID 999 not found' ); + + $dto = $this->createDto( + id: 999, + name: 'Test', + slug: 'test', + color: '#000000' + ); + + $this->updater->update( $dto ); + } } diff --git a/tests/Unit/Cms/Services/Page/UpdaterTest.php b/tests/Unit/Cms/Services/Page/UpdaterTest.php index f100958..97b1c21 100644 --- a/tests/Unit/Cms/Services/Page/UpdaterTest.php +++ b/tests/Unit/Cms/Services/Page/UpdaterTest.php @@ -264,4 +264,37 @@ public function testUpdateWhenRepositoryFails(): void $this->assertInstanceOf( Page::class, $result ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $repository = $this->createMock( IPageRepository::class ); + + $updater = new Updater( $repository ); + + $this->assertInstanceOf( Updater::class, $updater ); + } + + public function testUpdateThrowsExceptionWhenPageNotFound(): void + { + $repository = $this->createMock( IPageRepository::class ); + $updater = new Updater( $repository ); + + $repository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 999 ) + ->willReturn( null ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Page with ID 999 not found' ); + + $dto = $this->createDto( + id: 999, + title: 'Title', + content: '{}', + status: Page::STATUS_DRAFT + ); + + $updater->update( $dto ); + } } diff --git a/tests/Unit/Cms/Services/PasswordResetterTest.php b/tests/Unit/Cms/Services/PasswordResetterTest.php index c29cee8..fb93135 100644 --- a/tests/Unit/Cms/Services/PasswordResetterTest.php +++ b/tests/Unit/Cms/Services/PasswordResetterTest.php @@ -273,4 +273,70 @@ public function testCleanupExpiredTokens(): void $this->assertEquals( 5, $result ); } + + public function testResetPasswordWhenUserNotFound(): void + { + $plainToken = bin2hex( random_bytes( 32 ) ); + $hashedToken = hash( 'sha256', $plainToken ); + $email = 'deleted@example.com'; + + // Create mock token + $token = new PasswordResetToken( $email, $hashedToken, 60 ); + + // Repository finds token + $this->_tokenRepository + ->expects( $this->once() ) + ->method( 'findByToken' ) + ->with( $hashedToken ) + ->willReturn( $token ); + + // User repository doesn't find user (user was deleted) + $this->_userRepository + ->expects( $this->once() ) + ->method( 'findByEmail' ) + ->with( $email ) + ->willReturn( null ); + + $result = $this->_manager->resetPassword( $plainToken, 'NewPassword123!' ); + + $this->assertFalse( $result ); + } + + public function testConstructorWithCustomRandom(): void + { + $mockRandom = $this->createMock( \Neuron\Core\System\IRandom::class ); + + $mockRandom + ->expects( $this->once() ) + ->method( 'string' ) + ->with( 64, 'hex' ) + ->willReturn( str_repeat( 'a', 64 ) ); + + $user = new User(); + $user->setEmail( 'test@example.com' ); + + $this->_userRepository + ->method( 'findByEmail' ) + ->willReturn( $user ); + + $this->_tokenRepository + ->method( 'deleteByEmail' ); + + $this->_tokenRepository + ->expects( $this->once() ) + ->method( 'create' ) + ->with( $this->isInstanceOf( PasswordResetToken::class ) ); + + $manager = new PasswordResetter( + $this->_tokenRepository, + $this->_userRepository, + $this->_passwordHasher, + $this->_settings, + $this->_basePath, + $this->_resetUrl, + $mockRandom + ); + + $manager->requestReset( 'test@example.com' ); + } } diff --git a/tests/Unit/Cms/Services/User/CreatorTest.php b/tests/Unit/Cms/Services/User/CreatorTest.php index 0b1e8ed..043b2b8 100644 --- a/tests/Unit/Cms/Services/User/CreatorTest.php +++ b/tests/Unit/Cms/Services/User/CreatorTest.php @@ -234,4 +234,50 @@ public function testSetsEmailVerified(): void $this->assertTrue( $result->isEmailVerified() ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + $passwordHasher = $this->createMock( PasswordHasher::class ); + + $creator = new Creator( $userRepository, $passwordHasher ); + + $this->assertInstanceOf( Creator::class, $creator ); + } + + public function testConstructorWithEventEmitter(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + $passwordHasher = $this->createMock( PasswordHasher::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $passwordHasher + ->method( 'meetsRequirements' ) + ->willReturn( true ); + + $passwordHasher + ->method( 'hash' ) + ->willReturn( 'hashed_password' ); + + $userRepository + ->method( 'create' ) + ->willReturnArgument( 0 ); + + // Event emitter should emit UserCreatedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\UserCreatedEvent::class ) ); + + $creator = new Creator( $userRepository, $passwordHasher, $eventEmitter ); + + $dto = $this->createDto( + 'testuser', + 'test@example.com', + 'Password123!', + User::ROLE_SUBSCRIBER + ); + + $creator->create( $dto ); + } } diff --git a/tests/Unit/Cms/Services/User/UpdaterTest.php b/tests/Unit/Cms/Services/User/UpdaterTest.php index 787ef41..db1dff0 100644 --- a/tests/Unit/Cms/Services/User/UpdaterTest.php +++ b/tests/Unit/Cms/Services/User/UpdaterTest.php @@ -251,4 +251,73 @@ public function testUpdatesRole(): void $this->assertEquals( User::ROLE_ADMIN, $result->getRole() ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + $passwordHasher = $this->createMock( PasswordHasher::class ); + + $updater = new Updater( $userRepository, $passwordHasher ); + + $this->assertInstanceOf( Updater::class, $updater ); + } + + public function testConstructorWithEventEmitter(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + $passwordHasher = $this->createMock( PasswordHasher::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $user = new User(); + $user->setId( 1 ); + $user->setUsername( 'testuser' ); + $user->setEmail( 'test@example.com' ); + $user->setRole( User::ROLE_SUBSCRIBER ); + $user->setPasswordHash( 'existing_hash' ); + + $userRepository + ->method( 'findById' ) + ->willReturn( $user ); + + $userRepository + ->method( 'update' ); + + // Event emitter should emit UserUpdatedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\UserUpdatedEvent::class ) ); + + $updater = new Updater( $userRepository, $passwordHasher, $eventEmitter ); + + $dto = $this->createDto( + 1, + 'testuser', + 'test@example.com', + User::ROLE_SUBSCRIBER + ); + + $updater->update( $dto ); + } + + public function testThrowsExceptionWhenUserNotFound(): void + { + $this->_mockUserRepository + ->expects( $this->once() ) + ->method( 'findById' ) + ->with( 999 ) + ->willReturn( null ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'User with ID 999 not found' ); + + $dto = $this->createDto( + 999, + 'testuser', + 'test@example.com', + User::ROLE_SUBSCRIBER + ); + + $this->_updater->update( $dto ); + } } From f4c00dc883de4797d22b9f897a5959e6604a95fb Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Wed, 31 Dec 2025 13:24:05 -0600 Subject: [PATCH 2/5] updates tests --- .../Cms/Controllers/Admin/DashboardTest.php | 86 +++- .../Cms/Controllers/Admin/ProfileTest.php | 79 +++ tests/Unit/Cms/Controllers/CalendarTest.php | 316 ++++++++++++ tests/Unit/Cms/Controllers/HomeTest.php | 197 +++++++ .../Cms/Controllers/Member/DashboardTest.php | 99 ++++ .../Cms/Controllers/Member/ProfileTest.php | 79 +++ tests/Unit/Cms/Controllers/PagesTest.php | 243 +++++++++ .../Cms/Services/Category/DeleterTest.php | 36 ++ .../Services/Media/CloudinaryUploaderTest.php | 481 ++++++++++++++++++ tests/Unit/Cms/Services/User/DeleterTest.php | 36 ++ .../Services/Widget/CalendarWidgetTest.php | 238 +++++++++ .../Services/Widget/WidgetRendererTest.php | 29 ++ 12 files changed, 1898 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/Cms/Controllers/Admin/ProfileTest.php create mode 100644 tests/Unit/Cms/Controllers/CalendarTest.php create mode 100644 tests/Unit/Cms/Controllers/HomeTest.php create mode 100644 tests/Unit/Cms/Controllers/Member/DashboardTest.php create mode 100644 tests/Unit/Cms/Controllers/Member/ProfileTest.php create mode 100644 tests/Unit/Cms/Controllers/PagesTest.php diff --git a/tests/Unit/Cms/Controllers/Admin/DashboardTest.php b/tests/Unit/Cms/Controllers/Admin/DashboardTest.php index 02ce741..1d9d75a 100644 --- a/tests/Unit/Cms/Controllers/Admin/DashboardTest.php +++ b/tests/Unit/Cms/Controllers/Admin/DashboardTest.php @@ -1,55 +1,99 @@ createMock( SettingManager::class ); - $mockSettings->method( 'get' )->willReturnCallback( function( $section, $key = null ) { - if( $section === 'views' && $key === 'path' ) return '/tmp/views'; - if( $section === 'cache' && $key === 'enabled' ) return false; - return 'Test Site'; - }); - Registry::getInstance()->set( 'Settings', $mockSettings ); - - // Create mock application - $this->mockApp = $this->createMock( Application::class ); + // Create mock settings + $settings = new Memory(); + $settings->set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + $this->_settingManager = new SettingManager( $settings ); + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + // Create version file + $versionContent = json_encode([ 'major' => 1, 'minor' => 0, 'patch' => 0 ]); + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } } protected function tearDown(): void { - Registry::getInstance()->reset(); + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + Registry::getInstance()->set( 'CsrfToken', null ); + + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + parent::tearDown(); } - public function testConstructorWithApplication(): void + public function testConstructor(): void { - $controller = new Dashboard( $this->mockApp ); + $controller = new Dashboard(); $this->assertInstanceOf( Dashboard::class, $controller ); } - public function testConstructorWithNullApplication(): void + public function testConstructorWithApplication(): void { - $controller = new Dashboard( null ); + $mockApp = $this->createMock( Application::class ); + $controller = new Dashboard( $mockApp ); $this->assertInstanceOf( Dashboard::class, $controller ); } - public function testConstructorExtendsContentController(): void + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testIndexRendersView(): void { - $controller = new Dashboard( $this->mockApp ); - $this->assertInstanceOf( \Neuron\Cms\Controllers\Content::class, $controller ); + // Mock the controller to test view() method chain + $controller = $this->getMockBuilder( Dashboard::class ) + ->onlyMethods( [ 'view' ] ) + ->getMock(); + + // Create a mock ViewContext that supports the fluent interface + $mockViewContext = $this->getMockBuilder( ViewContext::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'title', 'description', 'withCurrentUser', 'withCsrfToken', 'render' ] ) + ->getMock(); + + $mockViewContext->method( 'title' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'description' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'withCurrentUser' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'withCsrfToken' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'render' )->willReturn( 'Dashboard' ); + + $controller->method( 'view' )->willReturn( $mockViewContext ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Dashboard', $result ); } } diff --git a/tests/Unit/Cms/Controllers/Admin/ProfileTest.php b/tests/Unit/Cms/Controllers/Admin/ProfileTest.php new file mode 100644 index 0000000..59fee9c --- /dev/null +++ b/tests/Unit/Cms/Controllers/Admin/ProfileTest.php @@ -0,0 +1,79 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + $this->_settingManager = new SettingManager( $settings ); + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + $versionContent = json_encode([ 'major' => 1, 'minor' => 0, 'patch' => 0 ]); + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + Registry::getInstance()->set( 'CsrfToken', null ); + Registry::getInstance()->set( 'User', null ); + + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + /** + * Note: Admin\Profile controller requires integration testing due to: + * - Global auth() function dependency + * - Global group_timezones_for_select() function dependency + * - Complex DTO handling with YAML configuration + * - Redirect mechanisms that terminate execution + * + * Unit testing these methods would require extensive mocking infrastructure. + * Integration tests are more appropriate for this controller. + */ + public function testConstructorWithDependencies(): void + { + $mockRepository = $this->createMock( IUserRepository::class ); + $mockHasher = $this->createMock( PasswordHasher::class ); + $mockUpdater = $this->createMock( IUserUpdater::class ); + + $controller = new Profile( null, $mockRepository, $mockHasher, $mockUpdater ); + + $this->assertInstanceOf( Profile::class, $controller ); + } +} diff --git a/tests/Unit/Cms/Controllers/CalendarTest.php b/tests/Unit/Cms/Controllers/CalendarTest.php new file mode 100644 index 0000000..6600d25 --- /dev/null +++ b/tests/Unit/Cms/Controllers/CalendarTest.php @@ -0,0 +1,316 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + $this->_settingManager = new SettingManager( $settings ); + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + // Create version file + $versionContent = json_encode([ 'major' => 1, 'minor' => 0, 'patch' => 0 ]); + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + public function testConstructorWithDependencies(): void + { + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $controller = new Calendar( null, $mockEventRepository, $mockCategoryRepository ); + + $this->assertInstanceOf( Calendar::class, $controller ); + } + + public function testIndexRendersCalendarForCurrentMonth(): void + { + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $mockEventRepository->method( 'getByDateRange' )->willReturn( [] ); + $mockCategoryRepository->method( 'all' )->willReturn( [] ); + + $controller = $this->getMockBuilder( Calendar::class ) + ->setConstructorArgs( [ null, $mockEventRepository, $mockCategoryRepository ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + return isset( $data['Title'] ) && + isset( $data['events'] ) && + isset( $data['categories'] ) && + isset( $data['currentMonth'] ) && + isset( $data['currentYear'] ); + } ), + 'index', + 'default' + ) + ->willReturn( 'Calendar' ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Calendar', $result ); + } + + public function testIndexRendersCalendarForSpecificMonth(): void + { + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + // Expect date range for March 2024 + $mockEventRepository->expects( $this->once() ) + ->method( 'getByDateRange' ) + ->with( + $this->callback( function( $date ) { + return $date->format( 'Y-m-d' ) === '2024-03-01'; + } ), + $this->callback( function( $date ) { + return $date->format( 'Y-m-d' ) === '2024-03-31'; + } ), + 'published' + ) + ->willReturn( [] ); + + $mockCategoryRepository->method( 'all' )->willReturn( [] ); + + $controller = $this->getMockBuilder( Calendar::class ) + ->setConstructorArgs( [ null, $mockEventRepository, $mockCategoryRepository ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->method( 'renderHtml' )->willReturn( 'Calendar March 2024' ); + + $request = $this->getMockBuilder( Request::class ) + ->onlyMethods( [ 'get' ] ) + ->getMock(); + $request->method( 'get' ) + ->willReturnCallback( function( $key, $default ) { + return match( $key ) { + 'month' => '3', + 'year' => '2024', + default => $default + }; + } ); + + $result = $controller->index( $request ); + + $this->assertEquals( 'Calendar March 2024', $result ); + } + + public function testShowRendersPublishedEvent(): void + { + $mockEvent = $this->createMock( Event::class ); + $mockEvent->method( 'getTitle' )->willReturn( 'Test Event' ); + $mockEvent->method( 'getDescription' )->willReturn( 'Event description' ); + $mockEvent->method( 'isPublished' )->willReturn( true ); + + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockEventRepository->method( 'findBySlug' )->with( 'test-event' )->willReturn( $mockEvent ); + $mockEventRepository->expects( $this->once() ) + ->method( 'incrementViewCount' ) + ->with( $mockEvent ); + + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $controller = $this->getMockBuilder( Calendar::class ) + ->setConstructorArgs( [ null, $mockEventRepository, $mockCategoryRepository ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) use ( $mockEvent ) { + return $data['event'] === $mockEvent && + isset( $data['Title'] ) && + isset( $data['Description'] ); + } ), + 'show', + 'default' + ) + ->willReturn( 'Event Detail' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'test-event' ] ); + $result = $controller->show( $request ); + + $this->assertEquals( 'Event Detail', $result ); + } + + public function testShowThrowsExceptionForNonexistentEvent(): void + { + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockEventRepository->method( 'findBySlug' )->willReturn( null ); + + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $controller = new Calendar( null, $mockEventRepository, $mockCategoryRepository ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Event not found' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'nonexistent' ] ); + $controller->show( $request ); + } + + public function testShowThrowsExceptionForUnpublishedEvent(): void + { + $mockEvent = $this->createMock( Event::class ); + $mockEvent->method( 'isPublished' )->willReturn( false ); + + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockEventRepository->method( 'findBySlug' )->willReturn( $mockEvent ); + + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $controller = new Calendar( null, $mockEventRepository, $mockCategoryRepository ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Event not found' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'unpublished-event' ] ); + $controller->show( $request ); + } + + public function testCategoryRendersEventsForCategory(): void + { + $mockCategory = $this->createMock( EventCategory::class ); + $mockCategory->method( 'getId' )->willReturn( 5 ); + $mockCategory->method( 'getName' )->willReturn( 'Workshops' ); + + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockEventRepository->method( 'getByCategory' )->with( 5, 'published' )->willReturn( [] ); + + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + $mockCategoryRepository->method( 'findBySlug' )->with( 'workshops' )->willReturn( $mockCategory ); + + $controller = $this->getMockBuilder( Calendar::class ) + ->setConstructorArgs( [ null, $mockEventRepository, $mockCategoryRepository ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) use ( $mockCategory ) { + return $data['category'] === $mockCategory && + isset( $data['events'] ) && + isset( $data['Title'] ) && + isset( $data['Description'] ); + } ), + 'category', + 'default' + ) + ->willReturn( 'Category Events' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'workshops' ] ); + $result = $controller->category( $request ); + + $this->assertEquals( 'Category Events', $result ); + } + + public function testCategoryThrowsExceptionForNonexistentCategory(): void + { + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + $mockCategoryRepository->method( 'findBySlug' )->willReturn( null ); + + $controller = new Calendar( null, $mockEventRepository, $mockCategoryRepository ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Category not found' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'nonexistent' ] ); + $controller->category( $request ); + } + + public function testShowUsesEventTitleWhenDescriptionNotSet(): void + { + $mockEvent = $this->createMock( Event::class ); + $mockEvent->method( 'getTitle' )->willReturn( 'Event Title' ); + $mockEvent->method( 'getDescription' )->willReturn( null ); // No description + $mockEvent->method( 'isPublished' )->willReturn( true ); + + $mockEventRepository = $this->createMock( IEventRepository::class ); + $mockEventRepository->method( 'findBySlug' )->willReturn( $mockEvent ); + $mockEventRepository->method( 'incrementViewCount' ); + + $mockCategoryRepository = $this->createMock( IEventCategoryRepository::class ); + + $controller = $this->getMockBuilder( Calendar::class ) + ->setConstructorArgs( [ null, $mockEventRepository, $mockCategoryRepository ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + // Description should default to event title when null + return $data['Description'] === 'Event Title'; + } ), + 'show', + 'default' + ) + ->willReturn( 'Event' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'test-event' ] ); + $controller->show( $request ); + } +} diff --git a/tests/Unit/Cms/Controllers/HomeTest.php b/tests/Unit/Cms/Controllers/HomeTest.php new file mode 100644 index 0000000..4512e76 --- /dev/null +++ b/tests/Unit/Cms/Controllers/HomeTest.php @@ -0,0 +1,197 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + // Wrap in SettingManager + $this->_settingManager = new SettingManager( $settings ); + + // Store settings in registry + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + // Create version file + $versionContent = json_encode([ + 'major' => 1, + 'minor' => 0, + 'patch' => 0 + ]); + + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + // Clear registry + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + + // Clean up temp version file + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + public function testConstructorWithRegistrationService(): void + { + $mockRegistrationService = $this->createMock( IRegistrationService::class ); + + $controller = new Home( null, $mockRegistrationService ); + + $this->assertInstanceOf( Home::class, $controller ); + } + + public function testConstructorWithoutRegistrationService(): void + { + $controller = new Home(); + + $this->assertInstanceOf( Home::class, $controller ); + } + + public function testIndexWithRegistrationEnabled(): void + { + $mockRegistrationService = $this->createMock( IRegistrationService::class ); + $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( true ); + + // Mock the controller to test renderHtml is called with correct params + $controller = $this->getMockBuilder( Home::class ) + ->setConstructorArgs( [ null, $mockRegistrationService ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + return isset( $data['RegistrationEnabled'] ) && + $data['RegistrationEnabled'] === true && + isset( $data['Title'] ) && + isset( $data['Name'] ) && + isset( $data['Description'] ); + } ), + 'index' + ) + ->willReturn( 'Home Page' ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Home Page', $result ); + } + + public function testIndexWithRegistrationDisabled(): void + { + $mockRegistrationService = $this->createMock( IRegistrationService::class ); + $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( false ); + + $controller = $this->getMockBuilder( Home::class ) + ->setConstructorArgs( [ null, $mockRegistrationService ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + return isset( $data['RegistrationEnabled'] ) && + $data['RegistrationEnabled'] === false; + } ), + 'index' + ) + ->willReturn( 'Home Page' ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Home Page', $result ); + } + + public function testIndexWithoutRegistrationService(): void + { + // No registration service injected + $controller = $this->getMockBuilder( Home::class ) + ->setConstructorArgs( [ null, null ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + // RegistrationEnabled should default to false when service is null + return isset( $data['RegistrationEnabled'] ) && + $data['RegistrationEnabled'] === false; + } ), + 'index' + ) + ->willReturn( 'Home Page' ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Home Page', $result ); + } + + public function testIndexPassesCorrectDataToView(): void + { + $mockRegistrationService = $this->createMock( IRegistrationService::class ); + $mockRegistrationService->method( 'isRegistrationEnabled' )->willReturn( true ); + + $controller = $this->getMockBuilder( Home::class ) + ->setConstructorArgs( [ null, $mockRegistrationService ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + // Verify all required keys are present + return isset( $data['Title'] ) && + isset( $data['Name'] ) && + isset( $data['Description'] ) && + isset( $data['RegistrationEnabled'] ) && + $data['RegistrationEnabled'] === true; + } ), + 'index' + ) + ->willReturn( 'Home Page' ); + + $request = new Request(); + $controller->index( $request ); + } +} diff --git a/tests/Unit/Cms/Controllers/Member/DashboardTest.php b/tests/Unit/Cms/Controllers/Member/DashboardTest.php new file mode 100644 index 0000000..b018f14 --- /dev/null +++ b/tests/Unit/Cms/Controllers/Member/DashboardTest.php @@ -0,0 +1,99 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + $this->_settingManager = new SettingManager( $settings ); + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + // Create version file + $versionContent = json_encode([ 'major' => 1, 'minor' => 0, 'patch' => 0 ]); + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + Registry::getInstance()->set( 'CsrfToken', null ); + + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + public function testConstructor(): void + { + $controller = new Dashboard(); + $this->assertInstanceOf( Dashboard::class, $controller ); + } + + public function testConstructorWithApplication(): void + { + $mockApp = $this->createMock( Application::class ); + $controller = new Dashboard( $mockApp ); + $this->assertInstanceOf( Dashboard::class, $controller ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testIndexRendersView(): void + { + // Mock the controller to test view() method chain + $controller = $this->getMockBuilder( Dashboard::class ) + ->onlyMethods( [ 'view' ] ) + ->getMock(); + + // Create a mock ViewContext that supports the fluent interface + $mockViewContext = $this->getMockBuilder( ViewContext::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'title', 'description', 'withCurrentUser', 'withCsrfToken', 'render' ] ) + ->getMock(); + + $mockViewContext->method( 'title' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'description' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'withCurrentUser' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'withCsrfToken' )->willReturn( $mockViewContext ); + $mockViewContext->method( 'render' )->willReturn( 'Member Dashboard' ); + + $controller->method( 'view' )->willReturn( $mockViewContext ); + + $request = new Request(); + $result = $controller->index( $request ); + + $this->assertEquals( 'Member Dashboard', $result ); + } +} diff --git a/tests/Unit/Cms/Controllers/Member/ProfileTest.php b/tests/Unit/Cms/Controllers/Member/ProfileTest.php new file mode 100644 index 0000000..5e1ad2b --- /dev/null +++ b/tests/Unit/Cms/Controllers/Member/ProfileTest.php @@ -0,0 +1,79 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + $this->_settingManager = new SettingManager( $settings ); + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + $versionContent = json_encode([ 'major' => 1, 'minor' => 0, 'patch' => 0 ]); + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + Registry::getInstance()->set( 'CsrfToken', null ); + Registry::getInstance()->set( 'User', null ); + + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + /** + * Note: Member\Profile controller requires integration testing due to: + * - Global auth() function dependency + * - Global group_timezones_for_select() function dependency + * - Complex DTO handling with YAML configuration + * - Redirect mechanisms that terminate execution + * + * Unit testing these methods would require extensive mocking infrastructure. + * Integration tests are more appropriate for this controller. + */ + public function testConstructorWithDependencies(): void + { + $mockRepository = $this->createMock( IUserRepository::class ); + $mockHasher = $this->createMock( PasswordHasher::class ); + $mockUpdater = $this->createMock( IUserUpdater::class ); + + $controller = new Profile( null, $mockRepository, $mockHasher, $mockUpdater ); + + $this->assertInstanceOf( Profile::class, $controller ); + } +} diff --git a/tests/Unit/Cms/Controllers/PagesTest.php b/tests/Unit/Cms/Controllers/PagesTest.php new file mode 100644 index 0000000..6c9671c --- /dev/null +++ b/tests/Unit/Cms/Controllers/PagesTest.php @@ -0,0 +1,243 @@ +set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); + + // Wrap in SettingManager + $this->_settingManager = new SettingManager( $settings ); + + // Store settings in registry + Registry::getInstance()->set( 'Settings', $this->_settingManager ); + + // Create version file + $versionContent = json_encode([ + 'major' => 1, + 'minor' => 0, + 'patch' => 0 + ]); + + $parentDir = dirname( getcwd() ); + if( !file_exists( $parentDir . '/.version.json' ) ) + { + file_put_contents( $parentDir . '/.version.json', $versionContent ); + } + } + + protected function tearDown(): void + { + // Clear registry + Registry::getInstance()->set( 'Settings', null ); + Registry::getInstance()->set( 'version', null ); + Registry::getInstance()->set( 'name', null ); + Registry::getInstance()->set( 'rss_url', null ); + Registry::getInstance()->set( 'DtoFactoryService', null ); + + // Clean up temp version file + $parentDir = dirname( getcwd() ); + @unlink( $parentDir . '/.version.json' ); + + parent::tearDown(); + } + + public function testConstructorWithDependencies(): void + { + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + + $controller = new Pages( null, $mockPageRepository, $mockRenderer ); + + $this->assertInstanceOf( Pages::class, $controller ); + } + + public function testShowRendersPublishedPage(): void + { + $mockPage = $this->createMock( Page::class ); + $mockPage->method( 'getId' )->willReturn( 1 ); + $mockPage->method( 'getTitle' )->willReturn( 'Test Page' ); + $mockPage->method( 'isPublished' )->willReturn( true ); + $mockPage->method( 'getContent' )->willReturn( [ 'blocks' => [] ] ); + $mockPage->method( 'getMetaTitle' )->willReturn( 'Test Meta Title' ); + $mockPage->method( 'getMetaDescription' )->willReturn( 'Test meta description' ); + $mockPage->method( 'getMetaKeywords' )->willReturn( 'test, keywords' ); + + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockPageRepository->method( 'findBySlug' )->with( 'test-page' )->willReturn( $mockPage ); + $mockPageRepository->expects( $this->once() ) + ->method( 'incrementViewCount' ) + ->with( 1 ); + + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + $mockRenderer->method( 'render' )->willReturn( '

Rendered content

' ); + + $controller = $this->getMockBuilder( Pages::class ) + ->setConstructorArgs( [ null, $mockPageRepository, $mockRenderer ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) use ( $mockPage ) { + return $data['Page'] === $mockPage && + $data['ContentHtml'] === '

Rendered content

' && + isset( $data['Title'] ) && + isset( $data['Description'] ) && + isset( $data['MetaKeywords'] ); + } ), + 'show' + ) + ->willReturn( 'Page content' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'test-page' ] ); + $result = $controller->show( $request ); + + $this->assertEquals( 'Page content', $result ); + } + + public function testShowThrowsNotFoundForNonexistentPage(): void + { + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockPageRepository->method( 'findBySlug' )->with( 'nonexistent' )->willReturn( null ); + + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + + $controller = new Pages( null, $mockPageRepository, $mockRenderer ); + + $this->expectException( NotFound::class ); + $this->expectExceptionMessage( 'Page not found' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'nonexistent' ] ); + $controller->show( $request ); + } + + public function testShowThrowsNotFoundForUnpublishedPage(): void + { + $mockPage = $this->createMock( Page::class ); + $mockPage->method( 'isPublished' )->willReturn( false ); + + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockPageRepository->method( 'findBySlug' )->with( 'unpublished' )->willReturn( $mockPage ); + + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + + $controller = new Pages( null, $mockPageRepository, $mockRenderer ); + + $this->expectException( NotFound::class ); + $this->expectExceptionMessage( 'Page not found' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'unpublished' ] ); + $controller->show( $request ); + } + + public function testShowUsesPageTitleWhenMetaTitleNotSet(): void + { + $mockPage = $this->createMock( Page::class ); + $mockPage->method( 'getId' )->willReturn( 1 ); + $mockPage->method( 'getTitle' )->willReturn( 'Test Page' ); + $mockPage->method( 'isPublished' )->willReturn( true ); + $mockPage->method( 'getContent' )->willReturn( [ 'blocks' => [] ] ); + $mockPage->method( 'getMetaTitle' )->willReturn( '' ); // Empty meta title + $mockPage->method( 'getMetaDescription' )->willReturn( '' ); + $mockPage->method( 'getMetaKeywords' )->willReturn( '' ); + + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockPageRepository->method( 'findBySlug' )->willReturn( $mockPage ); + $mockPageRepository->method( 'incrementViewCount' ); + + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + $mockRenderer->method( 'render' )->willReturn( '

Content

' ); + + $controller = $this->getMockBuilder( Pages::class ) + ->setConstructorArgs( [ null, $mockPageRepository, $mockRenderer ] ) + ->onlyMethods( [ 'renderHtml' ] ) + ->getMock(); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + // Title should use page title when meta title is empty + return str_contains( $data['Title'], 'Test Page' ); + } ), + 'show' + ) + ->willReturn( 'Page' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'test-page' ] ); + $controller->show( $request ); + } + + public function testShowUsesDefaultDescriptionWhenMetaDescriptionNotSet(): void + { + $mockPage = $this->createMock( Page::class ); + $mockPage->method( 'getId' )->willReturn( 1 ); + $mockPage->method( 'getTitle' )->willReturn( 'Test Page' ); + $mockPage->method( 'isPublished' )->willReturn( true ); + $mockPage->method( 'getContent' )->willReturn( [ 'blocks' => [] ] ); + $mockPage->method( 'getMetaTitle' )->willReturn( 'Meta Title' ); + $mockPage->method( 'getMetaDescription' )->willReturn( '' ); // Empty meta description + $mockPage->method( 'getMetaKeywords' )->willReturn( '' ); + + $mockPageRepository = $this->createMock( IPageRepository::class ); + $mockPageRepository->method( 'findBySlug' )->willReturn( $mockPage ); + $mockPageRepository->method( 'incrementViewCount' ); + + $mockRenderer = $this->createMock( EditorJsRenderer::class ); + $mockRenderer->method( 'render' )->willReturn( '

Content

' ); + + $controller = $this->getMockBuilder( Pages::class ) + ->setConstructorArgs( [ null, $mockPageRepository, $mockRenderer ] ) + ->onlyMethods( [ 'renderHtml', 'getDescription' ] ) + ->getMock(); + + $controller->method( 'getDescription' )->willReturn( 'Default Description' ); + + $controller->expects( $this->once() ) + ->method( 'renderHtml' ) + ->with( + $this->anything(), + $this->callback( function( $data ) { + // Description should use default when meta description is empty + return $data['Description'] === 'Default Description'; + } ), + 'show' + ) + ->willReturn( 'Page' ); + + $request = new Request(); + $request->setRouteParameters( [ 'slug' => 'test-page' ] ); + $controller->show( $request ); + } +} diff --git a/tests/Unit/Cms/Services/Category/DeleterTest.php b/tests/Unit/Cms/Services/Category/DeleterTest.php index 0f20732..6d6c685 100644 --- a/tests/Unit/Cms/Services/Category/DeleterTest.php +++ b/tests/Unit/Cms/Services/Category/DeleterTest.php @@ -80,4 +80,40 @@ public function testThrowsExceptionWhenCategoryNotFound(): void $this->_deleter->delete( 99 ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + + $deleter = new Deleter( $categoryRepository ); + + $this->assertInstanceOf( Deleter::class, $deleter ); + } + + public function testConstructorWithEventEmitter(): void + { + $categoryRepository = $this->createMock( ICategoryRepository::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $category = new Category(); + $category->setId( 1 ); + + $categoryRepository + ->method( 'findById' ) + ->willReturn( $category ); + + $categoryRepository + ->method( 'delete' ) + ->willReturn( true ); + + // Event emitter should emit CategoryDeletedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\CategoryDeletedEvent::class ) ); + + $deleter = new Deleter( $categoryRepository, $eventEmitter ); + + $deleter->delete( 1 ); + } } diff --git a/tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php b/tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php index 534b0e6..9e48848 100644 --- a/tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php +++ b/tests/Unit/Cms/Services/Media/CloudinaryUploaderTest.php @@ -396,4 +396,485 @@ public function testListResourcesWithPagination(): void $this->assertNull( $firstPage['next_cursor'] ); } } + + /** + * Unit tests using mocked Cloudinary SDK to test logic without real API calls + */ + + public function testUploadCallsCloudinaryApiCorrectly(): void + { + // Create a temporary test file + $testFile = tempnam( sys_get_temp_dir(), 'test_image_' ); + file_put_contents( $testFile, 'test image content' ); + + try + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + // Set up the mock expectations + $mockCloudinary + ->expects( $this->once() ) + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->expects( $this->once() ) + ->method( 'upload' ) + ->with( + $this->equalTo( $testFile ), + $this->callback( function( $options ) { + return isset( $options['folder'] ) && + $options['folder'] === 'test-folder' && + isset( $options['resource_type'] ) && + $options['resource_type'] === 'image'; + } ) + ) + ->willReturn( [ + 'secure_url' => 'https://res.cloudinary.com/test/image.jpg', + 'public_id' => 'test-folder/image', + 'width' => 800, + 'height' => 600, + 'format' => 'jpg', + 'bytes' => 12345, + 'resource_type' => 'image', + 'created_at' => '2024-01-01T00:00:00Z' + ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test the upload + $result = $uploader->upload( $testFile ); + + // Verify the result format + $this->assertIsArray( $result ); + $this->assertEquals( 'https://res.cloudinary.com/test/image.jpg', $result['url'] ); + $this->assertEquals( 'test-folder/image', $result['public_id'] ); + $this->assertEquals( 800, $result['width'] ); + $this->assertEquals( 600, $result['height'] ); + $this->assertEquals( 'jpg', $result['format'] ); + $this->assertEquals( 12345, $result['bytes'] ); + } + finally + { + // Clean up + if( file_exists( $testFile ) ) + { + unlink( $testFile ); + } + } + } + + public function testUploadWithCustomOptions(): void + { + // Create a temporary test file + $testFile = tempnam( sys_get_temp_dir(), 'test_image_' ); + file_put_contents( $testFile, 'test image content' ); + + try + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->expects( $this->once() ) + ->method( 'upload' ) + ->with( + $this->equalTo( $testFile ), + $this->callback( function( $options ) { + return $options['folder'] === 'custom-folder' && + $options['public_id'] === 'my-image' && + isset( $options['tags'] ) && + in_array( 'test-tag', $options['tags'] ); + } ) + ) + ->willReturn( [ + 'secure_url' => 'https://res.cloudinary.com/test/custom-folder/my-image.jpg', + 'public_id' => 'custom-folder/my-image', + 'width' => 1024, + 'height' => 768, + 'format' => 'jpg' + ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test upload with custom options + $result = $uploader->upload( $testFile, [ + 'folder' => 'custom-folder', + 'public_id' => 'my-image', + 'tags' => [ 'test-tag', 'another-tag' ] + ] ); + + $this->assertEquals( 'custom-folder/my-image', $result['public_id'] ); + } + finally + { + if( file_exists( $testFile ) ) + { + unlink( $testFile ); + } + } + } + + public function testUploadFromUrlCallsCloudinaryApiCorrectly(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->expects( $this->once() ) + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->expects( $this->once() ) + ->method( 'upload' ) + ->with( + $this->equalTo( 'https://example.com/image.jpg' ), + $this->callback( function( $options ) { + return isset( $options['folder'] ) && + $options['folder'] === 'test-folder'; + } ) + ) + ->willReturn( [ + 'secure_url' => 'https://res.cloudinary.com/test/image.jpg', + 'public_id' => 'test-folder/image', + 'width' => 800, + 'height' => 600, + 'format' => 'jpg' + ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test uploadFromUrl + $result = $uploader->uploadFromUrl( 'https://example.com/image.jpg' ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'https://res.cloudinary.com/test/image.jpg', $result['url'] ); + $this->assertEquals( 'test-folder/image', $result['public_id'] ); + } + + public function testDeleteCallsCloudinaryApiCorrectly(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->expects( $this->once() ) + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->expects( $this->once() ) + ->method( 'destroy' ) + ->with( $this->equalTo( 'test-folder/image-to-delete' ) ) + ->willReturn( [ 'result' => 'ok' ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test delete + $result = $uploader->delete( 'test-folder/image-to-delete' ); + + $this->assertTrue( $result ); + } + + public function testDeleteReturnsFalseWhenResultIsNotOk(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->method( 'destroy' ) + ->willReturn( [ 'result' => 'not found' ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test delete with non-ok result + $result = $uploader->delete( 'nonexistent-image' ); + + $this->assertFalse( $result ); + } + + public function testListResourcesCallsCloudinaryApiCorrectly(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockAdminApi = $this->createMock( \Cloudinary\Api\Admin\AdminApi::class ); + + $mockCloudinary + ->expects( $this->once() ) + ->method( 'adminApi' ) + ->willReturn( $mockAdminApi ); + + $mockAdminApi + ->expects( $this->once() ) + ->method( 'assets' ) + ->with( + $this->callback( function( $options ) { + return $options['type'] === 'upload' && + $options['prefix'] === 'test-folder' && + $options['max_results'] === 30 && + $options['resource_type'] === 'image'; + } ) + ) + ->willReturn( [ + 'resources' => [ + [ + 'secure_url' => 'https://res.cloudinary.com/test/image1.jpg', + 'public_id' => 'test-folder/image1', + 'width' => 800, + 'height' => 600, + 'format' => 'jpg', + 'bytes' => 12345, + 'resource_type' => 'image', + 'created_at' => '2024-01-01T00:00:00Z' + ], + [ + 'secure_url' => 'https://res.cloudinary.com/test/image2.jpg', + 'public_id' => 'test-folder/image2', + 'width' => 1024, + 'height' => 768, + 'format' => 'jpg', + 'bytes' => 23456, + 'resource_type' => 'image', + 'created_at' => '2024-01-02T00:00:00Z' + ] + ], + 'next_cursor' => 'abc123', + 'total_count' => 100 + ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test listResources + $result = $uploader->listResources(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'resources', $result ); + $this->assertArrayHasKey( 'next_cursor', $result ); + $this->assertArrayHasKey( 'total_count', $result ); + $this->assertCount( 2, $result['resources'] ); + $this->assertEquals( 'abc123', $result['next_cursor'] ); + $this->assertEquals( 100, $result['total_count'] ); + $this->assertEquals( 'test-folder/image1', $result['resources'][0]['public_id'] ); + } + + public function testListResourcesWithCustomOptionsAndPagination(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockAdminApi = $this->createMock( \Cloudinary\Api\Admin\AdminApi::class ); + + $mockCloudinary + ->method( 'adminApi' ) + ->willReturn( $mockAdminApi ); + + $mockAdminApi + ->expects( $this->once() ) + ->method( 'assets' ) + ->with( + $this->callback( function( $options ) { + return $options['max_results'] === 10 && + $options['next_cursor'] === 'cursor123' && + $options['prefix'] === 'custom-folder'; + } ) + ) + ->willReturn( [ + 'resources' => [], + 'next_cursor' => null, + 'total_count' => 0 + ] ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + // Test listResources with custom options + $result = $uploader->listResources( [ + 'max_results' => 10, + 'next_cursor' => 'cursor123', + 'folder' => 'custom-folder' + ] ); + + $this->assertIsArray( $result ); + $this->assertNull( $result['next_cursor'] ); + } + + public function testUploadThrowsExceptionOnCloudinaryError(): void + { + $testFile = tempnam( sys_get_temp_dir(), 'test_image_' ); + file_put_contents( $testFile, 'test content' ); + + try + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->method( 'upload' ) + ->willThrowException( new \Exception( 'Cloudinary API error' ) ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Cloudinary upload failed' ); + + $uploader->upload( $testFile ); + } + finally + { + if( file_exists( $testFile ) ) + { + unlink( $testFile ); + } + } + } + + public function testUploadFromUrlThrowsExceptionOnCloudinaryError(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->method( 'upload' ) + ->willThrowException( new \Exception( 'Invalid image format' ) ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Cloudinary upload from URL failed' ); + + $uploader->uploadFromUrl( 'https://example.com/image.jpg' ); + } + + public function testDeleteThrowsExceptionOnCloudinaryError(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockUploadApi = $this->createMock( \Cloudinary\Api\Upload\UploadApi::class ); + + $mockCloudinary + ->method( 'uploadApi' ) + ->willReturn( $mockUploadApi ); + + $mockUploadApi + ->method( 'destroy' ) + ->willThrowException( new \Exception( 'API error' ) ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Cloudinary deletion failed' ); + + $uploader->delete( 'test-image' ); + } + + public function testListResourcesThrowsExceptionOnCloudinaryError(): void + { + $uploader = new CloudinaryUploader( $this->_settings ); + + // Mock the Cloudinary instance + $mockCloudinary = $this->createMock( \Cloudinary\Cloudinary::class ); + $mockAdminApi = $this->createMock( \Cloudinary\Api\Admin\AdminApi::class ); + + $mockCloudinary + ->method( 'adminApi' ) + ->willReturn( $mockAdminApi ); + + $mockAdminApi + ->method( 'assets' ) + ->willThrowException( new \Exception( 'Authentication failed' ) ); + + // Use reflection to inject the mock + $reflection = new \ReflectionClass( $uploader ); + $property = $reflection->getProperty( '_cloudinary' ); + $property->setAccessible( true ); + $property->setValue( $uploader, $mockCloudinary ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Cloudinary list resources failed' ); + + $uploader->listResources(); + } } diff --git a/tests/Unit/Cms/Services/User/DeleterTest.php b/tests/Unit/Cms/Services/User/DeleterTest.php index b339cb5..04cb3f3 100644 --- a/tests/Unit/Cms/Services/User/DeleterTest.php +++ b/tests/Unit/Cms/Services/User/DeleterTest.php @@ -80,4 +80,40 @@ public function testThrowsExceptionWhenUserNotFound(): void $this->_deleter->delete( 99 ); } + + public function testConstructorSetsPropertiesCorrectly(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + + $deleter = new Deleter( $userRepository ); + + $this->assertInstanceOf( Deleter::class, $deleter ); + } + + public function testConstructorWithEventEmitter(): void + { + $userRepository = $this->createMock( IUserRepository::class ); + $eventEmitter = $this->createMock( \Neuron\Events\Emitter::class ); + + $user = new User(); + $user->setId( 1 ); + + $userRepository + ->method( 'findById' ) + ->willReturn( $user ); + + $userRepository + ->method( 'delete' ) + ->willReturn( true ); + + // Event emitter should emit UserDeletedEvent + $eventEmitter + ->expects( $this->once() ) + ->method( 'emit' ) + ->with( $this->isInstanceOf( \Neuron\Cms\Events\UserDeletedEvent::class ) ); + + $deleter = new Deleter( $userRepository, $eventEmitter ); + + $deleter->delete( 1 ); + } } diff --git a/tests/Unit/Cms/Services/Widget/CalendarWidgetTest.php b/tests/Unit/Cms/Services/Widget/CalendarWidgetTest.php index c5b0111..07cb137 100644 --- a/tests/Unit/Cms/Services/Widget/CalendarWidgetTest.php +++ b/tests/Unit/Cms/Services/Widget/CalendarWidgetTest.php @@ -113,4 +113,242 @@ public function test_limit_defaults_to_5(): void $this->widget->render( [] ); } + + public function testGetNameReturnsCalendar(): void + { + $this->assertEquals( 'calendar', $this->widget->getName() ); + } + + public function testGetDescriptionReturnsString(): void + { + $description = $this->widget->getDescription(); + $this->assertIsString( $description ); + $this->assertNotEmpty( $description ); + $this->assertEquals( 'Display a list of calendar events', $description ); + } + + public function testGetAttributesReturnsExpectedStructure(): void + { + $attributes = $this->widget->getAttributes(); + + $this->assertIsArray( $attributes ); + $this->assertArrayHasKey( 'category', $attributes ); + $this->assertArrayHasKey( 'limit', $attributes ); + $this->assertArrayHasKey( 'upcoming', $attributes ); + + $this->assertIsString( $attributes['category'] ); + $this->assertIsString( $attributes['limit'] ); + $this->assertIsString( $attributes['upcoming'] ); + } + + public function testRenderWithNoEventsReturnsNoEventsMessage(): void + { + $this->eventRepository->expects( $this->once() ) + ->method( 'getUpcoming' ) + ->willReturn( [] ); + + $result = $this->widget->render( [] ); + + $this->assertStringContainsString( 'No events found', $result ); + $this->assertStringContainsString( 'calendar-widget', $result ); + } + + public function testRenderWithCategoryNotFoundReturnsComment(): void + { + $this->categoryRepository->expects( $this->once() ) + ->method( 'findBySlug' ) + ->with( 'nonexistent' ) + ->willReturn( null ); + + $result = $this->widget->render( [ 'category' => 'nonexistent' ] ); + + $this->assertStringContainsString( '', $result ); + } + + public function testRenderWithValidCategoryFiltersEvents(): void + { + $category = $this->createMock( \Neuron\Cms\Models\EventCategory::class ); + $category->method( 'getId' )->willReturn( 5 ); + + $this->categoryRepository->expects( $this->once() ) + ->method( 'findBySlug' ) + ->with( 'tech-events' ) + ->willReturn( $category ); + + $this->eventRepository->expects( $this->once() ) + ->method( 'getByCategory' ) + ->with( 5, 'published' ) + ->willReturn( [] ); + + $this->widget->render( [ 'category' => 'tech-events' ] ); + } + + public function testRenderWithCategoryRespectsLimit(): void + { + $category = $this->createMock( \Neuron\Cms\Models\EventCategory::class ); + $category->method( 'getId' )->willReturn( 5 ); + + $this->categoryRepository->expects( $this->once() ) + ->method( 'findBySlug' ) + ->with( 'tech-events' ) + ->willReturn( $category ); + + // Create more events than the limit + $events = []; + for( $i = 1; $i <= 10; $i++ ) + { + $event = $this->createMock( \Neuron\Cms\Models\Event::class ); + $event->method( 'getTitle' )->willReturn( "Event $i" ); + $event->method( 'getSlug' )->willReturn( "event-$i" ); + $event->method( 'getStartDate' )->willReturn( new \DateTimeImmutable( '2024-01-01' ) ); + $event->method( 'isAllDay' )->willReturn( true ); + $event->method( 'getLocation' )->willReturn( null ); + $events[] = $event; + } + + $this->eventRepository->expects( $this->once() ) + ->method( 'getByCategory' ) + ->with( 5, 'published' ) + ->willReturn( $events ); + + $result = $this->widget->render( [ 'category' => 'tech-events', 'limit' => 3 ] ); + + // Should only show 3 events + $this->assertStringContainsString( 'Event 1', $result ); + $this->assertStringContainsString( 'Event 2', $result ); + $this->assertStringContainsString( 'Event 3', $result ); + $this->assertStringNotContainsString( 'Event 4', $result ); + } + + public function testRenderWithEventsGeneratesHtmlStructure(): void + { + $event = $this->createMock( \Neuron\Cms\Models\Event::class ); + $event->method( 'getTitle' )->willReturn( 'Test Event' ); + $event->method( 'getSlug' )->willReturn( 'test-event' ); + $event->method( 'getStartDate' )->willReturn( new \DateTimeImmutable( '2024-01-15 14:30:00' ) ); + $event->method( 'isAllDay' )->willReturn( false ); + $event->method( 'getLocation' )->willReturn( 'Conference Room A' ); + + $this->eventRepository->expects( $this->once() ) + ->method( 'getUpcoming' ) + ->willReturn( [ $event ] ); + + $result = $this->widget->render( [] ); + + // Check HTML structure + $this->assertStringContainsString( '', $result ); + } + + public function testRenderWithAllDayEventDoesNotShowTime(): void + { + $event = $this->createMock( \Neuron\Cms\Models\Event::class ); + $event->method( 'getTitle' )->willReturn( 'All Day Event' ); + $event->method( 'getSlug' )->willReturn( 'all-day' ); + $event->method( 'getStartDate' )->willReturn( new \DateTimeImmutable( '2024-01-15 00:00:00' ) ); + $event->method( 'isAllDay' )->willReturn( true ); + $event->method( 'getLocation' )->willReturn( null ); + + $this->eventRepository->expects( $this->once() ) + ->method( 'getUpcoming' ) + ->willReturn( [ $event ] ); + + $result = $this->widget->render( [] ); + + $this->assertStringContainsString( 'January 15, 2024', $result ); + $this->assertStringNotContainsString( ' at ', $result ); + $this->assertStringNotContainsString( 'AM', $result ); + $this->assertStringNotContainsString( 'PM', $result ); + } + + public function testRenderWithEventWithoutLocationDoesNotShowLocationSpan(): void + { + $event = $this->createMock( \Neuron\Cms\Models\Event::class ); + $event->method( 'getTitle' )->willReturn( 'Online Event' ); + $event->method( 'getSlug' )->willReturn( 'online-event' ); + $event->method( 'getStartDate' )->willReturn( new \DateTimeImmutable( '2024-01-15' ) ); + $event->method( 'isAllDay' )->willReturn( true ); + $event->method( 'getLocation' )->willReturn( null ); + + $this->eventRepository->expects( $this->once() ) + ->method( 'getUpcoming' ) + ->willReturn( [ $event ] ); + + $result = $this->widget->render( [] ); + + $this->assertStringNotContainsString( '', $result ); + } + + public function testRenderEscapesHtmlInEventData(): void + { + $event = $this->createMock( \Neuron\Cms\Models\Event::class ); + $event->method( 'getTitle' )->willReturn( '' ); + $event->method( 'getSlug' )->willReturn( 'safe-slug' ); + $event->method( 'getStartDate' )->willReturn( new \DateTimeImmutable( '2024-01-15' ) ); + $event->method( 'isAllDay' )->willReturn( true ); + $event->method( 'getLocation' )->willReturn( '' ); + + $this->eventRepository->expects( $this->once() ) + ->method( 'getUpcoming' ) + ->willReturn( [ $event ] ); + + $result = $this->widget->render( [] ); + + // HTML should be escaped + $this->assertStringNotContainsString( '