From a2f752d36aa9322878cb453fa29a22a2315ac992 Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Fri, 5 Jun 2026 16:50:18 +0200 Subject: [PATCH 1/5] feat(search): add chat message search to mapper and service with unit tests Signed-off-by: Anna Visman --- lib/Db/ChattyLLM/MessageMapper.php | 29 +++++ lib/Service/ChatService.php | 25 ++++ tests/unit/Service/ChatServiceSearchTest.php | 130 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/unit/Service/ChatServiceSearchTest.php diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index a0c4c0ce8..bab91013e 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -181,4 +181,33 @@ public function deleteMessageById(int $sessionId, int $messageId): void { $qb->executeStatement(); } + + /** + * @param string $userId + * @param string $query + * @param int $limit + * @return list + * @throws \OCP\DB\Exception + */ + public function searchMessages(string $userId, string $query, int $limit = 100): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Message::$columns) + ->from($this->getTableName(), 'm') + ->join('m', 'assistant_chat_sns', 's', + $qb->expr()->eq('m.session_id', 's.id') + ) + ->where($qb->expr()->eq('s.user_id', + $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR) + )) + ->andWhere($qb->expr()->iLike('m.content', + $qb->createPositionalParameter( + '%' . $this->db->escapeLikeParameter($query) . '%', + IQueryBuilder::PARAM_STR + ) + )) + ->orderBy('m.timestamp', 'DESC') + ->setMaxResults($limit); + + return $this->findEntities($qb); + } } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index cfddf61d3..b9ea135b3 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -168,6 +168,31 @@ public function getSessionsForUser(?string $userId): array { } } + /** + * @return array{messages: list>, sessionIds: list} + * @throws UnauthorizedException + * @throws InternalException + */ + public function searchMessages(?string $userId, string $query): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + if (trim($query) === '') { + return ['messages' => [], 'sessionIds' => []]; + } + try { + $messages = $this->messageMapper->searchMessages($userId, $query); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + $sessionIds = array_values(array_unique( + array_map(fn(Message $m) => $m->getSessionId(), $messages) + )); + return [ + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), + 'sessionIds' => $sessionIds, + ]; + } /** * @throws BadRequestException * @throws InternalException diff --git a/tests/unit/Service/ChatServiceSearchTest.php b/tests/unit/Service/ChatServiceSearchTest.php new file mode 100644 index 000000000..7f1514190 --- /dev/null +++ b/tests/unit/Service/ChatServiceSearchTest.php @@ -0,0 +1,130 @@ +messageMapper = $this->createMock(MessageMapper::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->service = new ChatService( + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $l10n, + $this->createMock(SessionMapper::class), + $this->messageMapper, + $this->createMock(SessionSummaryService::class), + $this->createMock(IManager::class), + $this->createMock(LoggerInterface::class), + $this->createMock(ITimeFactory::class), + ); + } + + public function testSearchMessagesUserIdNull(): void { + // UserId = null should throw an error + $this->expectException(UnauthorizedException::class); + $this->service->searchMessages(null, 'hello'); + } + + public function testSearchMessagesBlankQuery(): void { + // A blank query should not hit the database but return empty + $this->messageMapper->expects($this->never()) + ->method('searchMessages'); + + $result = $this->service->searchMessages('user1', ' '); + + $this->assertSame([], $result['messages']); + $this->assertSame([], $result['sessionIds']); + } + + public function testSearchMessagesSameSession(): void { + // Two messages from the same session + $msg1 = new Message(); + $msg1->setSessionId(1); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello assistant'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(1); + $msg2->setRole(Message::ROLE_ASSISTANT); + $msg2->setContent('hello human'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + // Two messages returned + $this->assertCount(2, $result['messages']); + // Check that the messages have the same session ID + $this->assertSame([1], $result['sessionIds']); + } + + public function testSearchMessagesDifferentSession(): void { + $msg1 = new Message(); + $msg1->setSessionId(2); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello from session 2'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(3); + $msg2->setRole(Message::ROLE_HUMAN); + $msg2->setContent('hello from session 3'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + $this->assertCount(2, $result['messages']); + + // Check that the messages have different session IDs + $this->assertSame([2, 3], $result['sessionIds']); + } +} \ No newline at end of file From ce256b2c7d09fa3c1bf7cb70ca34cc9749ad49be Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:04:57 +0200 Subject: [PATCH 2/5] feat(search): add search endpoint to ChattyLLMController and route Signed-off-by: Anna Visman --- appinfo/routes.php | 1 + lib/Controller/ChattyLLMController.php | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index 1022f74c3..63383af03 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -48,6 +48,7 @@ ['name' => 'chattyLLM#deleteMessage', 'url' => '/chat/delete_message', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getMessages', 'url' => '/chat/messages', 'verb' => 'GET'], ['name' => 'chattyLLM#getMessage', 'url' => '/chat/sessions/{sessionId}/messages/{messageId}', 'verb' => 'GET'], + ['name' => 'chattyLLM#searchMessages', 'url' => '/chat/search', 'verb' => 'GET'], ['name' => 'chattyLLM#generateForSession', 'url' => '/chat/generate', 'verb' => 'GET'], ['name' => 'chattyLLM#regenerateForSession', 'url' => '/chat/regenerate', 'verb' => 'GET'], ['name' => 'chattyLLM#checkSession', 'url' => '/chat/check_session', 'verb' => 'GET'], diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 765d8518d..9655df521 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -466,6 +466,32 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } } + /** + * Search chat messages + * + * Search through all chat messages for the current user + * + * @param string $query The search query + * @return JSONResponse, sessionIds: list}, array{}>|JSONResponse + * + * 200: Search results returned successfully + * 401: Not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] + public function searchMessages(string $query): JSONResponse { + try { + $result = $this->chatService->searchMessages($this->userId, $query); + return new JSONResponse($result); + } catch (InternalException $e) { + $this->logger->warning('Failed to search chat messages', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to search chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + } + + /** * Generate a new assistant message * From ef7416bfd8b218ab16d355e4d380e49a6876fd8e Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:07:01 +0200 Subject: [PATCH 3/5] enable CI Signed-off-by: Anna Visman From 002a45dd58a6aafef87efee5c3e875ae2d5013cb Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:28:23 +0200 Subject: [PATCH 4/5] small formatting changes to make the code in line with the existing code Signed-off-by: Anna Visman --- lib/Controller/ChattyLLMController.php | 3 ++- lib/Db/ChattyLLM/MessageMapper.php | 15 +++------------ lib/Service/ChatService.php | 3 ++- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 9655df521..d104afc12 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -480,13 +480,14 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function searchMessages(string $query): JSONResponse { + try { $result = $this->chatService->searchMessages($this->userId, $query); return new JSONResponse($result); } catch (InternalException $e) { $this->logger->warning('Failed to search chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to search chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (UnauthorizedException $e) { + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index bab91013e..454fc336d 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -193,18 +193,9 @@ public function searchMessages(string $userId, string $query, int $limit = 100): $qb = $this->db->getQueryBuilder(); $qb->select(Message::$columns) ->from($this->getTableName(), 'm') - ->join('m', 'assistant_chat_sns', 's', - $qb->expr()->eq('m.session_id', 's.id') - ) - ->where($qb->expr()->eq('s.user_id', - $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR) - )) - ->andWhere($qb->expr()->iLike('m.content', - $qb->createPositionalParameter( - '%' . $this->db->escapeLikeParameter($query) . '%', - IQueryBuilder::PARAM_STR - ) - )) + ->join('m', 'assistant_chat_sns', 's', $qb->expr()->eq('m.session_id', 's.id')) + ->where($qb->expr()->eq('s.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->iLike('m.content', $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter($query) . '%', IQueryBuilder::PARAM_STR))) ->orderBy('m.timestamp', 'DESC') ->setMaxResults($limit); diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b9ea135b3..3df748316 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -177,6 +177,7 @@ public function searchMessages(?string $userId, string $query): array { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } + // For empty queries return two empty lists right away if (trim($query) === '') { return ['messages' => [], 'sessionIds' => []]; } @@ -189,7 +190,7 @@ public function searchMessages(?string $userId, string $query): array { array_map(fn(Message $m) => $m->getSessionId(), $messages) )); return [ - 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays 'sessionIds' => $sessionIds, ]; } From 6e4871f15cb81a99ac41add23a6ad653eac0403d Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Mon, 8 Jun 2026 14:49:24 +0200 Subject: [PATCH 5/5] feat(search)added message search to the UI: search input in sidebar, denounced api calls, filtered session, and a no results message. added a fix to backend: prefix message columns with table alias to avoid ambiguous id in join Signed-off-by: Anna Visman --- lib/Db/ChattyLLM/MessageMapper.php | 2 +- lib/Service/ChatService.php | 4 +- .../ChattyLLM/ChattyLLMInputForm.vue | 47 +++- tests/unit/Service/ChatServiceSearchTest.php | 206 +++++++++--------- 4 files changed, 152 insertions(+), 107 deletions(-) diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 454fc336d..3072e70da 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -191,7 +191,7 @@ public function deleteMessageById(int $sessionId, int $messageId): void { */ public function searchMessages(string $userId, string $query, int $limit = 100): array { $qb = $this->db->getQueryBuilder(); - $qb->select(Message::$columns) + $qb->select(array_map(fn ($col) => 'm.' . $col, Message::$columns)) ->from($this->getTableName(), 'm') ->join('m', 'assistant_chat_sns', 's', $qb->expr()->eq('m.session_id', 's.id')) ->where($qb->expr()->eq('s.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 3df748316..3ad14a902 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -187,10 +187,10 @@ public function searchMessages(?string $userId, string $query): array { throw new InternalException(previous: $e); } $sessionIds = array_values(array_unique( - array_map(fn(Message $m) => $m->getSessionId(), $messages) + array_map(fn (Message $m) => $m->getSessionId(), $messages) )); return [ - 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays + 'messages' => array_map(fn (Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays 'sessionIds' => $sessionIds, ]; } diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 31e613941..51ef922ee 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -13,6 +13,9 @@ +
{{ t('assistant', 'Loading conversations…') }} @@ -20,8 +23,11 @@
{{ t('assistant', 'No conversations yet') }}
+
+ {{ t('assistant', 'No conversations match your search') }} +
generateOcsUrl('/apps/assistant/chat' + endpoint) + const Roles = { HUMAN: 'human', ASSISTANT: 'assistant', @@ -247,6 +255,7 @@ export default { NcAppNavigationItem, NcAppNavigationList, NcAppNavigationNew, + NcAppNavigationSearch, NcButton, NcLoadingIcon, NcDialog, @@ -279,6 +288,7 @@ export default { newSession: false, messageDelete: false, sessionDelete: false, + search: false, }, msgCursor: 0, msgLimit: 20, @@ -303,6 +313,9 @@ export default { message: t('assisant', 'Which actions can you do for me?'), }, ], + searchQuery: '', + searchResults: null, + searchDebounceTimer: null, } }, @@ -315,6 +328,13 @@ export default { const sessionTitle = this.getSessionTitle(session)?.trim() return t('assistant', 'Are you sure you want to delete "{sessionTitle}"?', { sessionTitle }) }, + + filteredSessions() { + if (!this.sessions || this.searchResults === null) { + return this.sessions + } + return this.sessions.filter(s => this.searchResults.sessionIds.includes(s.id)) + }, }, watch: { @@ -392,6 +412,17 @@ export default { this.loading.titleGeneration = false } }, + + searchQuery(newVal) { + clearTimeout(this.searchDebounceTimer) + if (!newVal.trim()) { + this.searchResults = null + return + } + this.searchDebounceTimer = setTimeout(() => { + this.performSearch(newVal.trim()) + }, 350) + }, }, beforeUnmount() { @@ -913,6 +944,20 @@ export default { const url = generateUrl('/apps/assistant/config') return axios.put(url, req) }, + + async performSearch(query) { + this.loading.search = true + try { + const response = await axios.get(getChatURL('/search'), { params: { query } }) + this.searchResults = response.data + } catch (error) { + console.error('Search error:', error) + showError(error?.response?.data?.error ?? t('assistant', 'Error searching messages')) + this.searchResults = null + } finally { + this.loading.search = false + } + }, }, } diff --git a/tests/unit/Service/ChatServiceSearchTest.php b/tests/unit/Service/ChatServiceSearchTest.php index 7f1514190..945533e50 100644 --- a/tests/unit/Service/ChatServiceSearchTest.php +++ b/tests/unit/Service/ChatServiceSearchTest.php @@ -25,106 +25,106 @@ class ChatServiceSearchTest extends TestCase { - private ChatService $service; - private MessageMapper $messageMapper; - - protected function setUp(): void { - parent::setUp(); - - // Mock all ChatService dependencies - $this->messageMapper = $this->createMock(MessageMapper::class); - - $l10n = $this->createMock(IL10N::class); - $l10n->method('t')->willReturnArgument(0); - - $this->service = new ChatService( - $this->createMock(IUserManager::class), - $this->createMock(IAppConfig::class), - $l10n, - $this->createMock(SessionMapper::class), - $this->messageMapper, - $this->createMock(SessionSummaryService::class), - $this->createMock(IManager::class), - $this->createMock(LoggerInterface::class), - $this->createMock(ITimeFactory::class), - ); - } - - public function testSearchMessagesUserIdNull(): void { - // UserId = null should throw an error - $this->expectException(UnauthorizedException::class); - $this->service->searchMessages(null, 'hello'); - } - - public function testSearchMessagesBlankQuery(): void { - // A blank query should not hit the database but return empty - $this->messageMapper->expects($this->never()) - ->method('searchMessages'); - - $result = $this->service->searchMessages('user1', ' '); - - $this->assertSame([], $result['messages']); - $this->assertSame([], $result['sessionIds']); - } - - public function testSearchMessagesSameSession(): void { - // Two messages from the same session - $msg1 = new Message(); - $msg1->setSessionId(1); - $msg1->setRole(Message::ROLE_HUMAN); - $msg1->setContent('hello assistant'); - $msg1->setTimestamp(1000); - $msg1->setSources('[]'); - $msg1->setAttachments('[]'); - - $msg2 = new Message(); - $msg2->setSessionId(1); - $msg2->setRole(Message::ROLE_ASSISTANT); - $msg2->setContent('hello human'); - $msg2->setTimestamp(1001); - $msg2->setSources('[]'); - $msg2->setAttachments('[]'); - - $this->messageMapper->expects($this->once()) - ->method('searchMessages') - ->with('user1', 'hello') - ->willReturn([$msg1, $msg2]); - - $result = $this->service->searchMessages('user1', 'hello'); - - // Two messages returned - $this->assertCount(2, $result['messages']); - // Check that the messages have the same session ID - $this->assertSame([1], $result['sessionIds']); - } - - public function testSearchMessagesDifferentSession(): void { - $msg1 = new Message(); - $msg1->setSessionId(2); - $msg1->setRole(Message::ROLE_HUMAN); - $msg1->setContent('hello from session 2'); - $msg1->setTimestamp(1000); - $msg1->setSources('[]'); - $msg1->setAttachments('[]'); - - $msg2 = new Message(); - $msg2->setSessionId(3); - $msg2->setRole(Message::ROLE_HUMAN); - $msg2->setContent('hello from session 3'); - $msg2->setTimestamp(1001); - $msg2->setSources('[]'); - $msg2->setAttachments('[]'); - - $this->messageMapper->expects($this->once()) - ->method('searchMessages') - ->with('user1', 'hello') - ->willReturn([$msg1, $msg2]); - - $result = $this->service->searchMessages('user1', 'hello'); - - $this->assertCount(2, $result['messages']); - - // Check that the messages have different session IDs - $this->assertSame([2, 3], $result['sessionIds']); - } -} \ No newline at end of file + private ChatService $service; + private MessageMapper $messageMapper; + + protected function setUp(): void { + parent::setUp(); + + // Mock all ChatService dependencies + $this->messageMapper = $this->createMock(MessageMapper::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->service = new ChatService( + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $l10n, + $this->createMock(SessionMapper::class), + $this->messageMapper, + $this->createMock(SessionSummaryService::class), + $this->createMock(IManager::class), + $this->createMock(LoggerInterface::class), + $this->createMock(ITimeFactory::class), + ); + } + + public function testSearchMessagesUserIdNull(): void { + // UserId = null should throw an error + $this->expectException(UnauthorizedException::class); + $this->service->searchMessages(null, 'hello'); + } + + public function testSearchMessagesBlankQuery(): void { + // A blank query should not hit the database but return empty + $this->messageMapper->expects($this->never()) + ->method('searchMessages'); + + $result = $this->service->searchMessages('user1', ' '); + + $this->assertSame([], $result['messages']); + $this->assertSame([], $result['sessionIds']); + } + + public function testSearchMessagesSameSession(): void { + // Two messages from the same session + $msg1 = new Message(); + $msg1->setSessionId(1); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello assistant'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(1); + $msg2->setRole(Message::ROLE_ASSISTANT); + $msg2->setContent('hello human'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + // Two messages returned + $this->assertCount(2, $result['messages']); + // Check that the messages have the same session ID + $this->assertSame([1], $result['sessionIds']); + } + + public function testSearchMessagesDifferentSession(): void { + $msg1 = new Message(); + $msg1->setSessionId(2); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello from session 2'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(3); + $msg2->setRole(Message::ROLE_HUMAN); + $msg2->setContent('hello from session 3'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + $this->assertCount(2, $result['messages']); + + // Check that the messages have different session IDs + $this->assertSame([2, 3], $result['sessionIds']); + } +}