diff --git a/__tests__/search.ts b/__tests__/search.ts index c6991a229d..f54e80f7a6 100644 --- a/__tests__/search.ts +++ b/__tests__/search.ts @@ -11,12 +11,16 @@ import { import { DataSource } from 'typeorm'; import createOrGetConnection from '../src/db'; import nock from 'nock'; +import { randomUUID } from 'crypto'; +import { offsetToCursor } from 'graphql-relay'; import { magniOrigin, SearchResultFeedback } from '../src/integrations'; import { ArticlePost, Feed, Keyword, + PostType, Source, + SourceMember, SourceUser, User, UserPost, @@ -28,6 +32,9 @@ import { ghostUser, updateFlagsStatement } from '../src/common'; import { ContentPreferenceUser } from '../src/entity/contentPreference/ContentPreferenceUser'; import { ContentPreferenceStatus } from '../src/entity/contentPreference/types'; import { ContentPreferenceSource } from '../src/entity/contentPreference/ContentPreferenceSource'; +import { SourceMemberRoles } from '../src/roles'; +import { mimirClient } from '../src/integrations/mimir'; +import { SearchResponse, SearchResult } from '@dailydotdev/schema'; let con: DataSource; let state: GraphQLTestingState; @@ -1070,3 +1077,173 @@ describe('query searchUserSuggestions', () => { expect(result.hits).toHaveLength(0); }); }); + +describe('query searchSourcePosts', () => { + const QUERY = ({ + source, + query = 'p', + first, + after, + }: { + source: string; + query?: string; + first?: number; + after?: string; + }): string => `{ + searchSourcePosts(source: "${source}", query: "${query}"${ + typeof first === 'number' ? `, first: ${first}` : '' + }${after ? `, after: "${after}"` : ''}) { + query + pageInfo { + hasNextPage + } + edges { + node { + id + } + } + } + }`; + + const mockMimirSearch = (postIds: string[]) => + jest.spyOn(mimirClient, 'search').mockResolvedValue( + new SearchResponse({ + result: postIds.map((postId) => new SearchResult({ postId })), + }), + ); + + beforeEach(async () => { + await saveFixtures(con, Source, sourcesFixture); + await saveFixtures(con, ArticlePost, postsFixture); + await saveFixtures(con, User, usersFixture); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return hydrated posts of a public source in Mimir ranked order', async () => { + mockMimirSearch(['p5', 'p2']); + + const res = await client.query(QUERY({ source: 'b' })); + + expect(res.errors).toBeFalsy(); + expect(res.data.searchSourcePosts.query).toBe('p'); + expect(res.data.searchSourcePosts.edges.map(({ node }) => node.id)).toEqual( + ['p5', 'p2'], + ); + }); + + it('should throw FORBIDDEN for an anonymous user on a private squad', async () => { + mockMimirSearch(['p1']); + + await testQueryErrorCode( + client, + { query: QUERY({ source: 'squad' }) }, + 'FORBIDDEN', + ); + expect(mimirClient.search).not.toHaveBeenCalled(); + }); + + it('should throw FORBIDDEN for a logged in non-member on a private squad', async () => { + loggedUser = '1'; + await con.getRepository(SourceMember).save({ + userId: '1', + sourceId: 'b', + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }); + mockMimirSearch(['p1']); + + await testQueryErrorCode( + client, + { query: QUERY({ source: 'squad' }) }, + 'FORBIDDEN', + ); + }); + + it('should allow a member to search posts of a private squad', async () => { + loggedUser = '1'; + await con.getRepository(ArticlePost).save({ + id: 'squadPost1', + shortId: 'squadPost1', + title: 'Squad Post 1', + url: 'http://squadpost1.com', + score: 1, + sourceId: 'squad', + private: true, + createdAt: new Date(), + type: PostType.Article, + }); + await con.getRepository(SourceMember).save({ + userId: '1', + sourceId: 'squad', + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }); + mockMimirSearch(['squadPost1']); + + const res = await client.query(QUERY({ source: 'squad' })); + + expect(res.errors).toBeFalsy(); + expect(res.data.searchSourcePosts.edges.map(({ node }) => node.id)).toEqual( + ['squadPost1'], + ); + }); + + it('should scope the Mimir request to the source, omit the private filter and include safety filters', async () => { + const search = mockMimirSearch(['p2']); + + await client.query(QUERY({ source: 'b', query: 'hello' })); + + expect(search).toHaveBeenCalledTimes(1); + const searchRequest = search.mock.calls[0][0]; + + expect(searchRequest.filters).toEqual([ + expect.objectContaining({ + field: 'source_id', + condition: expect.objectContaining({ + case: 'stringListFilter', + value: expect.objectContaining({ value: ['b'] }), + }), + }), + expect.objectContaining({ + field: 'deleted', + condition: expect.objectContaining({ + case: 'boolFilter', + value: expect.objectContaining({ value: false }), + }), + }), + expect.objectContaining({ + field: 'visible', + condition: expect.objectContaining({ + case: 'boolFilter', + value: expect.objectContaining({ value: true }), + }), + }), + expect.objectContaining({ + field: 'banned', + condition: expect.objectContaining({ + case: 'boolFilter', + value: expect.objectContaining({ value: false }), + }), + }), + ]); + expect( + searchRequest.filters.some((filter) => filter.field === 'private'), + ).toBe(false); + }); + + it('should compute offset and limit from first/after for pagination', async () => { + const search = mockMimirSearch(['p2']); + + await client.query( + QUERY({ source: 'b', first: 1, after: offsetToCursor(4) }), + ); + + expect(search).toHaveBeenCalledTimes(1); + const searchRequest = search.mock.calls[0][0]; + expect(searchRequest.offset).toBe(5); + expect(searchRequest.limit).toBe(1); + }); +}); diff --git a/src/common/schema/search.ts b/src/common/schema/search.ts new file mode 100644 index 0000000000..f6f611246e --- /dev/null +++ b/src/common/schema/search.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const searchSourcePostsSchema = z.object({ + source: z.string().min(1), + query: z.string().min(1), +}); diff --git a/src/schema/search.ts b/src/schema/search.ts index 9dbed597a4..b2321029cd 100644 --- a/src/schema/search.ts +++ b/src/schema/search.ts @@ -34,6 +34,8 @@ import { whereVordrFilter } from '../common/vordr'; import { ContentPreference } from '../entity/contentPreference/ContentPreference'; import { ContentPreferenceType } from '../entity/contentPreference/types'; import { mimirClient } from '../integrations/mimir'; +import { ensureSourcePermissions } from './sources'; +import { searchSourcePostsSchema } from '../common/schema/search'; import { BoolFilter, Filter, @@ -236,6 +238,41 @@ export const typeDefs = /* GraphQL */ ` columns: Int ): PostConnection! + """ + Search posts in a specific source (squad) + """ + searchSourcePosts( + """ + Id or handle of the source (squad) to search posts in + """ + source: ID! + + """ + The query to search for + """ + query: String! + + """ + Paginate first + """ + first: Int + + """ + Paginate after opaque cursor + """ + after: String + + """ + Array of supported post types + """ + supportedTypes: [String!] + + """ + Version of the search algorithm + """ + version: Int = 2 + ): PostConnection! + """ Get tags for search tags query """ @@ -341,6 +378,23 @@ const mimirSearchResolver = feedResolver( }, ); +const mimirSourcePostsSearchResolver = feedResolver( + ( + ctx, + { ids }: FeedArgs & { ids: string[]; pagination: MeiliPagination }, + builder, + alias, + ) => fixedIdsFeedBuilder(ctx, ids, builder, alias), + mimirOffsetGenerator(), + (ctx, args, page, builder) => builder, + { + removeHiddenPosts: true, + removeBannedPosts: false, + allowPrivatePosts: true, + removeNonPublicThresholdSquads: false, + }, +); + const getTimeRangeForSearchTime = (time: SearchTime) => { const now = new Date(); switch (time) { @@ -450,6 +504,45 @@ const mimirFilterBuilder = ({ return output; }; +const mimirSourcePostsFilterBuilder = ({ + sourceId, +}: { + sourceId: string; +}): Filter[] => [ + new Filter({ + field: 'source_id', + condition: { + value: new StringListFilter({ + value: [sourceId], + quantifier: Quantifier.ANY, + operation: Operation.INCLUDE, + }), + case: MimirFilterCases.StringListFilter, + }, + }), + new Filter({ + field: 'deleted', + condition: { + value: new BoolFilter({ value: false }), + case: MimirFilterCases.BoolFilter, + }, + }), + new Filter({ + field: 'visible', + condition: { + value: new BoolFilter({ value: true }), + case: MimirFilterCases.BoolFilter, + }, + }), + new Filter({ + field: 'banned', + condition: { + value: new BoolFilter({ value: false }), + case: MimirFilterCases.BoolFilter, + }, + }), +]; + export const resolvers: IResolvers = { Query: { searchSessionHistory: async ( @@ -586,6 +679,57 @@ export const resolvers: IResolvers = { query: args.query, }; }, + searchSourcePosts: async ( + source, + args: FeedArgs & { + source: string; + query: string; + version: number; + first: number; + after: string; + }, + ctx: Context, + info, + ): Promise & { query: string }> => { + const { source: sourceId, query } = searchSourcePostsSchema.parse(args); + + await ensureSourcePermissions(ctx, sourceId); + + const limit = args.first || 10; + const offset = getOffsetWithDefault(args.after, -1) + 1; + const searchReq = new SearchRequest({ + query, + version: args.version, + offset, + limit, + filters: mimirSourcePostsFilterBuilder({ sourceId }), + }); + + const mimirSearchRes = await mimirClient.search(searchReq); + const idsArr = mimirSearchRes.result?.length + ? mimirSearchRes.result.map((id) => id.postId) + : ['nosuchid']; + + const res = await mimirSourcePostsSearchResolver( + source, + { + ...args, + ids: idsArr, + pagination: { + limit, + offset, + total: limit + 1, + current: mimirSearchRes.result.length, + }, + }, + ctx, + info, + ); + return { + ...res, + query, + }; + }, searchTagSuggestions: async ( source, { query, limit }: SearchSuggestionArgs,