Files
nc-talk-ai/tests/unit/Controller/TalkBotControllerTest.php
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

335 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Controller;
use OCA\EducAI\AppInfo\Application;
use OCA\EducAI\Controller\TalkBotController;
use OCA\EducAI\Db\Bot;
use OCA\EducAI\Db\BotMapper;
use OCA\EducAI\Service\BotService;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IRequest;
use OCP\IURLGenerator;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class TalkBotControllerTest extends TestCase {
public function testRoomsRequiresAuthenticatedUser(): void {
$client = $this->createMock(IClient::class);
$client->expects($this->never())->method('get');
$client->expects($this->never())->method('post');
$controller = $this->createController($client, $this->createRequest(), null);
$response = $controller->rooms();
$this->assertSame(401, $response->getStatus());
$this->assertSame('Not authenticated', $response->getData()['error']);
}
public function testRoomsMapsAndSortsAccessibleConversations(): void {
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('get')
->with($this->stringContains('/ocs/v2.php/apps/spreed/api/v4/room'))
->willReturn($this->createResponse(['ocs' => ['data' => [
[
'token' => 'member-room',
'displayName' => 'Member Room',
'type' => 2,
'participantType' => 3,
'readOnly' => 0,
'lastActivity' => 300,
],
[
'token' => 'mod-room',
'displayName' => 'Moderator Room',
'type' => 2,
'participantType' => 2,
'readOnly' => 0,
'lastActivity' => 100,
],
]]]));
$controller = $this->createController($client);
$response = $controller->rooms();
$rooms = $response->getData()['rooms'];
$this->assertSame(200, $response->getStatus());
$this->assertSame('mod-room', $rooms[0]['token']);
$this->assertTrue($rooms[0]['isModerator']);
$this->assertSame('member-room', $rooms[1]['token']);
$this->assertFalse($rooms[1]['isModerator']);
}
public function testEnableBotReturnsForbiddenForNonModeratorsWhenBotIsDisabled(): void {
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$this->createResponse(['ocs' => ['data' => ['participantType' => 3]]]),
$this->createResponse(['ocs' => ['data' => [
['id' => 42, 'name' => Application::APP_DISPLAY_NAME, 'state' => 0],
]]])
);
$client->expects($this->never())->method('post');
$controller = $this->createController($client);
$response = $controller->enableBot('room-token');
$this->assertSame(403, $response->getStatus());
$this->assertSame(
'You do not have permission to enable bots in this conversation. Only moderators can enable bots.',
$response->getData()['error']
);
}
public function testEnableBotKeepsAlreadyEnabledResponseIdempotentForNonModerators(): void {
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$this->createResponse(['ocs' => ['data' => ['participantType' => 3]]]),
$this->createResponse(['ocs' => ['data' => [
['id' => 42, 'name' => Application::APP_DISPLAY_NAME, 'state' => 1],
]]])
);
$client->expects($this->never())->method('post');
$controller = $this->createController($client);
$response = $controller->enableBot('room-token');
$this->assertSame(200, $response->getStatus());
$this->assertTrue($response->getData()['success']);
$this->assertSame('Bot already enabled', $response->getData()['message']);
}
public function testStartBotChatCreatesRoomEnablesBotAndSendsMessage(): void {
$bot = $this->createBot(12, 'Catalogue', 'catalogue');
$botMapper = $this->createMock(BotMapper::class);
$botMapper->expects($this->once())->method('findById')->with(12)->willReturn($bot);
$botService = $this->createMock(BotService::class);
$botService->expects($this->once())->method('userCanAccessBot')->with($bot, 'alice')->willReturn(true);
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('get')
->with($this->stringContains('/ocs/v2.php/apps/spreed/api/v1/bot/new-token'))
->willReturn($this->createResponse(['ocs' => ['data' => [
['id' => 42, 'name' => Application::APP_DISPLAY_NAME, 'state' => 0],
]]]));
$postCalls = [];
$client->expects($this->exactly(3))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$postCalls): IResponse {
$postCalls[] = ['uri' => $uri, 'options' => $options];
if (count($postCalls) === 1) {
return $this->createResponse(['ocs' => ['data' => [
'token' => 'new-token',
]]]);
}
if (count($postCalls) === 2) {
return $this->createResponse(['ocs' => ['data' => []]]);
}
return $this->createResponse(['ocs' => ['data' => ['id' => 456]]]);
});
$controller = $this->createController($client, $this->createRequest([
'botId' => 12,
'mode' => 'new',
'roomName' => '',
'message' => 'What can you help me with?',
'sendMessage' => true,
]), 'alice', $botMapper, $botService);
$response = $controller->startBotChat();
$this->assertSame(200, $response->getStatus());
$this->assertSame('new-token', $response->getData()['roomToken']);
$this->assertSame('https://nextcloud.local/index.php/call/new-token', $response->getData()['talkUrl']);
$this->assertTrue($response->getData()['botEnabled']);
$this->assertFalse($response->getData()['botWasAlreadyEnabled']);
$this->assertTrue($response->getData()['messageSent']);
$this->assertSame(456, $response->getData()['messageId']);
$this->assertStringContainsString('/api/v4/room', $postCalls[0]['uri']);
$this->assertSame(2, $postCalls[0]['options']['body']['roomType']);
$this->assertSame('Chat with Catalogue', $postCalls[0]['options']['body']['roomName']);
$this->assertStringContainsString('/api/v1/bot/new-token/42', $postCalls[1]['uri']);
$this->assertStringContainsString('/api/v1/chat/new-token', $postCalls[2]['uri']);
$this->assertSame('@catalogue What can you help me with?', $postCalls[2]['options']['body']['message']);
}
public function testStartBotChatExistingRoomWithoutModeratorRightsReturnsForbiddenWhenBotDisabled(): void {
$bot = $this->createBot(12, 'Catalogue', '@catalogue');
$botMapper = $this->createMock(BotMapper::class);
$botMapper->method('findById')->with(12)->willReturn($bot);
$botService = $this->createMock(BotService::class);
$botService->method('userCanAccessBot')->with($bot, 'alice')->willReturn(true);
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$this->createResponse(['ocs' => ['data' => ['token' => 'shared-room', 'participantType' => 3]]]),
$this->createResponse(['ocs' => ['data' => [
['id' => 42, 'name' => Application::APP_DISPLAY_NAME, 'state' => 0],
]]])
);
$client->expects($this->never())->method('post');
$controller = $this->createController($client, $this->createRequest([
'botId' => 12,
'mode' => 'existing',
'roomToken' => 'shared-room',
'message' => '@catalogue Hello',
'sendMessage' => true,
]), 'alice', $botMapper, $botService);
$response = $controller->startBotChat();
$this->assertSame(403, $response->getStatus());
$this->assertSame(
'You do not have permission to enable bots in this conversation. Only moderators can enable bots.',
$response->getData()['error']
);
}
public function testStartBotChatExistingRoomWithEnabledBotCanSendMessage(): void {
$bot = $this->createBot(12, 'Catalogue', '@catalogue');
$botMapper = $this->createMock(BotMapper::class);
$botMapper->method('findById')->with(12)->willReturn($bot);
$botService = $this->createMock(BotService::class);
$botService->method('userCanAccessBot')->with($bot, 'alice')->willReturn(true);
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$this->createResponse(['ocs' => ['data' => ['token' => 'shared-room', 'participantType' => 3]]]),
$this->createResponse(['ocs' => ['data' => [
['id' => 42, 'name' => Application::APP_DISPLAY_NAME, 'state' => 1],
]]])
);
$client->expects($this->once())
->method('post')
->with($this->stringContains('/ocs/v2.php/apps/spreed/api/v1/chat/shared-room'))
->willReturn($this->createResponse(['ocs' => ['data' => ['id' => 789]]]));
$controller = $this->createController($client, $this->createRequest([
'botId' => 12,
'mode' => 'existing',
'roomToken' => 'shared-room',
'message' => 'Hello there',
'sendMessage' => true,
]), 'alice', $botMapper, $botService);
$response = $controller->startBotChat();
$this->assertSame(200, $response->getStatus());
$this->assertSame('shared-room', $response->getData()['roomToken']);
$this->assertTrue($response->getData()['botWasAlreadyEnabled']);
$this->assertTrue($response->getData()['messageSent']);
$this->assertSame(789, $response->getData()['messageId']);
}
public function testStartBotChatReturnsUsefulErrorWhenRoomCreationFails(): void {
$bot = $this->createBot(12, 'Catalogue', '@catalogue');
$botMapper = $this->createMock(BotMapper::class);
$botMapper->method('findById')->with(12)->willReturn($bot);
$botService = $this->createMock(BotService::class);
$botService->method('userCanAccessBot')->with($bot, 'alice')->willReturn(true);
$client = $this->createMock(IClient::class);
$client->expects($this->never())->method('get');
$client->expects($this->once())
->method('post')
->with($this->stringContains('/ocs/v2.php/apps/spreed/api/v4/room'))
->willReturn($this->createResponse(['ocs' => ['data' => []]], 500));
$controller = $this->createController($client, $this->createRequest([
'botId' => 12,
'mode' => 'new',
'roomName' => 'Chat with Catalogue',
'sendMessage' => false,
]), 'alice', $botMapper, $botService);
$response = $controller->startBotChat();
$this->assertSame(502, $response->getStatus());
$this->assertStringContainsString('Talk could not complete this action right now', $response->getData()['error']);
}
private function createController(
IClient $client,
?IRequest $request = null,
?string $userId = 'alice',
?BotMapper $botMapper = null,
?BotService $botService = null
): TalkBotController {
$request ??= $this->createRequest();
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('getAbsoluteURL')->willReturnCallback(static function (string $url): string {
if ($url === '') {
return 'https://nextcloud.local/';
}
return 'https://nextcloud.local/' . ltrim($url, '/');
});
$clientService = $this->createMock(IClientService::class);
$clientService->method('newClient')->willReturn($client);
return new TalkBotController(
'educai',
$request,
$clientService,
$urlGenerator,
$botMapper ?? $this->createMock(BotMapper::class),
$botService ?? $this->createMock(BotService::class),
$this->createMock(LoggerInterface::class),
$userId
);
}
private function createRequest(array $params = []): IRequest {
$request = $this->createMock(IRequest::class);
$request->method('getHeader')->with('Authorization')->willReturn('');
$request->method('getServerHost')->willReturn('nextcloud.local');
$request->method('getParam')->willReturnCallback(static function (string $key, $default = null) use ($params) {
return $params[$key] ?? $default;
});
return $request;
}
private function createBot(int $id, string $botName, string $mentionName): Bot {
$bot = new Bot();
$bot->setId($id);
$bot->setBotName($botName);
$bot->setMentionName($mentionName);
$bot->setIsActive(true);
return $bot;
}
private function createResponse(array $payload, int $statusCode = 200): IResponse {
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn(json_encode($payload));
$response->method('getStatusCode')->willReturn($statusCode);
return $response;
}
}