From bf080cb001c7676b5b2dc97637d0e28f65e290af Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 30 Aug 2026 22:42:07 +0200 Subject: [PATCH] fix(search): omit referenced message from search results (#2172) --- fluxer_api/src/api/search/SearchService.ts | 17 ++- .../MessageSearchReferencedMessage.test.ts | 110 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 fluxer_api/src/api/search/tests/MessageSearchReferencedMessage.test.ts diff --git a/fluxer_api/src/api/search/SearchService.ts b/fluxer_api/src/api/search/SearchService.ts index f6686b055..f5c66a83a 100644 --- a/fluxer_api/src/api/search/SearchService.ts +++ b/fluxer_api/src/api/search/SearchService.ts @@ -3,7 +3,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import type {GlobalSearchMessagesRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; -import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; import {createChannelID, createGuildID, type UserID} from '../BrandedTypes'; import type {IChannelRepository} from '../channel/IChannelRepository'; @@ -15,6 +15,19 @@ import type {IUserRepository} from '../user/IUserRepository'; import type {WorkerTaskName} from '../worker/WorkerLaneConfig'; import {GlobalSearchService} from './GlobalSearchService'; +function omitReferencedMessages(result: MessageSearchResponse): MessageSearchResponse { + if (!('messages' in result)) { + return result; + } + return { + ...result, + messages: result.messages.map((message) => { + const {referenced_message: _referencedMessage, ...rest} = message as MessageResponse; + return rest; + }), + }; +} + export class SearchService { private readonly globalSearch: GlobalSearchService; private readonly channelService: ChannelService; @@ -112,6 +125,6 @@ export class SearchService { } break; } - return result; + return omitReferencedMessages(result); } } diff --git a/fluxer_api/src/api/search/tests/MessageSearchReferencedMessage.test.ts b/fluxer_api/src/api/search/tests/MessageSearchReferencedMessage.test.ts new file mode 100644 index 000000000..72b8d4318 --- /dev/null +++ b/fluxer_api/src/api/search/tests/MessageSearchReferencedMessage.test.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants'; +import type { + MessageResponse, + MessageSearchResponse, + MessageSearchResultsResponse, +} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createGuild} from '../../guild/tests/GuildTestUtils'; +import {markChannelAsIndexed, markGuildChannelsAsIndexed, sendMessage} from '../../message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; +import {SearchService} from '../SearchService'; + +function isSearchResult(response: MessageSearchResponse): response is MessageSearchResultsResponse { + return 'messages' in response; +} + +describe('Message Search Referenced Message', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await harness.shutdown(); + }); + + async function sendReply( + token: string, + channelId: string, + guildId: string, + content: string, + targetMessageId: string, + ): Promise { + return createBuilder(harness, token) + .post(`/channels/${channelId}/messages`) + .body({ + content, + message_reference: { + message_id: targetMessageId, + channel_id: channelId, + guild_id: guildId, + type: MessageReferenceTypes.DEFAULT, + }, + }) + .expect(HTTP_STATUS.OK) + .execute(); + } + + async function searchAndCaptureServiceResult( + token: string, + body: Record, + ): Promise { + const spy = vi.spyOn(SearchService.prototype, 'searchMessages'); + try { + await createBuilder(harness, token).post('/search/messages').body(body).expect(HTTP_STATUS.OK).execute(); + const [invocation] = spy.mock.results; + if (!invocation || invocation.type !== 'return') { + throw new Error('SearchService.searchMessages did not return a result'); + } + return await invocation.value; + } finally { + spy.mockRestore(); + } + } + + test('channel scope omits referenced_message from reply hits', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Reply Search Channel Scope'); + const channelId = guild.system_channel_id!; + const marker = `reply-search-${Date.now()}`; + const original = await sendMessage(harness, account.token, channelId, `${marker} original`); + const reply = await sendReply(account.token, channelId, guild.id, `${marker} answer`, original.id); + await markChannelAsIndexed(harness, channelId); + const result = await searchAndCaptureServiceResult(account.token, { + content: `${marker} answer`, + context_channel_id: channelId, + }); + expect(isSearchResult(result)).toBe(true); + if (!isSearchResult(result)) return; + const hit = result.messages.find((message) => message.id === reply.id); + expect(hit).toBeDefined(); + expect(hit?.message_reference?.message_id).toBe(original.id); + expect(Object.hasOwn(hit!, 'referenced_message')).toBe(false); + }); + + test('guild scope omits referenced_message from reply hits', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Reply Search Guild Scope'); + const channelId = guild.system_channel_id!; + const marker = `reply-guild-search-${Date.now()}`; + const original = await sendMessage(harness, account.token, channelId, `${marker} original`); + const reply = await sendReply(account.token, channelId, guild.id, `${marker} answer`, original.id); + await markGuildChannelsAsIndexed(harness, account.token, guild.id); + const result = await searchAndCaptureServiceResult(account.token, { + content: `${marker} answer`, + context_guild_id: guild.id, + }); + expect(isSearchResult(result)).toBe(true); + if (!isSearchResult(result)) return; + const hit = result.messages.find((message) => message.id === reply.id); + expect(hit).toBeDefined(); + expect(hit?.message_reference?.message_id).toBe(original.id); + expect(Object.hasOwn(hit!, 'referenced_message')).toBe(false); + }); +});