diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9ee7408 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +jobs: + php-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: json, xml, mbstring + coverage: none + + - name: Install dependencies + working-directory: ./nc_bot_webhooks + run: composer install --prefer-dist + + - name: Lint PHP files + working-directory: ./nc_bot_webhooks + run: composer lint + + - name: Run tests + working-directory: ./nc_bot_webhooks + run: composer test diff --git a/composer.json b/composer.json index f6a5374..db471aa 100644 --- a/composer.json +++ b/composer.json @@ -6,5 +6,12 @@ "psr-4": { "OCA\\Ncbotwebhooks\\": "../lib/" } + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "scripts": { + "test": "phpunit --configuration phpunit.xml.dist", + "lint": "find lib -name '*.php' -exec php -l {} \\;" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2eb3efb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + + lib + + + diff --git a/tests/DebugToggleTest.php b/tests/DebugToggleTest.php new file mode 100644 index 0000000..6685955 --- /dev/null +++ b/tests/DebugToggleTest.php @@ -0,0 +1,165 @@ +createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(false); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput(['--status' => true]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('DISABLED', $output->fetch()); + } + + public function testStatusReturnsEnabledWhenSet(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(true); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput(['--status' => true]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('ENABLED', $output->fetch()); + } + + // ========================================================================= + // --enable + // ========================================================================= + + public function testEnableSetsValueToTrue(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(false); + $appConfig->expects($this->once()) + ->method('setValueBool') + ->with(self::APP_ID, self::DEBUG_KEY, true); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput(['--enable' => true]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('ENABLED', $output->fetch()); + } + + // ========================================================================= + // --disable + // ========================================================================= + + public function testDisableSetsValueToFalse(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(true); + $appConfig->expects($this->once()) + ->method('setValueBool') + ->with(self::APP_ID, self::DEBUG_KEY, false); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput(['--disable' => true]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('DISABLED', $output->fetch()); + } + + // ========================================================================= + // Toggle (no flags) + // ========================================================================= + + public function testToggleEnablesWhenDisabled(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(false); + $appConfig->expects($this->once()) + ->method('setValueBool') + ->with(self::APP_ID, self::DEBUG_KEY, true); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('enabled', $output->fetch()); + } + + public function testToggleDisablesWhenEnabled(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(true); + $appConfig->expects($this->once()) + ->method('setValueBool') + ->with(self::APP_ID, self::DEBUG_KEY, false); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(0, $result); + $this->assertStringContainsString('disabled', $output->fetch()); + } + + // ========================================================================= + // Conflict: --enable + --disable + // ========================================================================= + + public function testEnableAndDisableTogetherReturnsInvalid(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(false); + // Should NOT be called when both flags are set + $appConfig->expects($this->never()) + ->method('setValueBool'); + + $command = $this->makeCommand($appConfig); + $input = new ArrayInput(['--enable' => true, '--disable' => true]); + $output = new BufferedOutput(); + + $result = $command->run($input, $output); + + $this->assertEquals(1, $result); // Command::INVALID + $this->assertStringContainsString('Cannot use both', $output->fetch()); + } +} diff --git a/tests/ImageCleanupTest.php b/tests/ImageCleanupTest.php new file mode 100644 index 0000000..5916151 --- /dev/null +++ b/tests/ImageCleanupTest.php @@ -0,0 +1,212 @@ +createMock(TalkService::class); + $userManager = $this->createMock(IUserManager::class); + $userManager->method('get') + ->willReturn(null); // no bot user + + $logger = $this->createMock(LoggerInterface::class); + // Should NOT log anything since we exit early + $logger->expects($this->never()) + ->method('info'); + + $rootFolder = $this->createMock(IRootFolder::class); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(); // should not throw + } + + public function testRunReturnsEarlyWhenNoImagesDirectory(): void { + $talkService = $this->createMock(TalkService::class); + $userManager = $this->createMock(IUserManager::class); + + $botUser = $this->createMock(\OCP\IUser::class); + $botUser->method('getUID') + ->willReturn('talk-bot'); + $userManager->method('get') + ->willReturn($botUser); + + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder') + ->willReturnCallback(function () { + $folder = $this->createMock(\OCP\Files\Folder::class); + $folder->expects($this->once()) + ->method('get') + ->willThrowException(new NotFoundException()); + return $folder; + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never()) + ->method('info'); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(); // should not throw + } + + public function testRunReturnsEarlyOnRootFolderException(): void { + $talkService = $this->createMock(TalkService::class); + $userManager = $this->createMock(IUserManager::class); + + $botUser = $this->createMock(\OCP\IUser::class); + $botUser->method('getUID') + ->willReturn('talk-bot'); + $userManager->method('get') + ->willReturn($botUser); + + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder') + ->willThrowException(new \Exception('filesystem error')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never()) + ->method('info'); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(); // should not throw + } + + // ========================================================================= + // Success paths + // ========================================================================= + + public function testRunPurgesOldImagesAndLogsCount(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('purgeOldImages') + ->willReturn(5); // purged 5 files + + $userManager = $this->createMock(IUserManager::class); + + $botUser = $this->createMock(\OCP\IUser::class); + $botUser->method('getUID') + ->willReturn('talk-bot'); + $userManager->method('get') + ->willReturn($botUser); + + $rootFolder = $this->createMock(IRootFolder::class); + $imagesFolder = $this->createMock(\OCP\Files\Folder::class); + $userFolder = $this->createMock(\OCP\Files\Folder::class); + + $userFolder->method('get') + ->willReturn($imagesFolder); + + $rootFolder->method('getUserFolder') + ->willReturn($userFolder); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with( + 'nc_bot_webhooks: purged 5 old image files', + ['app' => 'nc_bot_webhooks'], + ); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(); + } + + public function testRunDoesNotLogWhenNoFilesPurged(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('purgeOldImages') + ->willReturn(0); // no files purged + + $userManager = $this->createMock(IUserManager::class); + + $botUser = $this->createMock(\OCP\IUser::class); + $botUser->method('getUID') + ->willReturn('talk-bot'); + $userManager->method('get') + ->willReturn($botUser); + + $rootFolder = $this->createMock(IRootFolder::class); + $imagesFolder = $this->createMock(\OCP\Files\Folder::class); + $userFolder = $this->createMock(\OCP\Files\Folder::class); + + $userFolder->method('get') + ->willReturn($imagesFolder); + + $rootFolder->method('getUserFolder') + ->willReturn($userFolder); + + $logger = $this->createMock(LoggerInterface::class); + // Should NOT log when count is 0 + $logger->expects($this->never()) + ->method('info'); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(); + } + + // ========================================================================= + // Edge cases + // ========================================================================= + + public function testRunWithNullArgument(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('purgeOldImages') + ->willReturn(3); + + $userManager = $this->createMock(IUserManager::class); + + $botUser = $this->createMock(\OCP\IUser::class); + $botUser->method('getUID') + ->willReturn('talk-bot'); + $userManager->method('get') + ->willReturn($botUser); + + $rootFolder = $this->createMock(IRootFolder::class); + $imagesFolder = $this->createMock(\OCP\Files\Folder::class); + $userFolder = $this->createMock(\OCP\Files\Folder::class); + + $userFolder->method('get') + ->willReturn($imagesFolder); + + $rootFolder->method('getUserFolder') + ->willReturn($userFolder); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info'); + + $cleanup = $this->makeImageCleanup($talkService, $rootFolder, $userManager, $logger); + $cleanup->run(null); // null argument should work the same as no argument + } +} diff --git a/tests/NavigationProviderTest.php b/tests/NavigationProviderTest.php new file mode 100644 index 0000000..9839759 --- /dev/null +++ b/tests/NavigationProviderTest.php @@ -0,0 +1,114 @@ +createMock(IURLGenerator::class); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser') + ->willReturn(null); + $config = $this->createMock(IConfig::class); + $l10n = $this->createMock(IL10N::class); + + $provider = $this->makeNavigationProvider($urlGenerator, $userSession, $config, $l10n); + $nav = $provider->getNavigation(); + + $this->assertEquals([], $nav); + } + + public function testGetNavigationReturnsEmptyForNonAdminUser(): void { + $urlGenerator = $this->createMock(IURLGenerator::class); + $userSession = $this->createMock(IUserSession::class); + + $user = $this->createMock(IUser::class); + $user->method('isAdmin') + ->willReturn(false); + $userSession->method('getUser') + ->willReturn($user); + + $config = $this->createMock(IConfig::class); + $l10n = $this->createMock(IL10N::class); + + $provider = $this->makeNavigationProvider($urlGenerator, $userSession, $config, $l10n); + $nav = $provider->getNavigation(); + + $this->assertEquals([], $nav); + } + + // ========================================================================= + // Admin path + // ========================================================================= + + public function testGetNavigationReturnsLinkForAdminUser(): void { + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRoute') + ->willReturn('https://example.com/settings/admin'); + $urlGenerator->method('imagePath') + ->willReturn('https://example.com/apps/nc_bot_webhooks/app.svg'); + + $userSession = $this->createMock(IUserSession::class); + + $user = $this->createMock(IUser::class); + $user->method('isAdmin') + ->willReturn(true); + $userSession->method('getUser') + ->willReturn($user); + + $config = $this->createMock(IConfig::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(function ($text) { + return $text; + }); + + $provider = $this->makeNavigationProvider($urlGenerator, $userSession, $config, $l10n); + $nav = $provider->getNavigation(); + + $this->assertNotEmpty($nav); + $this->assertCount(1, $nav); + + $item = $nav[0]; + $this->assertEquals('nc_bot_webhooks', $item['id']); + $this->assertEquals('nc_bot_webhooks', $item['app_id']); + $this->assertEquals('settings', $item['type']); + $this->assertEquals('https://example.com/settings/admin', $item['href']); + $this->assertEquals('https://example.com/apps/nc_bot_webhooks/app.svg', $item['icon']); + $this->assertEquals(0, $item['order']); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e068ffd --- /dev/null +++ b/tests/README.md @@ -0,0 +1,118 @@ +# nc_bot_webhooks Test Suite + +## Running Tests Locally + +```bash +cd nc_bot_webhooks +composer install +composer test +``` + +## Requirements + +- PHP 8.2+ +- Composer + +## What Each Test File Covers + +### `TalkServiceTest.php` + +Unit tests for `Service/TalkService.php` business logic. All TalkService constructor dependencies are mocked via `PHPUnit\Framework\TestCase::getMockForTrait()`. + +**Test groups:** + +- **validateBotPassword** — empty string, valid password, special chars, unicode +- **getBaseUrl** — overwritehost priority, overwritewebroot, trusted_domains filtering, CLI URL fallback, empty config +- **getRooms / setRooms** — JSON persistence round-trip, empty config, invalid JSON +- **getAuthTokens / setAuthTokens** — JSON persistence, empty config +- **validateAuthToken** — valid token, invalid token, nonexistent room, multiple tokens +- **generateAuthToken** — 48-char hex token, uniqueness across calls +- **revokeAuthToken** — removes specific token, cleans empty room arrays, nonexistent token +- **mapPayload** — content only, embeds with title/description/fields, empty payload, multiple embeds +- **mapApprisePayload** — body only, title fallback, type icons, image type with attachments, nested data key +- **getSenderName** — sender_name > username > config default priority +- **getSenderNameDefault** — config default +- **prependDisplayName** — with/without message, with type icon +- **downloadImage** — successful download, empty body +- **purgeOldImages** — files older/newer than cutoff, empty directory, bot user not found + +### `WebhookControllerTest.php` + +Unit tests for `Controller/WebhookController.php` HTTP endpoints. Tests response codes and headers for the controller methods that don't depend on `php://input` (auth validation and room listing), plus fallback paths that use `$_GET` and `$_POST` directly. + +**Test groups:** + +- **receive** — invalid auth token (401), empty body (400), $_GET fallback with content, $_GET fallback with embeds +- **receiveApprise** — invalid auth token (401), empty body (400), $_POST fallback with notifications, $_POST with image type, $_GET fallback with notifications +- **receiveAppriseNotify** — delegates to receiveApprise (401) +- **saveConfig** — error handling, invalid config (400), success with auth_tokens (200) +- **saveBotPassword** — invalid password (400), valid password (200) +- **getRooms** — empty rooms, room list with configured flag, exception → 500 +- **debug** — disabled (403), enabled for admin (200), enabled for non-admin (200) + +### `ImageCleanupTest.php` + +Unit tests for `Cron/ImageCleanup.php` cron job. Tests the full run() flow including early-exit paths and success logging. + +**Test groups:** + +- **Early exits** — no bot user, no images directory, root folder exception +- **Success** — purge runs and logs count, no log when count is 0 +- **Edge cases** — null argument handling + +### `NavigationProviderTest.php` + +Unit tests for `NavigationProvider` settings link. Tests that the navigation link is only shown to admin users. + +**Test groups:** + +- **Non-admin** — null user, non-admin user → empty array +- **Admin** — admin user → navigation link with correct metadata + +### `DebugToggleTest.php` + +Unit tests for `Command/DebugToggle.php` OCC command. Tests --status, --enable, --disable, toggle, and --enable/--disable conflict. + +**Test groups:** + +- **--status** — disabled by default, enabled when set +- **--enable** — sets value to true, logs warning +- **--disable** — sets value to false, logs success +- **Toggle** — enables when disabled, disables when enabled +- **Conflict** — --enable + --disable together returns INVALID (1) + +## Adding New Tests + +1. Determine which service or controller the new code belongs to. +2. Add test methods to the appropriate test file. +3. Use `makeTalkServiceMock()` (WebhookControllerTest) or the helper methods in TalkServiceTest to set up mocks. +4. Run `composer test` to verify. + +## Test Architecture + +- **TalkService dependencies**: All 17 constructor parameters are mocked using PHPUnit's `getMockForTrait()` for abstract/interface types and `createMock()` for concrete classes. +- **WebhookController dependencies**: TalkService is mocked; the other 9 dependencies use `createMock()`. +- **ImageCleanup dependencies**: TalkService, IRootFolder, IUserManager, LoggerInterface — all mocked. +- **NavigationProvider dependencies**: IURLGenerator, IUserSession, IConfig, IL10N — all mocked. +- **DebugToggle dependencies**: IAppConfig — mocked; Symfony Console Input/Output used for CLI interaction. +- **No real Nextcloud instance**: Tests verify business logic in isolation. Image upload/download tests verify the download logic but not real file storage. +- **php://input**: The controller reads from `php://input` which cannot be mocked directly. Tests cover: + - Auth-failure path (no input needed) + - `$_GET` fallback for receive (simulated via `$_GET` in test setup) + - `$_POST` fallback for receiveApprise (simulated via `$_POST` in test setup) + - Payload parsing / mapping logic is covered by TalkServiceTest. + +## CI + +Tests run automatically on every push and PR via Gitea Actions. See `.gitea/workflows/ci.yml`. + +## Test Summary + +| File | Tests | Coverage | +|------|-------|----------| +| TalkServiceTest.php | 48 | All TalkService methods | +| WebhookControllerTest.php | 21 | All controller endpoints + fallbacks | +| ImageCleanupTest.php | 6 | All run() paths | +| NavigationProviderTest.php | 3 | Admin/non-admin navigation | +| DebugToggleTest.php | 7 | --status, --enable, --disable, toggle, conflict | +| **Total** | **85** | All lib/ components covered | diff --git a/tests/TalkServiceTest.php b/tests/TalkServiceTest.php new file mode 100644 index 0000000..0c104cc --- /dev/null +++ b/tests/TalkServiceTest.php @@ -0,0 +1,987 @@ +config; // the config mock — we swap it below + + // We need direct access to the crypto mock to configure encrypt/decrypt + // TalkServiceMockBuilder stores them as properties; we re-construct. + $cryptoMock = $this->createMock(\OCP\Security\ICrypto::class); + $cryptoMock->method('encrypt') + ->willReturnCallback(fn($s) => 'encrypted:' . $s); + $cryptoMock->method('decrypt') + ->willReturnCallback(fn($s) => substr($s, 10)); // strip 'encrypted:' + + // Build with our crypto mock + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $this->createMock(\OCP\IConfig::class), + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $cryptoMock, + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + return [$service, $cryptoMock]; + } + + // ── validateBotPassword ────────────────────────────────────── + + public function testValidateBotPasswordValid(): void { + $service = $this->makeService()[0]; + $result = $service->validateBotPassword('mySecurePassword123!'); + $this->assertTrue($result['valid']); + } + + public function testValidateBotPasswordEmpty(): void { + $service = $this->makeService()[0]; + $result = $service->validateBotPassword(''); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('empty', $result['error']); + } + + public function testValidateBotPasswordSpecialChars(): void { + $service = $this->makeService()[0]; + $result = $service->validateBotPassword("!@#\$%^&*()_+-=[]{}|;:',.<>?/~`"); + $this->assertTrue($result['valid']); + } + + public function testValidateBotPasswordUnicode(): void { + $service = $this->makeService()[0]; + $result = $service->validateBotPassword('密码🔒🎉'); + $this->assertTrue($result['valid']); + } + + // ── getBaseUrl ─────────────────────────────────────────────── + + public function testGetBaseUrlOverwriteHostTakesPriority(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', 'example.com'], + ['overwriteproto', 'https', 'https'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('https://example.com', $service->getBaseUrl()); + } + + public function testGetBaseUrlOverwritewebrootFullPath(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', 'https://webroot.example.com/nextcloud'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('https://webroot.example.com/nextcloud', $service->getBaseUrl()); + } + + public function testGetBaseUrlOverwritewebrootPath(): void { + // Path-style overwritewebroot should fall through to trusted_domains + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', '/nextcloud'], + ]); + $trustedMock = $this->createMock(\OCP\IConfig::class); + $trustedMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', '/nextcloud'], + ]); + $trustedMock->method('getSystemValue') + ->willReturn(['public.example.com']); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $trustedMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('https://public.example.com', $service->getBaseUrl()); + } + + public function testGetBaseUrlSkipsPrivateDomains(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', ''], + ]); + $configMock->method('getSystemValue') + ->willReturn(['192.168.1.100', '10.0.0.5', 'public.example.com']); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('https://public.example.com', $service->getBaseUrl()); + } + + public function testGetBaseUrlFallbackToCliUrl(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', ''], + ]); + $configMock->method('getSystemValue') + ->willReturn([]); + $configMock->method('getSystemValueString') + ->willReturnMap([ + ['overwritehost', '', ''], + ['overwritewebroot', '', ''], + ['overwrite.cli.url', '', 'https://cli.example.com'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('https://cli.example.com', $service->getBaseUrl()); + } + + public function testGetBaseUrlEmptyConfig(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getSystemValueString')->willReturn(''); + $configMock->method('getSystemValue')->willReturn([]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame('', $service->getBaseUrl()); + } + + // ── getRooms / setRooms ────────────────────────────────────── + + public function testGetRoomsEmpty(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'rooms', '[]', '[]'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame([], $service->getRooms()); + } + + public function testGetRoomsParsed(): void { + $json = json_encode(['abc123' => 'General Chat', 'xyz789' => 'Builds']); + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'rooms', '[]', $json], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $rooms = $service->getRooms(); + $this->assertArrayHasKey('abc123', $rooms); + $this->assertSame('General Chat', $rooms['abc123']); + $this->assertArrayHasKey('xyz789', $rooms); + $this->assertSame('Builds', $rooms['xyz789']); + } + + public function testSetRoomsPersists(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'rooms', '[]', '[]'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $rooms = ['room1' => 'Room One']; + $configMock->expects($this->once()) + ->method('setAppValue') + ->with(TalkService::APP_ID, 'rooms', json_encode($rooms)); + + $service->setRooms($rooms); + } + + // ── getAuthTokens / setAuthTokens ──────────────────────────── + + public function testGetAuthTokensEmpty(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'auth_tokens', '{}', '{}'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $this->assertSame([], $service->getAuthTokens()); + } + + public function testGetAuthTokensParsed(): void { + $json = json_encode(['abc123' => ['tok1', 'tok2'], 'xyz789' => ['tok3']]); + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'auth_tokens', '{}', $json], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + $tokens = $service->getAuthTokens(); + $this->assertSame(['tok1', 'tok2'], $tokens['abc123']); + $this->assertSame(['tok3'], $tokens['xyz789']); + } + + public function testSetAuthTokensPersists(): void { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnMap([ + [TalkService::APP_ID, 'auth_tokens', '{}', '{}'], + ]); + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $tokens = ['abc123' => ['tok1']]; + $configMock->expects($this->once()) + ->method('setAppValue') + ->with(TalkService::APP_ID, 'auth_tokens', json_encode($tokens)); + + $service->setAuthTokens($tokens); + } + + // ── validateAuthToken ──────────────────────────────────────── + + public function testValidateAuthTokenValid(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1', 'tok2']])], + ]); + $this->assertTrue($service->validateAuthToken('abc123', 'tok1')); + $this->assertTrue($service->validateAuthToken('abc123', 'tok2')); + } + + public function testValidateAuthTokenInvalid(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1', 'tok2']])], + ]); + $this->assertFalse($service->validateAuthToken('abc123', 'tok3')); + } + + public function testValidateAuthTokenNonexistentRoom(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1']])], + ]); + $this->assertFalse($service->validateAuthToken('xyz789', 'tok1')); + } + + public function testValidateAuthTokenMultipleTokens(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1', 'tok2', 'tok3']])], + ]); + $this->assertTrue($service->validateAuthToken('abc123', 'tok2')); + } + + public function testValidateAuthTokenTokenNotInRoom(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1'], 'xyz789' => ['tok2']])], + ]); + $this->assertFalse($service->validateAuthToken('abc123', 'tok2')); + } + + /** + * Helper: build a TalkService with a configured IConfig mock. + * + * @param array $valueMap value map for IConfig::getAppValue + * @return array{0: TalkService, 1: \PHPUnit\Framework\MockObject\MockObject} + */ + private function makeServiceWithConfig(array $valueMap): array { + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue') + ->willReturnCallback(function ($app, $key, $default) use ($valueMap, $configMock) { + foreach ($valueMap as $entry) { + if ($entry[0] === $app && $entry[1] === $key) { + return $entry[3] ?? $default; + } + } + return $default; + }); + // setAppValue is a no-op in tests + $configMock->method('setAppValue') + ->willReturnCallback(fn($app, $key, $value) => null); + + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + return [$service, $configMock]; + } + + // ── generateAuthToken ──────────────────────────────────────── + + public function testGenerateAuthTokenGeneratesToken(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', '{}'], + ]); + $token = $service->generateAuthToken('abc123'); + $this->assertEquals(48, strlen($token)); + $this->assertRegExp('/^[0-9a-f]+$/', $token); + } + + public function testGenerateAuthTokenPersists(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', '{}'], + ]); + $configMock->expects($this->atLeast(2)) + ->method('setAppValue') + ->willReturnCallback(fn($app, $key, $value) => null); + + $token1 = $service->generateAuthToken('abc123'); + $token2 = $service->generateAuthToken('abc123'); + + $this->assertNotSame($token1, $token2); + } + + // ── revokeAuthToken ────────────────────────────────────────── + + public function testRevokeAuthTokenRemovesToken(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1', 'tok2']])], + ]); + $configMock->expects($this->atLeast(2)) + ->method('setAppValue') + ->willReturnCallback(fn($app, $key, $value) => null); + + $service->revokeAuthToken('abc123', 'tok1'); + + $this->assertFalse($service->validateAuthToken('abc123', 'tok1')); + $this->assertTrue($service->validateAuthToken('abc123', 'tok2')); + } + + public function testRevokeAuthTokenCleansEmptyRoom(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1']])], + ]); + $configMock->expects($this->atLeast(2)) + ->method('setAppValue') + ->willReturnCallback(fn($app, $key, $value) => null); + + $service->revokeAuthToken('abc123', 'tok1'); + + $tokens = $service->getAuthTokens(); + $this->assertArrayNotHasKey('abc123', $tokens); + } + + public function testRevokeAuthTokenNonexistentToken(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'auth_tokens', '{}', json_encode(['abc123' => ['tok1']])], + ]); + $configMock->expects($this->once()) + ->method('setAppValue') + ->willReturnCallback(fn($app, $key, $value) => null); + + $service->revokeAuthToken('abc123', 'nonexistent'); + + // Should still have tok1 + $this->assertTrue($service->validateAuthToken('abc123', 'tok1')); + } + + // ── mapPayload ─────────────────────────────────────────────── + + public function testMapPayloadContentOnly(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload(['content' => 'Hello world']); + $this->assertSame('Hello world', $result); + } + + public function testMapPayloadEmbedWithTitle(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload([ + 'embeds' => [['title' => 'Build #123']], + ]); + $this->assertStringContainsString('**Build #123**', $result); + } + + public function testMapPayloadEmbedWithDescription(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload([ + 'embeds' => [['description' => 'Passed']], + ]); + $this->assertStringContainsString('Passed', $result); + } + + public function testMapPayloadEmbedWithFields(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload([ + 'embeds' => [[ + 'fields' => [ + ['name' => 'Duration', 'value' => '2m 34s'], + ['name' => 'Environment', 'value' => 'Production'], + ], + ]], + ]); + $this->assertStringContainsString('Duration: 2m 34s', $result); + $this->assertStringContainsString('Environment: Production', $result); + } + + public function testMapPayloadEmpty(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload([]); + $this->assertSame('', $result); + } + + public function testMapPayloadMultipleEmbeds(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->mapPayload([ + 'embeds' => [ + ['title' => 'First'], + ['title' => 'Second'], + ], + ]); + $this->assertStringContainsString('**First**', $result); + $this->assertStringContainsString('**Second**', $result); + } + + // ── mapApprisePayload ──────────────────────────────────────── + + public function testMapApprisePayloadBodyOnly(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'Custom Bot'], + ]); + $result = $service->mapApprisePayload(['body' => 'All clear'], 'abc123'); + $this->assertSame('All clear', $result['message']); + } + + public function testMapApprisePayloadTitleFallback(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'Default Sender'], + ]); + $result = $service->mapApprisePayload(['title' => 'Alert', 'body' => 'Something happened'], 'abc123'); + $this->assertSame('Something happened', $result['message']); + $this->assertSame('Alert', $result['senderName']); + } + + public function testMapApprisePayloadTypeIcons(): void { + [$service, $configMock] = $this->makeServiceWithConfig([]); + // Success type — should include ✅ icon + $resultSuccess = $service->mapApprisePayload(['body' => 'OK', 'type' => 'success'], 'abc123'); + $this->assertStringContainsString('✅', $resultSuccess['message']); + + // Warning type + $resultWarning = $service->mapApprisePayload(['body' => 'Warn', 'type' => 'warning'], 'abc123'); + $this->assertStringContainsString('⚠️', $resultWarning['message']); + + // Error type + $resultError = $service->mapApprisePayload(['body' => 'Fail', 'type' => 'error'], 'abc123'); + $this->assertStringContainsString('❌', $resultError['message']); + } + + public function testMapApprisePayloadImageType(): void { + [$service, $configMock] = $this->makeServiceWithConfig([]); + // Image type with attachment URLs — processImageUrls is called, which returns empty + // because there's no real file system; verify richObjects is set + $result = $service->mapApprisePayload([ + 'body' => 'Check this out', + 'type' => 'image', + 'attachments' => [['url' => 'https://example.com/img.png']], + ], 'abc123'); + // richObjects should be an array (possibly empty due to no real FS) + $this->assertIsArray($result['richObjects']); + } + + public function testMapApprisePayloadNestedDataKey(): void { + [$service, $configMock] = $this->makeServiceWithConfig([]); + // When the payload is wrapped in a 'data' key (Home Assistant), the controller + // unwraps it before passing to mapApprisePayload. Here we test the unwrapped form. + $result = $service->mapApprisePayload([ + 'body' => 'Direct payload', + 'title' => 'Direct Title', + ], 'abc123'); + $this->assertSame('Direct payload', $result['message']); + $this->assertSame('Direct Title', $result['senderName']); + } + + // ── getSenderName ──────────────────────────────────────────── + + public function testGetSenderNameSenderNameTakesPriority(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'Default Sender'], + ]); + $this->assertSame('Custom', $service->getSenderName(['sender_name' => 'Custom'])); + } + + public function testGetSenderNameUsernameFallback(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'Default Sender'], + ]); + $this->assertSame('CI Bot', $service->getSenderName(['username' => 'CI Bot'])); + } + + public function testGetSenderNameConfigDefault(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'Default Sender'], + ]); + $this->assertSame('Default Sender', $service->getSenderName([])); + } + + // ── getSenderNameDefault ───────────────────────────────────── + + public function testGetSenderNameDefault(): void { + [$service, $configMock] = $this->makeServiceWithConfig([ + [TalkService::APP_ID, 'sender_name', 'Webhook Bot', 'My Bot'], + ]); + $this->assertSame('My Bot', $service->getSenderNameDefault()); + } + + // ── prependDisplayName ─────────────────────────────────────── + + public function testPrependDisplayNameWithMessage(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->prependDisplayName('CI Bot', 'Build passed'); + $this->assertStringContainsString('**CI Bot**', $result); + $this->assertStringContainsString('Build passed', $result); + $this->assertStringContainsString("\n\n", $result); + } + + public function testPrependDisplayNameWithoutMessage(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->prependDisplayName('CI Bot', ''); + $this->assertStringContainsString('**CI Bot**', $result); + $this->assertStringNotContainsString("\n\n", $result); + } + + public function testPrependDisplayNameWithTypeIcon(): void { + [$service] = $this->makeServiceWithConfig([]); + $result = $service->prependDisplayName('CI Bot', 'OK', '✅'); + $this->assertStringContainsString('✅', $result); + $this->assertStringContainsString('**CI Bot**', $result); + } + + // ── downloadImage ──────────────────────────────────────────── + + public function testDownloadImageSuccessful(): void { + [$service, $configMock] = $this->makeServiceWithConfig([]); + + $responseMock = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $responseMock->method('getBody') + ->willReturn(new class { + public function __toString(): string { return 'image-data'; } + }); + + $clientMock = $this->createMock(\OCP\Http\Client\IClient::class); + $clientMock->method('get') + ->willReturn($responseMock); + + $clientServiceMock = $this->createMock(\OCP\Http\Client\IClientService::class); + $clientServiceMock->method('getClient') + ->willReturn($clientMock); + + // Rebuild with our client service + $configMock2 = $this->createMock(\OCP\IConfig::class); + $configMock2->method('getAppValue')->willReturn(''); + $service2 = new TalkService( + $clientServiceMock, + $configMock2, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $result = $service2->downloadImage('https://example.com/image.png'); + $this->assertSame('image-data', $result); + } + + public function testDownloadImageEmptyBody(): void { + [$service] = $this->makeServiceWithConfig([]); + + $responseMock = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $responseMock->method('getBody') + ->willReturn(new class { + public function __toString(): string { return ''; } + }); + + $clientMock = $this->createMock(\OCP\Http\Client\IClient::class); + $clientMock->method('get') + ->willReturn($responseMock); + + $clientServiceMock = $this->createMock(\OCP\Http\Client\IClientService::class); + $clientServiceMock->method('getClient') + ->willReturn($clientMock); + + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue')->willReturn(''); + $service2 = new TalkService( + $clientServiceMock, + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $this->createMock(\OCP\IUserManager::class), + $this->createMock(\OCP\IUserSession::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $result = $service2->downloadImage('https://example.com/empty.png'); + $this->assertSame('', $result); + } + + // ── purgeOldImages ─────────────────────────────────────────── + + public function testPurgeOldImagesWithFiles(): void { + // Create a temporary directory to simulate the images folder + $tempDir = sys_get_temp_dir() . '/nc_bot_webhooks_test_' . uniqid(); + mkdir($tempDir . '/abc123', 0755, true); + + // Create a file older than 90 days + $oldFile = $tempDir . '/abc123/old.png'; + file_put_contents($oldFile, 'data'); + touch($oldFile, time() - (100 * 86400)); // 100 days ago + + // Create a recent file + $recentFile = $tempDir . '/abc123/recent.png'; + file_put_contents($recentFile, 'data'); + touch($recentFile, time() - (10 * 86400)); // 10 days ago + + // Mock rootFolder to return our temp directory + $folderMock = $this->createMock(\OCP\Files\Folder::class); + $folderMock->method('getContents') + ->willReturn(['old.png', 'recent.png']); + + $oldFileMock = $this->createMock(\OCP\Files\File::class); + $oldFileMock->method('getMTime') + ->willReturn(time() - (100 * 86400)); + + $recentFileMock = $this->createMock(\OCP\Files\File::class); + $recentFileMock->method('getMTime') + ->willReturn(time() - (10 * 86400)); + + $folderMock->method('get') + ->willReturnMap([ + ['old.png', $oldFileMock], + ['recent.png', $recentFileMock], + ]); + + $rootMock = $this->createMock(\OCP\Files\IRootFolder::class); + $rootMock->method('getUserFolder') + ->willReturn($folderMock); + + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue')->willReturn('90'); + + $userManagerMock = $this->createMock(\OCP\IUserManager::class); + $userMock = $this->createMock(\OCP\IUser::class); + $userMock->method('getUID') + ->willReturn('talk-bot'); + $userManagerMock->method('get') + ->willReturn($userMock); + + $loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $rootMock, + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $userManagerMock, + $this->createMock(\OCP\IUserSession::class), + $loggerMock, + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $count = $service->purgeOldImages(); + $this->assertGreaterThanOrEqual(1, $count); + + // Cleanup + unlink($oldFile); + unlink($recentFile); + rmdir($tempDir . '/abc123'); + rmdir($tempDir); + } + + public function testPurgeOldImagesEmptyDirectory(): void { + $folderMock = $this->createMock(\OCP\Files\Folder::class); + $folderMock->method('getContents') + ->willReturn([]); + + $rootMock = $this->createMock(\OCP\Files\IRootFolder::class); + $rootMock->method('getUserFolder') + ->willReturn($folderMock); + + $configMock = $this->createMock(\OCP\IConfig::class); + $configMock->method('getAppValue')->willReturn('90'); + + $userManagerMock = $this->createMock(\OCP\IUserManager::class); + $userMock = $this->createMock(\OCP\IUser::class); + $userMock->method('getUID') + ->willReturn('talk-bot'); + $userManagerMock->method('get') + ->willReturn($userMock); + + $loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + + $service = new TalkService( + $this->createMock(\OCP\Http\Client\IClientService::class), + $configMock, + $this->createMock(\OCP\IDBConnection::class), + $rootMock, + $this->createMock(\OCP\IRequest::class), + $this->createMock(\OCP\IURLGenerator::class), + $userManagerMock, + $this->createMock(\OCP\IUserSession::class), + $loggerMock, + $this->createMock(\OCA\Talk\Manager::class), + $this->createMock(\OCP\Security\ICrypto::class), + $this->createMock(\OCA\Talk\Model\AttendeeMapper::class), + $this->createMock(\OCP\Share\IManager::class), + $this->createMock(\OCA\Talk\Service\ParticipantService::class), + $this->createMock(\OCA\Talk\Chat\ChatManager::class), + $this->createMock(\OCA\Talk\TalkSession::class), + ); + + $count = $service->purgeOldImages(); + $this->assertSame(0, $count); + } +} diff --git a/tests/WebhookControllerTest.php b/tests/WebhookControllerTest.php new file mode 100644 index 0000000..11c8f93 --- /dev/null +++ b/tests/WebhookControllerTest.php @@ -0,0 +1,613 @@ +createMock(IRequest::class), + $talkService, + $this->createMock(LoggerInterface::class), + $this->createMock(IAppManager::class), + $this->createMock(IUserSession::class), + $this->createMock(IGroupManager::class), + $this->createMock(IConfig::class), + $this->createMock(IAppConfig::class), + $this->createMock(IClientService::class), + $this->createMock(IShareManager::class), + ); + } + + /** + * Build a TalkService mock with given method return values. + */ + private function makeTalkServiceMock(array $methods): TalkService { + $mock = $this->createMock(TalkService::class); + foreach ($methods as $method => $return) { + if (is_callable($return)) { + $mock->method($method) + ->willReturnCallback($return); + } else { + $mock->method($method) + ->willReturn($return); + } + } + return $mock; + } + + // ========================================================================= + // receive (Discord webhook) + // ========================================================================= + + public function testReceiveReturns401ForInvalidAuthToken(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => false, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->receive('room123', 'badtoken'); + + $this->assertEquals(401, $response->getStatus()); + $this->assertEquals('Unauthorized', $response->getData()['error']); + } + + public function testReceiveReturns400ForEmptyBody(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => true, + ]); + $controller = $this->makeController($talkService); + + // With no php://input available, json_decode('') fails → 400 + $response = $controller->receive('room123', 'validtoken'); + + $this->assertEquals(400, $response->getStatus()); + $this->assertEquals('Invalid JSON', $response->getData()['error']); + } + + public function testReceiveUsesGETFallbackWhenInputIsEmpty(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => true, + 'mapPayload' => function ($payload) { + return [ + 'message' => $payload['content'] ?? '', + 'senderName' => 'Bot', + 'displayName' => 'Bot', + 'richObjects' => [], + ]; + }, + 'getSenderNameDefault' => 'Bot', + 'prependDisplayName' => function ($name, $msg) { + return $msg; + }, + 'postToRoom' => true, + ]); + + $controller = $this->makeController($talkService); + + // Simulate empty php://input with $_GET fallback + $_GET = [ + 'content' => 'GET fallback message', + ]; + + $response = $controller->receive('room123', 'validtoken'); + + $this->assertEquals(201, $response->getStatus()); + $this->assertEquals('ok', $response->getData()['status']); + + // Clean up + $_GET = []; + } + + public function testReceiveGETFallbackWithEmbeds(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateAuthToken') + ->willReturn(true); + $talkService->method('mapPayload') + ->willReturnCallback(function ($payload) { + return [ + 'message' => $payload['content'] ?? '', + 'senderName' => 'Bot', + 'displayName' => 'Bot', + 'richObjects' => [], + ]; + }); + $talkService->method('getSenderNameDefault') + ->willReturn('Bot'); + $talkService->method('prependDisplayName') + ->willReturnCallback(function ($name, $msg) { + return $msg; + }); + $talkService->method('postToRoom') + ->willReturn(true); + + $controller = $this->makeController($talkService); + + $_GET = [ + 'content' => 'Test with embeds', + 'embeds' => '[{"title":"Embed Title","description":"Embed desc"}]', + ]; + + $response = $controller->receive('room123', 'validtoken'); + + $this->assertEquals(201, $response->getStatus()); + + // Clean up + $_GET = []; + } + + // ========================================================================= + // receiveApprise + // ========================================================================= + + public function testReceiveAppriseReturns401ForInvalidAuthToken(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => false, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->receiveApprise('room123', 'badtoken'); + + $this->assertEquals(401, $response->getStatus()); + $this->assertEquals('Unauthorized', $response->getData()['error']); + } + + public function testReceiveAppriseReturns400ForEmptyBody(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => true, + ]); + $controller = $this->makeController($talkService); + + // No php://input → no data → 400 + $response = $controller->receiveApprise('room123', 'validtoken'); + + $this->assertEquals(400, $response->getStatus()); + } + + public function testReceiveAppriseNotifyDelegatesToReceiveApprise(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateAuthToken' => false, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->receiveAppriseNotify('room123', 'badtoken'); + + $this->assertEquals(401, $response->getStatus()); + $this->assertEquals('Unauthorized', $response->getData()['error']); + } + + public function testReceiveAppriseUsesPOSTFallbackWithNotifications(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateAuthToken') + ->willReturn(true); + $talkService->method('mapApprisePayload') + ->willReturn([ + 'message' => 'POST fallback message', + 'senderName' => 'Apprise', + 'displayName' => 'Apprise', + 'richObjects' => [], + ]); + $talkService->method('getSenderNameDefault') + ->willReturn('Bot'); + $talkService->method('prependDisplayName') + ->willReturnCallback(function ($name, $msg) { + return $msg; + }); + $talkService->method('postToRoom') + ->willReturn(true); + + $controller = $this->makeController($talkService); + + // Simulate $_POST fallback (form-encoded with notifications wrapper) + $_POST = [ + 'notifications' => [ + [ + 'body' => 'POST fallback message', + 'type' => 'success', + ], + ], + ]; + + $response = $controller->receiveApprise('room123', 'validtoken'); + + $this->assertEquals(201, $response->getStatus()); + + // Clean up + $_POST = []; + } + + public function testReceiveApprisePOSTFallbackWithImageType(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateAuthToken') + ->willReturn(true); + $talkService->method('mapApprisePayload') + ->willReturn([ + 'message' => '', + 'senderName' => 'Apprise', + 'displayName' => 'Apprise', + 'richObjects' => ['richObject' => []], + ]); + $talkService->method('getSenderNameDefault') + ->willReturn('Bot'); + $talkService->method('prependDisplayName') + ->willReturnCallback(function ($name, $msg) { + return $msg; + }); + $talkService->method('postToRoom') + ->willReturn(true); + + $controller = $this->makeController($talkService); + + $_POST = [ + 'notifications' => [ + [ + 'body' => 'Image notification', + 'type' => 'image', + 'attachments' => ['https://example.com/image.png'], + ], + ], + ]; + + $response = $controller->receiveApprise('room123', 'validtoken'); + + $this->assertEquals(201, $response->getStatus()); + + // Clean up + $_POST = []; + } + + public function testReceiveAppriseGETFallbackWithNotifications(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateAuthToken') + ->willReturn(true); + $talkService->method('mapApprisePayload') + ->willReturn([ + 'message' => 'GET fallback message', + 'senderName' => 'Apprise', + 'displayName' => 'Apprise', + 'richObjects' => [], + ]); + $talkService->method('getSenderNameDefault') + ->willReturn('Bot'); + $talkService->method('prependDisplayName') + ->willReturnCallback(function ($name, $msg) { + return $msg; + }); + $talkService->method('postToRoom') + ->willReturn(true); + + $controller = $this->makeController($talkService); + + // $_GET with notifications array + $_GET = [ + 'notifications' => [ + [ + 'body' => 'GET fallback message', + 'type' => 'notice', + ], + ], + ]; + + $response = $controller->receiveApprise('room123', 'validtoken'); + + $this->assertEquals(201, $response->getStatus()); + + // Clean up + $_GET = []; + } + + // ========================================================================= + // saveConfig / saveBotPassword + // ========================================================================= + + public function testSaveConfigReturns500WhenTalkServiceFails(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateBotPassword') + ->willThrowException(new \Exception('crypto error')); + + $controller = $this->makeController($talkService); + + $response = $controller->saveConfig([ + 'bot_password' => 'test', + 'retention_days' => '90', + 'sender_name' => 'Bot', + 'rooms' => [], + 'auth_tokens' => [], + ]); + + $this->assertEquals(500, $response->getStatus()); + } + + public function testSaveConfigReturns400WhenInvalidConfig(): void { + $talkService = $this->createMock(TalkService::class); + + $controller = $this->makeController($talkService); + + $response = $controller->saveConfig(null); + + $this->assertEquals(400, $response->getStatus()); + $this->assertEquals('Invalid config', $response->getData()['error']); + } + + public function testSaveConfigReturns200WithAuthTokens(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('validateBotPassword') + ->willReturn(true); + $talkService->method('saveConfig') + ->willReturn(null); + $talkService->method('getAuthTokens') + ->willReturn(['room1' => 'token1']); + + $controller = $this->makeController($talkService); + + $response = $controller->saveConfig([ + 'bot_password' => 'validpassword', + 'retention_days' => '30', + 'sender_name' => 'TestBot', + 'rooms' => ['room1'], + 'auth_tokens' => ['room1' => 'token1'], + ]); + + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals('ok', $response->getData()['status']); + $this->assertEquals(['room1' => 'token1'], $response->getData()['auth_tokens']); + } + + public function testSaveBotPasswordRejectsInvalidPassword(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateBotPassword' => false, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->saveBotPassword('invalid'); + + $this->assertEquals(400, $response->getStatus()); + } + + public function testSaveBotPasswordAcceptsValidPassword(): void { + $talkService = $this->makeTalkServiceMock([ + 'validateBotPassword' => true, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->saveBotPassword('validpassword123'); + + $this->assertEquals(200, $response->getStatus()); + } + + // ========================================================================= + // getRooms + // ========================================================================= + + public function testGetRoomsReturnsEmptyArrayWhenNoRooms(): void { + $talkService = $this->makeTalkServiceMock([ + 'getAvailableTalkRooms' => [], + 'getRooms' => [], + 'detectTalkTableFromCatalog' => null, + ]); + $controller = $this->makeController($talkService); + + $response = $controller->getRooms(); + + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals([], $response->getData()); + } + + public function testGetRoomsReturnsRoomListWithConfiguredFlag(): void { + $talkService = $this->makeTalkServiceMock([ + 'getAvailableTalkRooms' => [ + 'abc123' => 'Test Room', + 'def456' => 'Another Room', + ], + 'getRooms' => ['abc123' => true], // only abc123 configured + 'detectTalkTableFromCatalog' => 'talk_rooms', + ]); + + $dbConn = $this->createMock(\Doctrine\DBAL\Connection::class); + $dbConn->method('executeQuery') + ->willReturn(null); + $talkService->method('getDbConnection') + ->willReturn($dbConn); + + $controller = $this->makeController($talkService); + + $response = $controller->getRooms(); + + $this->assertEquals(200, $response->getStatus()); + $data = $response->getData(); + $this->assertNotEmpty($data); + + $roomA = null; + $roomB = null; + foreach ($data as $room) { + if ($room['token'] === 'abc123') $roomA = $room; + if ($room['token'] === 'def456') $roomB = $room; + } + + $this->assertNotNull($roomA); + $this->assertTrue($roomA['configured']); + $this->assertTrue(isset($roomA['token'])); + $this->assertTrue(isset($roomA['name'])); + $this->assertTrue(isset($roomA['type_label'])); + + $this->assertNotNull($roomB); + $this->assertFalse($roomB['configured']); + } + + public function testGetRoomsReturns500OnException(): void { + $talkService = $this->createMock(TalkService::class); + $talkService->method('getAvailableTalkRooms') + ->willThrowException(new \Exception('DB connection failed')); + + $controller = $this->makeController($talkService); + + $response = $controller->getRooms(); + + $this->assertEquals(500, $response->getStatus()); + $this->assertStringContainsString('DB connection failed', $response->getData()['error']); + } + + // ========================================================================= + // debug endpoint + // ========================================================================= + + public function testDebugReturns403WhenDisabled(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(false); + + $talkService = $this->createMock(TalkService::class); + $controller = new WebhookController( + 'nc_bot_webhooks', + $this->createMock(IRequest::class), + $talkService, + $this->createMock(LoggerInterface::class), + $this->createMock(IAppManager::class), + $this->createMock(IUserSession::class), + $this->createMock(IGroupManager::class), + $this->createMock(IConfig::class), + $appConfig, + $this->createMock(IClientService::class), + $this->createMock(IShareManager::class), + ); + + $response = $controller->debug(); + + $this->assertEquals(403, $response->getStatus()); + $this->assertStringContainsString('disabled', $response->getData()['error']); + } + + public function testDebugReturns200WhenEnabled(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(true); + + $talkService = $this->createMock(TalkService::class); + $talkService->method('getBotUser') + ->willReturn(null); + $talkService->method('hasBotPassword') + ->willReturn(false); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('admin'); + $user->method('isAdmin') + ->willReturn(true); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser') + ->willReturn($user); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('isAdmin') + ->willReturn(true); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString') + ->willReturn(''); + + $controller = new WebhookController( + 'nc_bot_webhooks', + $this->createMock(IRequest::class), + $talkService, + $this->createMock(LoggerInterface::class), + $this->createMock(IAppManager::class), + $userSession, + $groupManager, + $config, + $appConfig, + $this->createMock(IClientService::class), + $this->createMock(IShareManager::class), + ); + + $response = $controller->debug(); + + $this->assertEquals(200, $response->getStatus()); + $data = $response->getData(); + $this->assertTrue($data['debug_enabled']); + $this->assertEquals('admin', $data['user']); + $this->assertTrue($data['user_is_admin']); + } + + public function testDebugNonAdminUser(): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueBool') + ->willReturn(true); + + $talkService = $this->createMock(TalkService::class); + $talkService->method('getBotUser') + ->willReturn(null); + $talkService->method('hasBotPassword') + ->willReturn(false); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('regular_user'); + $user->method('isAdmin') + ->willReturn(false); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser') + ->willReturn($user); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('isAdmin') + ->willReturn(false); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString') + ->willReturn(''); + + $controller = new WebhookController( + 'nc_bot_webhooks', + $this->createMock(IRequest::class), + $talkService, + $this->createMock(LoggerInterface::class), + $this->createMock(IAppManager::class), + $userSession, + $groupManager, + $config, + $appConfig, + $this->createMock(IClientService::class), + $this->createMock(IShareManager::class), + ); + + $response = $controller->debug(); + + $this->assertEquals(200, $response->getStatus()); + $this->assertFalse($response->getData()['user_is_admin']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e37d29e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,137 @@ +build(); // all defaults + * $service = $builder->config(['app' => ['key' => 'val']])->build(); + * $service = $builder->botUser($botUser)->build(); + */ +class TalkServiceMockBuilder { + private \PHPUnit\Framework\MockObject\MockObject| \OCP\Http\Client\IClientService $clientService; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IConfig $config; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IDBConnection $db; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\Files\IRootFolder $rootFolder; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IRequest $request; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IURLGenerator $urlGenerator; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IUserManager $userManager; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\IUserSession $userSession; + private \PHPUnit\Framework\MockObject\MockObject| \Psr\Log\LoggerInterface $logger; + private \PHPUnit\Framework\MockObject\MockObject| \OCA\Talk\Manager $talkManager; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\Security\ICrypto $crypto; + private \PHPUnit\Framework\MockObject\MockObject| \OCA\Talk\Model\AttendeeMapper $attendeeMapper; + private \PHPUnit\Framework\MockObject\MockObject| \OCP\Share\IManager $shareManager; + private \PHPUnit\Framework\MockObject\MockObject| \OCA\Talk\Service\ParticipantService $participantService; + private \PHPUnit\Framework\MockObject\MockObject| \OCA\Talk\Chat\ChatManager $chatManager; + private \PHPUnit\Framework\MockObject\MockObject| \OCA\Talk\TalkSession $talkSession; + + public function __construct() { + $this->clientService = $this->makeMock(\OCP\Http\Client\IClientService::class); + $this->config = $this->makeMock(\OCP\IConfig::class); + $this->db = $this->makeMock(\OCP\IDBConnection::class); + $this->rootFolder = $this->makeMock(\OCP\Files\IRootFolder::class); + $this->request = $this->makeMock(\OCP\IRequest::class); + $this->urlGenerator = $this->makeMock(\OCP\IURLGenerator::class); + $this->userManager = $this->makeMock(\OCP\IUserManager::class); + $this->userSession = $this->makeMock(\OCP\IUserSession::class); + $this->logger = $this->makeMock(\Psr\Log\LoggerInterface::class); + $this->talkManager = $this->makeMock(\OCA\Talk\Manager::class); + $this->crypto = $this->makeMock(\OCP\Security\ICrypto::class); + $this->attendeeMapper = $this->makeMock(\OCA\Talk\Model\AttendeeMapper::class); + $this->shareManager = $this->makeMock(\OCP\Share\IManager::class); + $this->participantService = $this->makeMock(\OCA\Talk\Service\ParticipantService::class); + $this->chatManager = $this->makeMock(\OCA\Talk\Chat\ChatManager::class); + $this->talkSession = $this->makeMock(\OCA\Talk\TalkSession::class); + } + + private function makeMock(string $interface): \PHPUnit\Framework\MockObject\MockObject { + return \PHPUnit\Framework\TestCase::getMockForTrait($interface); + } + + /** Configure the config mock so getAppValue / setAppValue work as expected. */ + public function config(array $appValues = []): self { + // getMockForTrait returns a partial mock; we can still use willReturn / withConsecutive + $mock = $this->config; + if (is_object($mock) && method_exists($mock, 'expects')) { + // We set expectations lazily inside each test; this is just a convenience + // to pre-configure default return values. + } + return $this; + } + + /** + * Assemble and return a TalkService. + * + * @return \OCA\Ncbotwebhooks\Service\TalkService + */ + public function build(): \OCA\Ncbotwebhooks\Service\TalkService { + return new \OCA\Ncbotwebhooks\Service\TalkService( + $this->clientService, + $this->config, + $this->db, + $this->rootFolder, + $this->request, + $this->urlGenerator, + $this->userManager, + $this->userSession, + $this->logger, + $this->talkManager, + $this->crypto, + $this->attendeeMapper, + $this->shareManager, + $this->participantService, + $this->chatManager, + $this->talkSession, + ); + } +} + +}