Files
nc-talk-ai/tests/unit/Service/RoomImageIngestionServiceTest.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

179 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Service;
use OCA\EducAI\Db\RoomImageEmbedding;
use OCA\EducAI\Db\RoomImageEmbeddingMapper;
use OCA\EducAI\Db\RoomImageSource;
use OCA\EducAI\Db\RoomImageSourceMapper;
use OCA\EducAI\Service\AttachmentResolver;
use OCA\EducAI\Service\EmbeddingClient;
use OCA\EducAI\Service\ResolvedAttachment;
use OCA\EducAI\Service\RoomImageIngestionService;
use OCA\EducAI\Service\SettingsService;
use OCA\EducAI\Service\VisionClient;
use OCA\EducAI\Webhook\IncomingTalkAttachment;
use OCP\Files\File;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class RoomImageIngestionServiceTest extends TestCase {
public function testIngestsImageAnalysisAndEmbedding(): void {
$sourceMapper = $this->createMock(RoomImageSourceMapper::class);
$embeddingMapper = $this->createMock(RoomImageEmbeddingMapper::class);
$attachmentResolver = $this->createMock(AttachmentResolver::class);
$visionClient = $this->createMock(VisionClient::class);
$embeddingClient = $this->createMock(EmbeddingClient::class);
$settingsService = $this->createSettingsService();
$file = $this->createMock(File::class);
$attachment = $this->createImageAttachment();
$tempPath = tempnam(sys_get_temp_dir(), 'educai-img-');
file_put_contents($tempPath, 'image-bytes');
$file->method('getId')->willReturn(42);
$file->method('getContent')->willReturn('image-bytes');
$attachmentResolver->method('resolveSourceFile')->with($attachment)->willReturn($file);
$attachmentResolver->method('resolveToTempFile')->with($attachment)->willReturn(new ResolvedAttachment($tempPath, 'login-error.png', 'image/png'));
$sourceMapper->method('findOneByBotRoomAndNode')->willReturn(null);
$sourceMapper->method('findOneByBotRoomAndAttachment')->willReturn(null);
$sourceMapper->expects($this->once())
->method('insert')
->willReturnCallback(static function (RoomImageSource $source): RoomImageSource {
$source->setId(77);
return $source;
});
$sourceMapper->expects($this->once())
->method('update')
->willReturnCallback(static fn (RoomImageSource $source): RoomImageSource => $source);
$visionClient->method('isEnabled')->willReturn(true);
$visionClient->expects($this->once())
->method('analyzeImage')
->willReturn('Screenshot shows a red login error banner.');
$embeddingClient->expects($this->once())
->method('getActiveModel')
->willReturn('embed-model');
$embeddingClient->expects($this->once())
->method('embedTexts')
->with(['Screenshot shows a red login error banner.'], 'embed-model')
->willReturn([[1.0, 0.0]]);
$embeddingMapper->expects($this->once())
->method('deleteBySource')
->with(77);
$embeddingMapper->expects($this->once())
->method('insert')
->with($this->callback(static function (RoomImageEmbedding $embedding): bool {
return $embedding->getSourceId() === 77
&& $embedding->getChunkText() === 'Screenshot shows a red login error banner.'
&& str_contains((string)$embedding->getMetadata(), 'login-error.png');
}));
$service = new RoomImageIngestionService(
$sourceMapper,
$embeddingMapper,
$attachmentResolver,
$visionClient,
$embeddingClient,
$settingsService,
$this->createMock(LoggerInterface::class)
);
$result = $service->ingestAttachment(7, 'room-a', 'alice', 1234, $attachment);
$this->assertSame(77, $result->getId());
$this->assertSame('ready', $result->getStatus());
$this->assertSame('login-error.png', $result->getDisplayName());
$this->assertSame('Screenshot shows a red login error banner.', $result->getAnalysisText());
}
public function testReingestingUnchangedImageDoesNotCallVisionAgain(): void {
$sourceMapper = $this->createMock(RoomImageSourceMapper::class);
$embeddingMapper = $this->createMock(RoomImageEmbeddingMapper::class);
$attachmentResolver = $this->createMock(AttachmentResolver::class);
$visionClient = $this->createMock(VisionClient::class);
$embeddingClient = $this->createMock(EmbeddingClient::class);
$settingsService = $this->createSettingsService();
$file = $this->createMock(File::class);
$attachment = $this->createImageAttachment();
$existing = new RoomImageSource();
$existing->setId(77);
$existing->setChecksum(sha1('image-bytes'));
$existing->setAnalysisText('Existing analysis');
$file->method('getId')->willReturn(42);
$file->method('getContent')->willReturn('image-bytes');
$attachmentResolver->method('resolveSourceFile')->with($attachment)->willReturn($file);
$sourceMapper->method('findOneByBotRoomAndNode')->willReturn($existing);
$sourceMapper->expects($this->exactly(2))
->method('update')
->willReturnCallback(static fn (RoomImageSource $source): RoomImageSource => $source);
$visionClient->method('isEnabled')->willReturn(true);
$visionClient->expects($this->never())->method('analyzeImage');
$embeddingMapper->expects($this->once())
->method('findBySource')
->with(77)
->willReturn([new RoomImageEmbedding()]);
$embeddingClient->expects($this->never())->method('embedTexts');
$service = new RoomImageIngestionService(
$sourceMapper,
$embeddingMapper,
$attachmentResolver,
$visionClient,
$embeddingClient,
$settingsService,
$this->createMock(LoggerInterface::class)
);
$result = $service->ingestAttachment(7, 'room-a', 'alice', 1234, $attachment);
$this->assertSame('ready', $result->getStatus());
$this->assertSame('Existing analysis', $result->getAnalysisText());
}
public function testDisabledVisionReturnsClearError(): void {
$visionClient = $this->createMock(VisionClient::class);
$visionClient->method('isEnabled')->willReturn(false);
$service = new RoomImageIngestionService(
$this->createMock(RoomImageSourceMapper::class),
$this->createMock(RoomImageEmbeddingMapper::class),
$this->createMock(AttachmentResolver::class),
$visionClient,
$this->createMock(EmbeddingClient::class),
$this->createSettingsService(),
$this->createMock(LoggerInterface::class)
);
$this->expectExceptionMessage('Room image ingestion is not enabled');
$service->ingestAttachment(7, 'room-a', 'alice', 1234, $this->createImageAttachment());
}
private function createSettingsService(): SettingsService {
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getRagConfig')->willReturn([
'rag_enabled' => true,
'embedding_model' => 'embed-model',
]);
$settingsService->method('getVisionConfig')->willReturn([
'enabled' => true,
'model' => 'vision-model',
]);
return $settingsService;
}
private function createImageAttachment(): IncomingTalkAttachment {
return new IncomingTalkAttachment(
IncomingTalkAttachment::KIND_IMAGE,
'file',
'image/png',
'login-error.png',
'file',
[
'fileId' => 42,
]
);
}
}