untested... test: add unit test suite and CI workflow

I have never done this style of test before... who knows if it will work

- Add Gitea Actions workflow for PHP 8.2 testing
- Add PHPUnit configuration and bootstrap file
- Add unit tests for TalkService, WebhookController, ImageCleanup, NavigationProvider, and DebugToggle
- Add README documentation for the test suite
- Update composer.json with test and lint scripts
This commit is contained in:
kyle
2026-06-14 19:12:25 -07:00
parent b18b996468
commit df930de6cd
10 changed files with 2410 additions and 0 deletions
+35
View File
@@ -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
+7
View File
@@ -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 {} \\;"
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">lib</directory>
</include>
</source>
</phpunit>
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace OCA\Ncbotwebhooks\Test;
use OCA\Ncbotwebhooks\Command\DebugToggle;
use OCP\AppConfig;
use OCP\IAppConfig;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Input\InputOption;
/**
* Unit tests for DebugToggle OCC command.
*
* Tests --status, --enable, --disable, toggle, and --enable/--disable conflict.
*/
class DebugToggleTest extends TestCase {
private const APP_ID = 'nc_bot_webhooks';
private const DEBUG_KEY = 'debug_enabled';
private function makeCommand(IAppConfig $appConfig): DebugToggle {
return new DebugToggle($appConfig);
}
// =========================================================================
// --status
// =========================================================================
public function testStatusReturnsDisabledByDefault(): void {
$appConfig = $this->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());
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
namespace OCA\Ncbotwebhooks\Test;
use OCA\Ncbotwebhooks\Cron\ImageCleanup;
use OCA\Ncbotwebhooks\Service\TalkService;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IUserManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for ImageCleanup cron job.
*
* Tests the full run() flow: bot user check, directory check, purge execution.
*/
class ImageCleanupTest extends TestCase {
/**
* Build an ImageCleanup with all dependencies mocked.
*/
private function makeImageCleanup(
TalkService $talkService,
IRootFolder $rootFolder,
IUserManager $userManager,
LoggerInterface $logger,
): ImageCleanup {
return new ImageCleanup(
$talkService,
$rootFolder,
$userManager,
$logger,
);
}
// =========================================================================
// Early exit paths
// =========================================================================
public function testRunReturnsEarlyWhenNoBotUser(): void {
$talkService = $this->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
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace OCA\Ncbotwebhooks\Test;
use OCA\Ncbotwebhooks\NavigationProvider;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for NavigationProvider settings link.
*
* Tests that the navigation link is only shown to admin users.
*/
class NavigationProviderTest extends TestCase {
/**
* Build a NavigationProvider with all dependencies mocked.
*/
private function makeNavigationProvider(
IURLGenerator $urlGenerator,
IUserSession $userSession,
IConfig $config,
IL10N $l10n,
): NavigationProvider {
return new NavigationProvider(
$urlGenerator,
$userSession,
$config,
$l10n,
);
}
// =========================================================================
// Non-admin paths
// =========================================================================
public function testGetNavigationReturnsEmptyForNullUser(): void {
$urlGenerator = $this->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']);
}
}
+118
View File
@@ -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 |
+987
View File
@@ -0,0 +1,987 @@
<?php
namespace OCA\Ncbotwebhooks\Tests;
use OCA\Ncbotwebhooks\Service\TalkService;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for TalkService business logic.
*
* All TalkService constructor dependencies are mocked — no real Nextcloud
* instance is required. TalkServiceMockBuilder (in bootstrap.php) assembles
* the mocks; each test configures the specific expectations it needs.
*/
class TalkServiceTest extends TestCase {
/**
* Build a TalkService with a crypto mock that round-trips any string.
*
* @return array{0: TalkService, 1: \PHPUnit\Framework\MockObject\MockObject}
*/
private function makeService(): array {
$builder = new TalkServiceMockBuilder();
$crypto = $builder->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);
}
}
+613
View File
@@ -0,0 +1,613 @@
<?php
namespace OCA\Ncbotwebhooks\Test;
use OCA\Ncbotwebhooks\Controller\WebhookController;
use OCA\Ncbotwebhooks\Service\TalkService;
use OCP\AppFramework\Http;
use OCP\IAppConfig;
use OCP\IAppManager;
use OCP\IConfig;
use OCP\Http\Client\IClientService;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Share\IManager as IShareManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for WebhookController HTTP endpoints.
*
* The controller reads from php://input internally, which can't be mocked
* directly. We test:
* - Auth failure path (returns 401, no input needed)
* - getRooms (returns structured room list)
* - getRooms error handling (returns 500)
* - $_GET fallback for receive (when php://input is empty)
* - $_POST fallback for receiveApprise
* - debug endpoint (disabled/enabled)
* - saveConfig/saveBotPassword edge cases
*
* The payload parsing / mapping logic is covered by TalkServiceTest.
*/
class WebhookControllerTest extends TestCase {
/**
* Build a WebhookController with all dependencies mocked.
*/
private function makeController(TalkService $talkService): WebhookController {
return 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),
$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']);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Nextcloud nc_bot_webhooks — PHPUnit bootstrap
*
* Minimal bootstrap that mocks the Talk app services so TalkService can be
* instantiated without a running Nextcloud instance. Every constructor
* dependency of TalkService gets a PHPUnit mock object.
*/
// Load composer autoloader first
$base = __DIR__ . '/../';
$loader = $base . 'vendor/autoload.php';
if (!file_exists($loader)) {
fwrite(STDERR, "vendor/autoload.php not found — run `composer install`\n");
exit(1);
}
require_once $loader;
// ── Minimal OCP / OC interface stubs ──────────────────────────
// PHPUnit's mock generator needs the actual class/interface definitions
// to inspect method signatures. We provide tiny stubs for anything that
// is not already provided by composer / the real OCP packages.
// OCP\Http\Client\IClientService
if (!interface_exists(\OCP\Http\Client\IClientService::class)) {
namespace OCP\Http\Client {
interface IClientService {
public function getClient(): \OCP\Http\Client\IClient;
}
}
}
if (!interface_exists(\OCP\Http\Client\IClient::class)) {
namespace OCP\Http\Client {
interface IClient {
public function get(string $url, array $options = []): \Psr\Http\Message\ResponseInterface;
public function post(string $url, array $options = []): \Psr\Http\Message\ResponseInterface;
public function delete(string $url): \Psr\Http\Message\ResponseInterface;
}
}
}
if (!interface_exists(\Psr\Http\Message\ResponseInterface::class)) {
// PSR-7 — must already be loaded via composer; guard just in case
namespace { /* nothing */ }
}
namespace {
/**
* Build a TalkService instance with all-mocked dependencies.
*
* Usage:
* $builder = new TalkServiceMockBuilder();
* $service = $builder->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,
);
}
}
}