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

346 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Service;
use OCA\EducAI\Db\QueuedRequest;
use OCA\EducAI\Db\QueuedRequestMapper;
use OCA\EducAI\Db\RateLimitState;
use OCA\EducAI\Db\RateLimitStateMapper;
use OCA\EducAI\Db\Settings;
use OCA\EducAI\Service\RateLimitService;
use OCA\EducAI\Service\SettingsService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\BackgroundJob\IJobList;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class RateLimitServiceTest extends TestCase {
public function testGetStatusReturnsSeparateChatAndEmbeddingStates(): void {
$settings = new Settings();
$settings->setRateLimitEnabled(true);
$settings->setRateLimitSecond(2);
$settings->setRateLimitMinute(30);
$settings->setRateLimitHour(500);
$settings->setRateLimitDay(1000);
$chatState = $this->buildState(
RateLimitService::ENDPOINT_CHAT,
2,
30,
500,
1000,
2,
29,
197,
990
);
$embeddingState = $this->buildState(
RateLimitService::ENDPOINT_EMBEDDINGS,
null,
100,
2000,
4000,
null,
99,
1994,
3994
);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')
->willReturn($settings);
$rateLimitMapper = $this->createMock(RateLimitStateMapper::class);
$rateLimitMapper->expects($this->once())
->method('getOrCreate')
->with(RateLimitService::ENDPOINT_CHAT, 2, 30, 500, 1000)
->willReturn($chatState);
$rateLimitMapper->expects($this->once())
->method('findByEndpoint')
->with(RateLimitService::ENDPOINT_EMBEDDINGS)
->willReturn($embeddingState);
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->method('getQueueStats')
->willReturn([
'pending' => 3,
'processing' => 1,
'completed' => 0,
'failed' => 0,
'total' => 4,
]);
$service = new RateLimitService(
$rateLimitMapper,
$queueMapper,
$settingsService,
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
);
$status = $service->getStatus();
$this->assertTrue($status['enabled']);
$this->assertSame($chatState->jsonSerialize(), $status['state']);
$this->assertSame($chatState->jsonSerialize(), $status['chat_status']['state']);
$this->assertTrue($status['chat_status']['can_process']);
$this->assertSame($embeddingState->jsonSerialize(), $status['embedding_status']['state']);
$this->assertTrue($status['embedding_status']['observed']);
$this->assertTrue($status['embedding_status']['can_process']);
$this->assertSame(3, $status['queue_stats']['pending']);
}
public function testGetStatusLeavesEmbeddingStatusEmptyWhenNotObserved(): void {
$settings = new Settings();
$settings->setRateLimitEnabled(true);
$settings->setRateLimitMinute(30);
$settings->setRateLimitHour(500);
$settings->setRateLimitDay(1000);
$settings->setEmbeddingRateLimitMode('disabled');
$chatState = $this->buildState(
RateLimitService::ENDPOINT_CHAT,
null,
30,
500,
1000,
null,
30,
500,
1000
);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')
->willReturn($settings);
$rateLimitMapper = $this->createMock(RateLimitStateMapper::class);
$rateLimitMapper->expects($this->once())
->method('getOrCreate')
->willReturn($chatState);
$rateLimitMapper->expects($this->once())
->method('findByEndpoint')
->with(RateLimitService::ENDPOINT_EMBEDDINGS)
->willThrowException(new DoesNotExistException('Embedding endpoint has not been observed'));
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->method('getQueueStats')
->willReturn([
'pending' => 0,
'processing' => 0,
'completed' => 0,
'failed' => 0,
'total' => 0,
]);
$service = new RateLimitService(
$rateLimitMapper,
$queueMapper,
$settingsService,
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
);
$status = $service->getStatus();
$this->assertFalse($status['embedding_status']['observed']);
$this->assertSame('disabled', $status['embedding_status']['mode']);
$this->assertNull($status['embedding_status']['state']);
$this->assertNull($status['embedding_status']['can_process']);
}
public function testGetStatusBuildsConfiguredEmbeddingStateWhenModeIsCustom(): void {
$settings = new Settings();
$settings->setRateLimitEnabled(true);
$settings->setRateLimitMinute(30);
$settings->setRateLimitHour(500);
$settings->setRateLimitDay(1000);
$settings->setEmbeddingRateLimitMode('custom');
$settings->setEmbeddingRateLimitSecond(4);
$settings->setEmbeddingRateLimitMinute(120);
$settings->setEmbeddingRateLimitHour(2400);
$settings->setEmbeddingRateLimitDay(4800);
$chatState = $this->buildState(
RateLimitService::ENDPOINT_CHAT,
null,
30,
500,
1000,
null,
30,
500,
1000
);
$configuredEmbeddingState = $this->buildState(
RateLimitService::ENDPOINT_EMBEDDINGS,
4,
120,
2400,
4800,
4,
120,
2400,
4800
);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')
->willReturn($settings);
$rateLimitMapper = $this->createMock(RateLimitStateMapper::class);
$rateLimitMapper->expects($this->exactly(2))
->method('getOrCreate')
->willReturnCallback(function (string $endpoint, ?int $second, ?int $minute, ?int $hour, ?int $day) use ($chatState, $configuredEmbeddingState): RateLimitState {
if ($endpoint === RateLimitService::ENDPOINT_CHAT) {
$this->assertNull($second);
$this->assertSame(30, $minute);
$this->assertSame(500, $hour);
$this->assertSame(1000, $day);
return $chatState;
}
$this->assertSame(RateLimitService::ENDPOINT_EMBEDDINGS, $endpoint);
$this->assertSame(4, $second);
$this->assertSame(120, $minute);
$this->assertSame(2400, $hour);
$this->assertSame(4800, $day);
return $configuredEmbeddingState;
});
$rateLimitMapper->expects($this->once())
->method('findByEndpoint')
->with(RateLimitService::ENDPOINT_EMBEDDINGS)
->willThrowException(new DoesNotExistException('Embedding endpoint has not been observed'));
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->method('getQueueStats')
->willReturn([
'pending' => 0,
'processing' => 0,
'completed' => 0,
'failed' => 0,
'total' => 0,
]);
$service = new RateLimitService(
$rateLimitMapper,
$queueMapper,
$settingsService,
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
);
$status = $service->getStatus();
$this->assertSame('custom', $status['embedding_status']['mode']);
$this->assertSame('configured', $status['embedding_status']['source']);
$this->assertFalse($status['embedding_status']['observed']);
$this->assertTrue($status['embedding_status']['can_process']);
$this->assertSame($configuredEmbeddingState->jsonSerialize(), $status['embedding_status']['state']);
}
public function testGetQueuedRequestReturnsNullWhenRequestDoesNotExist(): void {
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->expects($this->once())
->method('findById')
->with(42)
->willThrowException(new DoesNotExistException('not found'));
$service = new RateLimitService(
$this->createMock(RateLimitStateMapper::class),
$queueMapper,
$this->createMock(SettingsService::class),
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
);
$this->assertNull($service->getQueuedRequest(42));
}
public function testQueueRequestStoresTalkReplyAndThreadContext(): void {
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->expects($this->once())
->method('insert')
->willReturnCallback(static fn (QueuedRequest $request): QueuedRequest => $request);
$queueMapper->method('countPending')
->willReturn(1);
$service = new class(
$this->createMock(RateLimitStateMapper::class),
$queueMapper,
$this->createMock(SettingsService::class),
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
) extends RateLimitService {
public function scheduleProcessingJob(): void {}
};
$queued = $service->queueRequest(
7,
'room-token',
'owner',
'continue',
'@personal-bot continue',
100,
42,
42
);
$this->assertSame(42, $queued->getReplyToMessageId());
$this->assertSame(42, $queued->getThreadRootMessageId());
}
public function testGetQueuedRequestDoesNotSwallowMapperFailures(): void {
$queueMapper = $this->createMock(QueuedRequestMapper::class);
$queueMapper->expects($this->once())
->method('findById')
->with(42)
->willThrowException(new \RuntimeException('database unavailable'));
$service = new RateLimitService(
$this->createMock(RateLimitStateMapper::class),
$queueMapper,
$this->createMock(SettingsService::class),
$this->createMock(IJobList::class),
$this->createMock(LoggerInterface::class)
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('database unavailable');
$service->getQueuedRequest(42);
}
private function buildState(
string $endpointKey,
?int $limitSecond,
?int $limitMinute,
?int $limitHour,
?int $limitDay,
?int $remainingSecond,
?int $remainingMinute,
?int $remainingHour,
?int $remainingDay
): RateLimitState {
$now = time();
$state = new RateLimitState();
$state->setEndpointKey($endpointKey);
$state->setLimitSecond($limitSecond);
$state->setLimitMinute($limitMinute);
$state->setLimitHour($limitHour);
$state->setLimitDay($limitDay);
$state->setRemainingSecond($remainingSecond);
$state->setRemainingMinute($remainingMinute);
$state->setRemainingHour($remainingHour);
$state->setRemainingDay($remainingDay);
$state->setResetSecond($limitSecond !== null ? $now + 1 : null);
$state->setResetMinuteAt($limitMinute !== null ? $now + 60 : null);
$state->setResetHour($limitHour !== null ? $now + 3600 : null);
$state->setUpdatedAt($now);
return $state;
}
}