From 6950068a122d257c07504e99883300d7e1449ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 12:56:28 +0200 Subject: [PATCH 01/26] Sketch test for #1054 --- .../resolvers/users/blockedUsers.spec.js | 44 +++++++++++++++++++ backend/src/schema/types/type/User.gql | 11 +++++ 2 files changed, 55 insertions(+) create mode 100644 backend/src/schema/resolvers/users/blockedUsers.spec.js diff --git a/backend/src/schema/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js new file mode 100644 index 000000000..4a42f1c5c --- /dev/null +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -0,0 +1,44 @@ +import { createTestClient } from 'apollo-server-testing' +import createServer from '../../../server' +import { gql } from '../../../jest/helpers' + +describe('blockedUsers', () => { + it.todo('throws permission error') + + describe('authenticated', () => { + it.todo('returns a list of blocked users') + }) +}) + +describe('block', () => { + it.todo('throws permission error') + + describe('authenticated', () => { + it.todo('throws argument error') + + describe('given a to-be-blocked user', () => { + it.todo('blocks a user') + + describe('blocked user writes a post', () => { + it.todo('disappears in the newsfeed of the current user') + }) + + describe('current user writes a post', () => { + it.todo('disappears in the newsfeed of the blocked user') + }) + }) + }) +}) + +describe('unblock', () => { + it.todo('throws permission error') + describe('authenticated', () => { + it.todo('throws argument error') + describe('given a blocked user', () => { + it.todo('unblocks a user') + describe('unblocking twice', () => { + it.todo('has no effect') + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 2534463d1..b58b64ac3 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -42,6 +42,12 @@ type User { RETURN COUNT(u) >= 1 """ ) + isBlocked: Boolean! @cypher( + statement: """ + MATCH (this)-[:BLOCKED]->(u:User {id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """ + ) #contributions: [WrittenPost]! #contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! @@ -164,4 +170,9 @@ type Mutation { ): User DeleteUser(id: ID!, resource: [Deletable]): User + + + block(id: ID!): User + unblock(id: ID!): User + blockedUsers: [User] } From f8b37b5c1e74b0a68b9d6a363c1888d8c33b5863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 14:02:11 +0200 Subject: [PATCH 02/26] Remove obsolete relationship 'BLACKLISTED' --- backend/src/schema/resolvers/users.js | 1 - backend/src/schema/types/type/User.gql | 2 -- backend/src/seed/seed-db.js | 12 ------------ 3 files changed, 15 deletions(-) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 77e4ae2aa..393ae8281 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -91,7 +91,6 @@ export default { followedBy: '<-[:FOLLOWS]-(related:User)', following: '-[:FOLLOWS]->(related:User)', friends: '-[:FRIENDS]-(related:User)', - blacklisted: '-[:BLACKLISTED]->(related:User)', socialMedia: '-[:OWNED_BY]->(related:SocialMedia', contributions: '-[:WROTE]->(related:Post)', comments: '-[:WROTE]->(related:Comment)', diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index b58b64ac3..74be4b5a6 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -73,8 +73,6 @@ type User { organizationsCreated: [Organization] @relation(name: "CREATED_ORGA", direction: "OUT") organizationsOwned: [Organization] @relation(name: "OWNING_ORGA", direction: "OUT") - blacklisted: [User]! @relation(name: "BLACKLISTED", direction: "OUT") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") badges: [Badge]! @relation(name: "REWARDED", direction: "IN") diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index c3a05248d..17380a4cc 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -135,18 +135,6 @@ import Factory from './factories' from: 'u2', to: 'u3', }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u4', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u5', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u6', - }), ]) await Promise.all([ From b63f6b0ba18a71a6bb8b5234a24b7557ccf23259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 14:10:05 +0200 Subject: [PATCH 03/26] Setup blocked relationships in seeds --- backend/src/models/User.js | 7 +++++ backend/src/seed/seed-db.js | 54 ++++++++++--------------------------- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 2c1575423..5125c75ec 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -48,6 +48,13 @@ module.exports = { target: 'Badge', direction: 'in', }, + blocked: { + type: 'relationship', + relationship: 'BLOCKED', + target: 'User', + direction: 'out', + eager: true + }, invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, updatedAt: { diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 17380a4cc..394ca5e88 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -120,48 +120,22 @@ import Factory from './factories' bobDerBaumeister.relateTo(turtle, 'rewarded'), jennyRostock.relateTo(bear, 'rewarded'), dagobert.relateTo(rabbit, 'rewarded'), - ]) - await Promise.all([ - f.relate('User', 'Friends', { - from: 'u1', - to: 'u2', - }), - f.relate('User', 'Friends', { - from: 'u1', - to: 'u3', - }), - f.relate('User', 'Friends', { - from: 'u2', - to: 'u3', - }), - ]) + peterLustig.relateTo(bobDerBaumeister, 'friends'), + peterLustig.relateTo(jennyRostock, 'friends'), + bobDerBaumeister.relateTo(jennyRostock, 'friends'), - await Promise.all([ - asAdmin.follow({ - id: 'u3', - type: 'User', - }), - asModerator.follow({ - id: 'u4', - type: 'User', - }), - asUser.follow({ - id: 'u4', - type: 'User', - }), - asTick.follow({ - id: 'u6', - type: 'User', - }), - asTrick.follow({ - id: 'u4', - type: 'User', - }), - asTrack.follow({ - id: 'u3', - type: 'User', - }), + peterLustig.relateTo(jennyRostock, 'following'), + peterLustig.relateTo(tick, 'following'), + bobDerBaumeister.relateTo(tick, 'following'), + jennyRostock.relateTo(tick, 'following'), + tick.relateTo(track, 'following'), + trick.relateTo(tick, 'following'), + track.relateTo(jennyRostock, 'following'), + + dagobert.relateTo(tick, 'blocked'), + dagobert.relateTo(trick, 'blocked'), + dagobert.relateTo(track, 'blocked'), ]) await Promise.all([ From 05aee24efd5daa8108b9c189153878920d23abd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 15:12:13 +0200 Subject: [PATCH 04/26] Implement+test User.blockedUsers resolver --- .../src/middleware/permissionsMiddleware.js | 1 + backend/src/models/User.js | 1 - backend/src/schema/resolvers/users.js | 29 +++++++ .../resolvers/users/blockedUsers.spec.js | 78 ++++++++++++++++++- backend/src/schema/types/type/User.gql | 3 +- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 78f833c23..93096acba 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -159,6 +159,7 @@ const permissions = shield( Badge: allow, PostsEmotionsCountByEmotion: allow, PostsEmotionsByCurrentUser: allow, + blockedUsers: isAuthenticated, }, Mutation: { '*': deny, diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 5125c75ec..c952c0be6 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -53,7 +53,6 @@ module.exports = { relationship: 'BLOCKED', target: 'User', direction: 'out', - eager: true }, invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 393ae8281..b06f2d098 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -18,6 +18,23 @@ export default { } return neo4jgraphql(object, args, context, resolveInfo, false) }, + blockedUsers: async (object, args, context, resolveInfo) => { + try { + const userModel = instance.model('User') + let blockedUsers = instance + .query() + .match('user', userModel) + .where('user.id', context.user.id) + .relationship(userModel.relationships().get('blocked')) + .to('blocked', userModel) + .return('blocked') + blockedUsers = await blockedUsers.execute() + blockedUsers = blockedUsers.records.map(r => r.get('blocked').properties) + return blockedUsers + } catch (e) { + throw new UserInputError(e.message) + } + }, }, Mutation: { UpdateUser: async (object, args, context, resolveInfo) => { @@ -63,6 +80,18 @@ export default { const [{ email }] = result.records.map(r => r.get('e').properties) return email }, + isBlocked: async (parent, params, context, resolveInfo) => { + if (typeof parent.isBlocked !== 'undefined') return parent.isBlocked + const result = await instance.cypher( + ` + MATCH (u:User { id: $currentUser.id })-[:BLOCKED]->(b:User {id: $parent.id}) + RETURN COUNT(u) >= 1 as isBlocked + `, + { parent, currentUser: context.user }, + ) + const [record] = result.records + return record.get('isBlocked') + }, ...Resolver('User', { undefinedToNull: [ 'actorId', diff --git a/backend/src/schema/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js index 4a42f1c5c..c77ed76d3 100644 --- a/backend/src/schema/resolvers/users/blockedUsers.spec.js +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -1,12 +1,83 @@ import { createTestClient } from 'apollo-server-testing' import createServer from '../../../server' +import Factory from '../../../seed/factories' import { gql } from '../../../jest/helpers' +import { neode, getDriver } from '../../../bootstrap/neo4j' + +const driver = getDriver() +const factory = Factory() +const instance = neode() + +let currentUser +let blockedUser +let server + +beforeEach(() => { + ;({ server } = createServer({ + context: () => { + return { + user: currentUser, + driver, + } + }, + })) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) describe('blockedUsers', () => { - it.todo('throws permission error') + let blockedUserQuery + beforeEach(() => { + blockedUserQuery = gql` + query { + blockedUsers { + id + name + isBlocked + } + } + ` + }) - describe('authenticated', () => { - it.todo('returns a list of blocked users') + it('throws permission error', async () => { + const { query } = createTestClient(server) + const result = await query({ query: blockedUserQuery }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + + describe('authenticated and given a blocked user', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + blockedUser = await instance.create('User', { + name: 'Blocked User', + id: 'u2', + }) + await currentUser.relateTo(blockedUser, 'blocked') + currentUser = await currentUser.toJson() + blockedUser = await blockedUser.toJson() + }) + + it('returns a list of blocked users', async () => { + const { query } = createTestClient(server) + await expect(query({ query: blockedUserQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + blockedUsers: [ + { + name: 'Blocked User', + id: 'u2', + isBlocked: true, + }, + ], + }, + }), + ) + }) }) }) @@ -18,6 +89,7 @@ describe('block', () => { describe('given a to-be-blocked user', () => { it.todo('blocks a user') + it.todo('removes any `FOLLOW` relationship') describe('blocked user writes a post', () => { it.todo('disappears in the newsfeed of the current user') diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 74be4b5a6..c0b8220f5 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -152,6 +152,8 @@ type Query { orderBy: [_UserOrdering] filter: _UserFilter ): [User] + + blockedUsers: [User] } type Mutation { @@ -172,5 +174,4 @@ type Mutation { block(id: ID!): User unblock(id: ID!): User - blockedUsers: [User] } From f5a59568ab7414ab9950a556ee4d4a462afd2e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 16:59:28 +0200 Subject: [PATCH 05/26] Implement block except for the unfollow feature --- .../src/middleware/permissionsMiddleware.js | 3 +- backend/src/schema/resolvers/users.js | 29 ++++--- .../resolvers/users/blockedUsers.spec.js | 78 +++++++++++++++++-- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 93096acba..bd5046331 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -10,7 +10,7 @@ const instance = neode() const isAuthenticated = rule({ cache: 'contextual', })(async (_parent, _args, ctx, _info) => { - return ctx.user != null + return !!(ctx && ctx.user && ctx.user.id) }) const isModerator = rule()(async (parent, args, { user }, info) => { @@ -196,6 +196,7 @@ const permissions = shield( resetPassword: allow, AddPostEmotions: isAuthenticated, RemovePostEmotions: isAuthenticated, + block: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index b06f2d098..394f74e8e 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -8,16 +8,6 @@ const instance = neode() export default { Query: { - User: async (object, args, context, resolveInfo) => { - const { email } = args - if (email) { - const e = await instance.first('EmailAddress', { email }) - let user = e.get('belongsTo') - user = await user.toJson() - return [user.node] - } - return neo4jgraphql(object, args, context, resolveInfo, false) - }, blockedUsers: async (object, args, context, resolveInfo) => { try { const userModel = instance.model('User') @@ -35,8 +25,27 @@ export default { throw new UserInputError(e.message) } }, + User: async (object, args, context, resolveInfo) => { + const { email } = args + if (email) { + const e = await instance.first('EmailAddress', { email }) + let user = e.get('belongsTo') + user = await user.toJson() + return [user.node] + } + return neo4jgraphql(object, args, context, resolveInfo, false) + }, }, Mutation: { + block: async (object, args, context, resolveInfo) => { + if(context.user.id === args.id) return null + const [user, blockedUser] = await Promise.all([ + instance.find('User', context.user.id), + instance.find('User', args.id) + ]) + await user.relateTo(blockedUser, 'blocked') + return blockedUser.toJson() + }, UpdateUser: async (object, args, context, resolveInfo) => { args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) try { diff --git a/backend/src/schema/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js index c77ed76d3..826ea649d 100644 --- a/backend/src/schema/resolvers/users/blockedUsers.spec.js +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -13,11 +13,15 @@ let blockedUser let server beforeEach(() => { + currentUser = undefined ;({ server } = createServer({ context: () => { return { user: currentUser, driver, + cypherParams: { + currentUserId: currentUser ? currentUser.id : null, + }, } }, })) @@ -59,7 +63,6 @@ describe('blockedUsers', () => { }) await currentUser.relateTo(blockedUser, 'blocked') currentUser = await currentUser.toJson() - blockedUser = await blockedUser.toJson() }) it('returns a list of blocked users', async () => { @@ -82,14 +85,79 @@ describe('blockedUsers', () => { }) describe('block', () => { - it.todo('throws permission error') + let blockAction + + beforeEach(() => { + currentUser = undefined + blockAction = (variables) => { + const { mutate } = createTestClient(server) + const blockMutation = gql`mutation($id: ID!) { + block(id: $id) { + id + name + isBlocked + } + }` + return mutate({ mutation: blockMutation, variables }) + } + }) + + it('throws permission error', async () => { + const result = await blockAction({ id: 'u2' }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) describe('authenticated', () => { - it.todo('throws argument error') + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + currentUser = await currentUser.toJson() + }) + + describe('block yourself', () => { + it('returns null', async () => { + await expect(blockAction({ id: 'u1' })) + .resolves.toEqual(expect.objectContaining({ data: { block: null } })) + }) + }) + + describe('block not existing user', () => { + it('returns null', async () => { + await expect(blockAction({ id: 'u2' })) + .resolves.toEqual(expect.objectContaining({ data: { block: null } })) + }) + }) describe('given a to-be-blocked user', () => { - it.todo('blocks a user') - it.todo('removes any `FOLLOW` relationship') + beforeEach(async () => { + blockedUser = await instance.create('User', { + name: 'Blocked User', + id: 'u2', + }) + }) + + it('blocks a user', async () => { + await expect(blockAction({ id: 'u2' })) + .resolves.toEqual(expect.objectContaining({ + data: { block: { id: 'u2', name: 'Blocked User', isBlocked: true} } + })) + }) + + it('unfollows the user', async () => { + const user = await instance.find('User', currentUser.id) + await user.relateTo(blockedUser, 'following') + const queryUser = gql`query { User(id: "u2") { id isBlocked followedByCurrentUser } }` + const { query } = createTestClient(server) + await expect(query({ query: queryUser })).resolves.toEqual(expect.objectContaining({ + data: { User: [{id: "u2", isBlocked: false, followedByCurrentUser: true }] } + })) + await blockAction({id: 'u2'}) + await expect(query({ query: queryUser })).resolves.toEqual(expect.objectContaining({ + data: { User: [{id: "u2", isBlocked: true, followedByCurrentUser: false }] } + })) + }) describe('blocked user writes a post', () => { it.todo('disappears in the newsfeed of the current user') From 700bdcb8f1608bb10af0ca24e20230284ab4f956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 17:21:52 +0200 Subject: [PATCH 06/26] Implement+test unblock mutation --- .../src/middleware/permissionsMiddleware.js | 1 + backend/src/schema/resolvers/users.js | 17 +- .../resolvers/users/blockedUsers.spec.js | 163 ++++++++++++++---- 3 files changed, 150 insertions(+), 31 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index bd5046331..0f6f9e1c5 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -197,6 +197,7 @@ const permissions = shield( AddPostEmotions: isAuthenticated, RemovePostEmotions: isAuthenticated, block: isAuthenticated, + unblock: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 394f74e8e..66adc8734 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -38,14 +38,27 @@ export default { }, Mutation: { block: async (object, args, context, resolveInfo) => { - if(context.user.id === args.id) return null + if (context.user.id === args.id) return null const [user, blockedUser] = await Promise.all([ instance.find('User', context.user.id), - instance.find('User', args.id) + instance.find('User', args.id), ]) await user.relateTo(blockedUser, 'blocked') return blockedUser.toJson() }, + unblock: async (object, args, context, resolveInfo) => { + const { user: currentUser } = context + if (currentUser.id === args.id) return null + await instance.cypher( + ` + MATCH(u:User {id: $currentUser.id})-[r:BLOCKED]->(b:User {id: $args.id}) + DELETE r + `, + { currentUser, args }, + ) + const blockedUser = await instance.find('User', args.id) + return blockedUser.toJson() + }, UpdateUser: async (object, args, context, resolveInfo) => { args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) try { diff --git a/backend/src/schema/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js index 826ea649d..75fa791c0 100644 --- a/backend/src/schema/resolvers/users/blockedUsers.spec.js +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -89,15 +89,17 @@ describe('block', () => { beforeEach(() => { currentUser = undefined - blockAction = (variables) => { + blockAction = variables => { const { mutate } = createTestClient(server) - const blockMutation = gql`mutation($id: ID!) { - block(id: $id) { - id - name - isBlocked + const blockMutation = gql` + mutation($id: ID!) { + block(id: $id) { + id + name + isBlocked + } } - }` + ` return mutate({ mutation: blockMutation, variables }) } }) @@ -118,15 +120,17 @@ describe('block', () => { describe('block yourself', () => { it('returns null', async () => { - await expect(blockAction({ id: 'u1' })) - .resolves.toEqual(expect.objectContaining({ data: { block: null } })) + await expect(blockAction({ id: 'u1' })).resolves.toEqual( + expect.objectContaining({ data: { block: null } }), + ) }) }) describe('block not existing user', () => { it('returns null', async () => { - await expect(blockAction({ id: 'u2' })) - .resolves.toEqual(expect.objectContaining({ data: { block: null } })) + await expect(blockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ data: { block: null } }), + ) }) }) @@ -139,24 +143,37 @@ describe('block', () => { }) it('blocks a user', async () => { - await expect(blockAction({ id: 'u2' })) - .resolves.toEqual(expect.objectContaining({ - data: { block: { id: 'u2', name: 'Blocked User', isBlocked: true} } - })) + await expect(blockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { block: { id: 'u2', name: 'Blocked User', isBlocked: true } }, + }), + ) }) it('unfollows the user', async () => { const user = await instance.find('User', currentUser.id) await user.relateTo(blockedUser, 'following') - const queryUser = gql`query { User(id: "u2") { id isBlocked followedByCurrentUser } }` + const queryUser = gql` + query { + User(id: "u2") { + id + isBlocked + followedByCurrentUser + } + } + ` const { query } = createTestClient(server) - await expect(query({ query: queryUser })).resolves.toEqual(expect.objectContaining({ - data: { User: [{id: "u2", isBlocked: false, followedByCurrentUser: true }] } - })) - await blockAction({id: 'u2'}) - await expect(query({ query: queryUser })).resolves.toEqual(expect.objectContaining({ - data: { User: [{id: "u2", isBlocked: true, followedByCurrentUser: false }] } - })) + await expect(query({ query: queryUser })).resolves.toEqual( + expect.objectContaining({ + data: { User: [{ id: 'u2', isBlocked: false, followedByCurrentUser: true }] }, + }), + ) + await blockAction({ id: 'u2' }) + await expect(query({ query: queryUser })).resolves.toEqual( + expect.objectContaining({ + data: { User: [{ id: 'u2', isBlocked: true, followedByCurrentUser: false }] }, + }), + ) }) describe('blocked user writes a post', () => { @@ -171,13 +188,101 @@ describe('block', () => { }) describe('unblock', () => { - it.todo('throws permission error') + let unblockAction + + beforeEach(() => { + currentUser = undefined + unblockAction = variables => { + const { mutate } = createTestClient(server) + const unblockMutation = gql` + mutation($id: ID!) { + unblock(id: $id) { + id + name + isBlocked + } + } + ` + return mutate({ mutation: unblockMutation, variables }) + } + }) + + it('throws permission error', async () => { + const result = await unblockAction({ id: 'u2' }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + describe('authenticated', () => { - it.todo('throws argument error') - describe('given a blocked user', () => { - it.todo('unblocks a user') - describe('unblocking twice', () => { - it.todo('has no effect') + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + currentUser = await currentUser.toJson() + }) + + describe('unblock yourself', () => { + it('returns null', async () => { + await expect(unblockAction({ id: 'u1' })).resolves.toEqual( + expect.objectContaining({ data: { unblock: null } }), + ) + }) + }) + + describe('unblock not-existing user', () => { + it('returns null', async () => { + await expect(unblockAction({ id: 'lksjdflksfdj' })).resolves.toEqual( + expect.objectContaining({ data: { unblock: null } }), + ) + }) + }) + + describe('given another user', () => { + let user, blockedUser + + beforeEach(async () => { + ;[user, blockedUser] = await Promise.all([ + instance.find('User', 'u1'), + instance.create('User', { + name: 'Blocked User', + id: 'u2', + }), + ]) + }) + + describe('unblocking a not yet blocked user', () => { + it('does not hurt', async () => { + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + }) + + describe('given a blocked user', () => { + beforeEach(async () => { + await user.relateTo(blockedUser, 'blocked') + }) + + it('unblocks a user', async () => { + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + + describe('unblocking twice', () => { + it('has no effect', async () => { + await unblockAction({ id: 'u2' }) + await expect(unblockAction({ id: 'u2' })).resolves.toEqual( + expect.objectContaining({ + data: { unblock: { id: 'u2', name: 'Blocked User', isBlocked: false } }, + }), + ) + }) + }) }) }) }) From 293054a05b87010bc7cb54c4afefc4f7543dc401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 17:27:53 +0200 Subject: [PATCH 07/26] Implement block+unblock basic features --- backend/src/schema/resolvers/users.js | 12 ++++++++++-- backend/src/schema/types/type/User.gql | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 66adc8734..0824d62c0 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -38,9 +38,17 @@ export default { }, Mutation: { block: async (object, args, context, resolveInfo) => { - if (context.user.id === args.id) return null + const { user: currentUser } = context + if (currentUser.id === args.id) return null + await instance.cypher( + ` + MATCH(u:User {id: $currentUser.id})-[r:FOLLOWS]->(b:User {id: $args.id}) + DELETE r + `, + { currentUser, args }, + ) const [user, blockedUser] = await Promise.all([ - instance.find('User', context.user.id), + instance.find('User', currentUser.id), instance.find('User', args.id), ]) await user.relateTo(blockedUser, 'blocked') diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index c0b8220f5..e3e8f8450 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -44,7 +44,7 @@ type User { ) isBlocked: Boolean! @cypher( statement: """ - MATCH (this)-[:BLOCKED]->(u:User {id: $cypherParams.currentUserId}) + MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1 """ ) From 824b2a5561d493df8957393420b3161def549bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 7 Aug 2019 19:07:20 +0200 Subject: [PATCH 08/26] Implement page for blocked users --- webapp/graphql/settings/BlockedUsers.js | 17 +++++ webapp/locales/de.json | 14 ++++ webapp/locales/en.json | 15 ++++ webapp/pages/settings.vue | 4 ++ webapp/pages/settings/blocked-users.vue | 95 +++++++++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 webapp/graphql/settings/BlockedUsers.js create mode 100644 webapp/pages/settings/blocked-users.vue diff --git a/webapp/graphql/settings/BlockedUsers.js b/webapp/graphql/settings/BlockedUsers.js new file mode 100644 index 000000000..2cfd7bfe3 --- /dev/null +++ b/webapp/graphql/settings/BlockedUsers.js @@ -0,0 +1,17 @@ +import gql from 'graphql-tag' + +export default () => { + return gql(` + { + blockedUsers { + id + name + slug + avatar + about + disabled + deleted + } + } + `) +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 5a2b77131..2e89a72d0 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -189,6 +189,20 @@ "submit": "Link hinzufügen", "successAdd": "Social-Media hinzugefügt. Profil aktualisiert!", "successDelete": "Social-Media gelöscht. Profil aktualisiert!" + }, + "blocked-users": { + "name": "Blockierte Benutzer", + "explanation": { + "intro": "Wenn ein anderer Benutzer von dir blockiert wurde, dann passiert folgendes:", + "your-perspective": "In deiner Beitragsübersicht tauchen keine Beiträge der blockierten Person mehr auf.", + "their-perspective": "Umgekehrt das gleiche: Die blockierte Person sieht deine Beiträge auch nicht mehr in ihrer Übersicht.", + "closing": "Das sollte fürs Erste genügen, damit blockierte Benutzer dich nicht mehr länger belästigen können." + }, + "columns": { + "name": "Name", + "slug": "Alias" + }, + "empty": "Bislang hast du niemanden blockiert." } }, "admin": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index fe0f1c99c..7f8441c71 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -189,6 +189,21 @@ "submit": "Add link", "successAdd": "Added social media. Updated user profile!", "successDelete": "Deleted social media. Updated user profile!" + }, + "blocked-users": { + "name": "Blocked users", + "explanation": { + "intro": "If another user has been blocked by you, this is what happens:", + "your-perspective": "The blocked person's posts will no longer appear in your news feed.", + "their-perspective": "Vice versa: The blocked person will also no longer see your posts in their news feed.", + "closing": "This should be sufficient for now so that blocked users can no longer bother you." + + }, + "columns": { + "name": "Name", + "slug": "Slug" + }, + "empty": "So far, you did not block anybody." } }, "admin": { diff --git a/webapp/pages/settings.vue b/webapp/pages/settings.vue index 1284aea7f..67493c333 100644 --- a/webapp/pages/settings.vue +++ b/webapp/pages/settings.vue @@ -31,6 +31,10 @@ export default { name: this.$t('settings.social-media.name'), path: `/settings/my-social-media`, }, + { + name: this.$t('settings.blocked-users.name'), + path: `/settings/blocked-users`, + }, { name: this.$t('settings.deleteUserAccount.name'), path: `/settings/delete-account`, diff --git a/webapp/pages/settings/blocked-users.vue b/webapp/pages/settings/blocked-users.vue new file mode 100644 index 000000000..b9bfcecd5 --- /dev/null +++ b/webapp/pages/settings/blocked-users.vue @@ -0,0 +1,95 @@ + + + + + From 7f509b3201fa597f869cdff7517ee38ddbab088a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Thu, 8 Aug 2019 01:11:42 +0200 Subject: [PATCH 09/26] Implement block/unbock UI --- .../src/schema/resolvers/helpers/Resolver.js | 23 +++++++++++++ backend/src/schema/resolvers/users.js | 18 ++++------ webapp/components/ContentMenu.vue | 33 +++++++++++++++---- webapp/graphql/UserProfile/User.js | 1 + webapp/graphql/settings/BlockedUsers.js | 24 +++++++++++++- webapp/locales/de.json | 4 ++- webapp/locales/en.json | 4 ++- webapp/pages/profile/_id/_slug.vue | 29 ++++++++++++---- webapp/pages/settings/blocked-users.vue | 2 +- 9 files changed, 109 insertions(+), 29 deletions(-) diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index 655cf08a0..fd41205a3 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -15,10 +15,12 @@ export default function Resolver(type, options = {}) { const { idAttribute = 'id', undefinedToNull = [], + boolean = {}, count = {}, hasOne = {}, hasMany = {}, } = options + const _hasResolver = (resolvers, { key, connection }, { returnType }) => { return async (parent, params, context, resolveInfo) => { if (typeof parent[key] !== 'undefined') return parent[key] @@ -31,6 +33,26 @@ export default function Resolver(type, options = {}) { } } + const booleanResolver = obj => { + const resolvers = {} + for (const [key, condition] of Object.entries(obj)) { + resolvers[key] = async (parent, params, { cypherParams }, resolveInfo) => { + if (typeof parent[key] !== 'undefined') return parent[key] + const result = await instance.cypher( + ` + ${condition.replace('this', 'this {id: $parent.id}')} as ${key}`, + { + parent, + cypherParams, + }, + ) + const [record] = result.records + return record.get(key) + } + } + return resolvers + } + const countResolver = obj => { const resolvers = {} for (const [key, connection] of Object.entries(obj)) { @@ -67,6 +89,7 @@ export default function Resolver(type, options = {}) { } const result = { ...undefinedToNullResolver(undefinedToNull), + ...booleanResolver(boolean), ...countResolver(count), ...hasOneResolver(hasOne), ...hasManyResolver(hasMany), diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 0824d62c0..767cde24b 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -110,18 +110,6 @@ export default { const [{ email }] = result.records.map(r => r.get('e').properties) return email }, - isBlocked: async (parent, params, context, resolveInfo) => { - if (typeof parent.isBlocked !== 'undefined') return parent.isBlocked - const result = await instance.cypher( - ` - MATCH (u:User { id: $currentUser.id })-[:BLOCKED]->(b:User {id: $parent.id}) - RETURN COUNT(u) >= 1 as isBlocked - `, - { parent, currentUser: context.user }, - ) - const [record] = result.records - return record.get('isBlocked') - }, ...Resolver('User', { undefinedToNull: [ 'actorId', @@ -132,6 +120,12 @@ export default { 'locationName', 'about', ], + boolean: { + followedByCurrentUser: + 'MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + isBlocked: + 'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + }, count: { contributionsCount: '-[:WROTE]->(related:Post)', friendsCount: '<-[:FRIENDS]->(related:User)', diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 0ac885597..3b82486fe 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -122,13 +122,34 @@ export default { } } - if (this.isOwner && this.resourceType === 'user') { - routes.push({ - name: this.$t(`settings.name`), - path: '/settings', - icon: 'edit', - }) + if (this.resourceType === 'user') { + if (this.isOwner) { + routes.push({ + name: this.$t(`settings.name`), + path: '/settings', + icon: 'edit', + }) + } else { + if (this.resource.isBlocked) { + routes.push({ + name: this.$t(`settings.blocked-users.unblock`), + callback: () => { + this.$emit('unblock', this.resource) + }, + icon: 'user-plus', + }) + } else { + routes.push({ + name: this.$t(`settings.blocked-users.block`), + callback: () => { + this.$emit('block', this.resource) + }, + icon: 'user-times', + }) + } + } } + return routes }, isModerator() { diff --git a/webapp/graphql/UserProfile/User.js b/webapp/graphql/UserProfile/User.js index 897b5b91d..9febce142 100644 --- a/webapp/graphql/UserProfile/User.js +++ b/webapp/graphql/UserProfile/User.js @@ -46,6 +46,7 @@ export default i18n => { } followedByCount followedByCurrentUser + isBlocked followedBy(first: 7) { id slug diff --git a/webapp/graphql/settings/BlockedUsers.js b/webapp/graphql/settings/BlockedUsers.js index 2cfd7bfe3..e47355b18 100644 --- a/webapp/graphql/settings/BlockedUsers.js +++ b/webapp/graphql/settings/BlockedUsers.js @@ -1,6 +1,6 @@ import gql from 'graphql-tag' -export default () => { +export const BlockedUsers = () => { return gql(` { blockedUsers { @@ -15,3 +15,25 @@ export default () => { } `) } + +export const Block = () => { + return gql(`mutation($id:ID!) { + block(id: $id) { + id + name + isBlocked + followedByCurrentUser + } + }`) +} + +export const Unblock = () => { + return gql(`mutation($id:ID!) { + unblock(id: $id) { + id + name + isBlocked + followedByCurrentUser + } + }`) +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 2e89a72d0..5a77a85eb 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -202,7 +202,9 @@ "name": "Name", "slug": "Alias" }, - "empty": "Bislang hast du niemanden blockiert." + "empty": "Bislang hast du niemanden blockiert.", + "block": "Nutzer blockieren", + "unblock": "Nutzer entblocken" } }, "admin": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 7f8441c71..85e21abff 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -203,7 +203,9 @@ "name": "Name", "slug": "Slug" }, - "empty": "So far, you did not block anybody." + "empty": "So far, you did not block anybody.", + "block": "Block user", + "unblock": "Unblock user" } }, "admin": { diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index 8a04e873b..a807740d0 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -22,6 +22,8 @@ :resource="user" :is-owner="myProfile" class="user-content-menu" + @block="block" + @unblock="unblock" /> @@ -54,13 +56,18 @@ - + From 6099a986c92545c8ec10638ccf0f3098469d3f3e Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 14 Aug 2019 14:29:35 +0200 Subject: [PATCH 25/26] Improve English, update case for English translations --- .../blocked-users/Blocking.feature | 50 +++++++++---------- .../blocked-users/Content.feature | 26 +++++----- webapp/locales/en.json | 5 +- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature index 6a106fc9e..07d84d7bf 100644 --- a/cypress/integration/user_profile/blocked-users/Blocking.feature +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -1,36 +1,36 @@ Feature: Block a User As a user I'd like to have a button to block another user - To prevent him to see and interact with my contributions and also to avoid to see his/her posts + To prevent him from seeing and interacting with my contributions and also to avoid seeing his/her posts Background: Given I have a user account And there is an annoying user called "Spammy Spammer" And I am logged in - Scenario: Block a user - Given I am on the profile page of the annoying user - When I click on "Block User" from the content menu in the user info box - And I navigate to my "Blocked users" settings page - Then I can see the following table: - | Avatar | Name | - | | Spammy Spammer | + Scenario: Block a user + Given I am on the profile page of the annoying user + When I click on "Block User" from the content menu in the user info box + And I navigate to my "Blocked users" settings page + Then I can see the following table: + | Avatar | Name | + | | Spammy Spammer | - Scenario: Block a previously followed user - Given I follow the user "Spammy Spammer" - And "Spammy Spammer" wrote a post "Spam Spam Spam" - When I visit the profile page of the annoying user - And I click on "Block User" from the content menu in the user info box - Then the list of posts of this user is empty - And nobody is following the user profile anymore + Scenario: Block a previously followed user + Given I follow the user "Spammy Spammer" + And "Spammy Spammer" wrote a post "Spam Spam Spam" + When I visit the profile page of the annoying user + And I click on "Block User" from the content menu in the user info box + Then the list of posts of this user is empty + And nobody is following the user profile anymore - Scenario: Posts of blocked users are filtered from search results - Given "Spammy Spammer" wrote a post "Spam Spam Spam" - When I search for "Spam" - Then I should see the following posts in the select dropdown: - | title | - | Spam Spam Spam | - When I block the user "Spammy Spammer" - And I refresh the page - And I search for "Spam" - Then the search has no results + Scenario: Posts of blocked users are filtered from search results + Given "Spammy Spammer" wrote a post "Spam Spam Spam" + When I search for "Spam" + Then I should see the following posts in the select dropdown: + | title | + | Spam Spam Spam | + When I block the user "Spammy Spammer" + And I refresh the page + And I search for "Spam" + Then the search has no results diff --git a/cypress/integration/user_profile/blocked-users/Content.feature b/cypress/integration/user_profile/blocked-users/Content.feature index 8bbad7e8c..edc0d63b9 100644 --- a/cypress/integration/user_profile/blocked-users/Content.feature +++ b/cypress/integration/user_profile/blocked-users/Content.feature @@ -1,22 +1,22 @@ Feature: Block a User As a user I'd like to have a button to block another user - To prevent him to see and interact with my contributions and also to avoid to see his/her posts + To prevent him from seeing and interacting with my contributions and also to avoid seeing his/her posts Background: Given I have a user account And there is an annoying user called "Spammy Spammer" - Scenario Outline: Blocked users cannot see each others posts - Given "Spammy Spammer" wrote a post "Spam Spam Spam" - And I wrote a post "I hate spammers" - And I block the user "Spammy Spammer" - When I log in with: - | Email | Password | - | | | - Then I see only one post with the title "" - Examples: - | email | password | expected_title | - | peterpan@example.org | 1234 | I hate spammers | - | spammy-spammer@example.org | 1234 | Spam Spam Spam | + Scenario Outline: Blocked users cannot see each others posts + Given "Spammy Spammer" wrote a post "Spam Spam Spam" + And I wrote a post "I hate spammers" + And I block the user "Spammy Spammer" + When I log in with: + | Email | Password | + | | | + Then I see only one post with the title "" + Examples: + | email | password | expected_title | + | peterpan@example.org | 1234 | I hate spammers | + | spammy-spammer@example.org | 1234 | Spam Spam Spam | diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 03724e7cf..a045352db 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -199,7 +199,6 @@ "search": "Posts of blocked people disappear from your search results.", "notifications": "Blocked users will no longer receive notifications if they are mentioned in your posts.", "closing": "This should be sufficient for now so that blocked users can no longer bother you." - }, "columns": { "name": "Name", @@ -207,8 +206,8 @@ }, "empty": "So far, you have not blocked anybody.", "how-to": "You can block other users on their profile page via the content menu.", - "block": "Block User", - "unblock": "Unblock User" + "block": "Block user", + "unblock": "Unblock user" } }, "admin": { From a21cf5aef65a0ec76ed2330938cfa340de17ced2 Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 14 Aug 2019 15:19:27 +0200 Subject: [PATCH 26/26] Fix cypress test --- .../integration/user_profile/blocked-users/Blocking.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature index 07d84d7bf..9ab4fde6e 100644 --- a/cypress/integration/user_profile/blocked-users/Blocking.feature +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -10,7 +10,7 @@ Feature: Block a User Scenario: Block a user Given I am on the profile page of the annoying user - When I click on "Block User" from the content menu in the user info box + When I click on "Block user" from the content menu in the user info box And I navigate to my "Blocked users" settings page Then I can see the following table: | Avatar | Name | @@ -20,7 +20,7 @@ Feature: Block a User Given I follow the user "Spammy Spammer" And "Spammy Spammer" wrote a post "Spam Spam Spam" When I visit the profile page of the annoying user - And I click on "Block User" from the content menu in the user info box + And I click on "Block user" from the content menu in the user info box Then the list of posts of this user is empty And nobody is following the user profile anymore