diff --git a/backend/src/middleware/filterBubble/filterBubble.js b/backend/src/middleware/filterBubble/filterBubble.js new file mode 100644 index 000000000..bfdad5e2c --- /dev/null +++ b/backend/src/middleware/filterBubble/filterBubble.js @@ -0,0 +1,12 @@ +import replaceParams from './replaceParams' + +const replaceFilterBubbleParams = async (resolve, root, args, context, resolveInfo) => { + args = await replaceParams(args, context) + return resolve(root, args, context, resolveInfo) +} + +export default { + Query: { + Post: replaceFilterBubbleParams, + }, +} diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js new file mode 100644 index 000000000..afe1df1c9 --- /dev/null +++ b/backend/src/middleware/filterBubble/filterBubble.spec.js @@ -0,0 +1,76 @@ +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../../jest/helpers' +import Factory from '../../seed/factories' + +const factory = Factory() + +const currentUserParams = { + email: 'you@example.org', + name: 'This is you', + password: '1234', +} +const followedAuthorParams = { + id: 'u2', + email: 'followed@example.org', + name: 'Followed User', + password: '1234', +} +const randomAuthorParams = { + email: 'someone@example.org', + name: 'Someone else', + password: 'else', +} + +beforeEach(async () => { + await Promise.all([ + factory.create('User', currentUserParams), + factory.create('User', followedAuthorParams), + factory.create('User', randomAuthorParams), + ]) + const [asYourself, asFollowedUser, asSomeoneElse] = await Promise.all([ + Factory().authenticateAs(currentUserParams), + Factory().authenticateAs(followedAuthorParams), + Factory().authenticateAs(randomAuthorParams), + ]) + await asYourself.follow({ id: 'u2', type: 'User' }) + await asFollowedUser.create('Post', { title: 'This is the post of a followed user' }) + await asSomeoneElse.create('Post', { title: 'This is some random post' }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('FilterBubble middleware', () => { + describe('given an authenticated user', () => { + let authenticatedClient + + beforeEach(async () => { + const headers = await login(currentUserParams) + authenticatedClient = new GraphQLClient(host, { headers }) + }) + + describe('no filter bubble', () => { + it('returns all posts', async () => { + const query = '{ Post( filterBubble: {}) { title } }' + const expected = { + Post: [ + { title: 'This is some random post' }, + { title: 'This is the post of a followed user' }, + ], + } + await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + }) + }) + + describe('filtering for posts of followed users only', () => { + it('returns only posts authored by followed users', async () => { + const query = '{ Post( filterBubble: { author: following }) { title } }' + const expected = { + Post: [{ title: 'This is the post of a followed user' }], + } + await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + }) + }) + }) +}) diff --git a/backend/src/middleware/filterBubble/replaceParams.js b/backend/src/middleware/filterBubble/replaceParams.js new file mode 100644 index 000000000..a10b6c29d --- /dev/null +++ b/backend/src/middleware/filterBubble/replaceParams.js @@ -0,0 +1,31 @@ +import { UserInputError } from 'apollo-server' + +export default async function replaceParams(args, context) { + const { author = 'all' } = args.filterBubble || {} + const { user } = context + + if (author === 'following') { + if (!user) + throw new UserInputError( + "You are unauthenticated - I don't know any users you are following.", + ) + + const session = context.driver.session() + let { records } = await session.run( + 'MATCH(followed:User)<-[:FOLLOWS]-(u {id: $userId}) RETURN followed.id', + { userId: context.user.id }, + ) + const followedIds = records.map(record => record.get('followed.id')) + + // carefully override `id_in` + args.filter = args.filter || {} + args.filter.author = args.filter.author || {} + args.filter.author.id_in = followedIds + + session.close() + } + + delete args.filterBubble + + return args +} diff --git a/backend/src/middleware/filterBubble/replaceParams.spec.js b/backend/src/middleware/filterBubble/replaceParams.spec.js new file mode 100644 index 000000000..e14fda416 --- /dev/null +++ b/backend/src/middleware/filterBubble/replaceParams.spec.js @@ -0,0 +1,129 @@ +import replaceParams from './replaceParams.js' + +describe('replaceParams', () => { + let args + let context + let run + + let action = () => { + return replaceParams(args, context) + } + + beforeEach(() => { + args = {} + run = jest.fn().mockResolvedValue({ + records: [{ get: () => 1 }, { get: () => 2 }, { get: () => 3 }], + }) + context = { + driver: { + session: () => { + return { + run, + close: () => {}, + } + }, + }, + } + }) + + describe('args == ', () => { + describe('{}', () => { + it('does not crash', async () => { + await expect(action()).resolves.toEqual({}) + }) + }) + + describe('unauthenticated user', () => { + beforeEach(() => { + context.user = null + }) + + describe('{ filterBubble: { author: following } }', () => { + it('throws error', async () => { + args = { filterBubble: { author: 'following' } } + await expect(action()).rejects.toThrow('You are unauthenticated') + }) + }) + + describe('{ filterBubble: { author: all } }', () => { + it('removes filterBubble param', async () => { + const expected = {} + await expect(action()).resolves.toEqual(expected) + }) + + it('does not make database calls', async () => { + await action() + expect(run).not.toHaveBeenCalled() + }) + }) + }) + + describe('authenticated user', () => { + beforeEach(() => { + context.user = { id: 'u4711' } + }) + + describe('{ filterBubble: { author: following } }', () => { + beforeEach(() => { + args = { filterBubble: { author: 'following' } } + }) + + it('returns args object with resolved ids of followed users', async () => { + const expected = { filter: { author: { id_in: [1, 2, 3] } } } + await expect(action()).resolves.toEqual(expected) + }) + + it('makes database calls', async () => { + await action() + expect(run).toHaveBeenCalledTimes(1) + }) + + describe('given any additional filter args', () => { + describe('merges', () => { + it('empty filter object', async () => { + args.filter = {} + const expected = { filter: { author: { id_in: [1, 2, 3] } } } + await expect(action()).resolves.toEqual(expected) + }) + + it('filter.title', async () => { + args.filter = { title: 'bla' } + const expected = { filter: { title: 'bla', author: { id_in: [1, 2, 3] } } } + await expect(action()).resolves.toEqual(expected) + }) + + it('filter.author', async () => { + args.filter = { author: { name: 'bla' } } + const expected = { filter: { author: { name: 'bla', id_in: [1, 2, 3] } } } + await expect(action()).resolves.toEqual(expected) + }) + }) + }) + }) + + describe('{ filterBubble: { } }', () => { + it('removes filterBubble param', async () => { + const expected = {} + await expect(action()).resolves.toEqual(expected) + }) + + it('does not make database calls', async () => { + await action() + expect(run).not.toHaveBeenCalled() + }) + }) + + describe('{ filterBubble: { author: all } }', () => { + it('removes filterBubble param', async () => { + const expected = {} + await expect(action()).resolves.toEqual(expected) + }) + + it('does not make database calls', async () => { + await action() + expect(run).not.toHaveBeenCalled() + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 75314abc0..6bc7be000 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -13,6 +13,7 @@ import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation' import notifications from './notifications' +import filterBubble from './filterBubble/filterBubble' export default schema => { const middlewares = { @@ -30,11 +31,13 @@ export default schema => { user: user, includedFields: includedFields, orderBy: orderBy, + filterBubble: filterBubble, } let order = [ 'permissions', 'activityPub', + 'filterBubble', 'password', 'dateTime', 'validation', diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 6e23862ed..1179c3e20 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -1,3 +1,40 @@ +enum FilterBubbleAuthorEnum { + following + all +} + +input FilterBubble { + author: FilterBubbleAuthorEnum +} + +type Query { + Post( + id: ID + activityId: String + objectId: String + title: String + slug: String + content: String + contentExcerpt: String + image: String + imageUpload: Upload + visibility: Visibility + deleted: Boolean + disabled: Boolean + createdAt: String + updatedAt: String + commentsCount: Int + shoutedCount: Int + shoutedByCurrentUser: Boolean + _id: String + first: Int + offset: Int + orderBy: [_PostOrdering] + filter: _PostFilter + filterBubble: FilterBubble + ): [Post] +} + type Post { id: ID! activityId: String @@ -40,4 +77,4 @@ type Post { RETURN COUNT(u) >= 1 """ ) -} \ No newline at end of file +}