Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions __tests__/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
});
});
6 changes: 6 additions & 0 deletions src/common/schema/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from 'zod';

export const searchSourcePostsSchema = z.object({
source: z.string().min(1),
query: z.string().min(1),
});
144 changes: 144 additions & 0 deletions src/schema/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<unknown, BaseContext> = {
Query: {
searchSessionHistory: async (
Expand Down Expand Up @@ -586,6 +679,57 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
query: args.query,
};
},
searchSourcePosts: async (
source,
args: FeedArgs & {
source: string;
query: string;
version: number;
first: number;
after: string;
},
ctx: Context,
info,
): Promise<ConnectionRelay<GQLPost> & { 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,
Expand Down
Loading