diff --git a/backend/package.json b/backend/package.json
index 19904c22d..853ef25b6 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -89,7 +89,7 @@
"metascraper-youtube": "^5.6.3",
"minimatch": "^3.0.4",
"neo4j-driver": "~1.7.5",
- "neo4j-graphql-js": "^2.6.3",
+ "neo4j-graphql-js": "^2.7.0",
"neode": "^0.3.1",
"node-fetch": "~2.6.0",
"nodemailer": "^6.3.0",
diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js
index 3faa31689..ab0456f9a 100644
--- a/backend/src/middleware/handleHtmlContent/handleContentData.js
+++ b/backend/src/middleware/handleHtmlContent/handleContentData.js
@@ -7,11 +7,14 @@ const notifyMentions = async (label, id, idsOfMentionedUsers, context) => {
const session = context.driver.session()
const createdAt = new Date().toISOString()
const cypher = `
- MATCH (u: User) WHERE u.id in $idsOfMentionedUsers
- MATCH (source) WHERE source.id = $id AND $label IN LABELS(source)
- CREATE (n: Notification { id: apoc.create.uuid(), read: false, createdAt: $createdAt })
- MERGE (n)-[:NOTIFIED]->(u)
- MERGE (source)-[:NOTIFIED]->(n)
+ MATCH (source)
+ WHERE source.id = $id AND $label IN LABELS(source)
+ MATCH (source)<-[:WROTE]-(author: User)
+ MATCH (u: User)
+ WHERE u.id in $idsOfMentionedUsers
+ AND NOT (u)<-[:BLOCKED]-(author)
+ CREATE (n: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt })
+ MERGE (source)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u)
`
await session.run(cypher, {
idsOfMentionedUsers,
diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js
index 6fe5d3891..40d8a2481 100644
--- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js
+++ b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js
@@ -1,12 +1,36 @@
-import { GraphQLClient } from 'graphql-request'
-import { host, login, gql } from '../../jest/helpers'
+import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
+import { createTestClient } from 'apollo-server-testing'
+import { neode, getDriver } from '../../bootstrap/neo4j'
+import createServer from '../../server'
const factory = Factory()
-let client
+const driver = getDriver()
+const instance = neode()
+let server
+let query
+let mutate
+let user
+let authenticatedUser
+
+beforeAll(() => {
+ const createServerResult = createServer({
+ context: () => {
+ return {
+ user: authenticatedUser,
+ neode: instance,
+ driver,
+ }
+ },
+ })
+ server = createServerResult.server
+ const createTestClientResult = createTestClient(server)
+ query = createTestClientResult.query
+ mutate = createTestClientResult.mutate
+})
beforeEach(async () => {
- await factory.create('User', {
+ user = await instance.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
@@ -19,8 +43,8 @@ afterEach(async () => {
await factory.cleanDatabase()
})
-describe('currentUser { notifications }', () => {
- const query = gql`
+describe('notifications', () => {
+ const notificationQuery = gql`
query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
@@ -34,82 +58,60 @@ describe('currentUser { notifications }', () => {
`
describe('authenticated', () => {
- let headers
beforeEach(async () => {
- headers = await login({
- email: 'test@example.org',
- password: '1234',
- })
- client = new GraphQLClient(host, {
- headers,
- })
+ authenticatedUser = user
})
describe('given another user', () => {
- let authorClient
- let authorParams
- let authorHeaders
-
+ let author
beforeEach(async () => {
- authorParams = {
+ author = await instance.create('User', {
email: 'author@example.org',
password: '1234',
id: 'author',
- }
- await factory.create('User', authorParams)
- authorHeaders = await login(authorParams)
+ })
})
describe('who mentions me in a post', () => {
- let post
const title = 'Mentioning Al Capone'
const content =
'Hey @al-capone how do you do?'
- beforeEach(async () => {
+ const createPostAction = async () => {
const createPostMutation = gql`
- mutation($title: String!, $content: String!) {
- CreatePost(title: $title, content: $content) {
+ mutation($id: ID, $title: String!, $content: String!) {
+ CreatePost(id: $id, title: $title, content: $content) {
id
title
content
}
}
`
- authorClient = new GraphQLClient(host, {
- headers: authorHeaders,
+ authenticatedUser = await author.toJson()
+ await mutate({
+ mutation: createPostMutation,
+ variables: { id: 'p47', title, content },
})
- const { CreatePost } = await authorClient.request(createPostMutation, {
- title,
- content,
- })
- post = CreatePost
- })
+ authenticatedUser = await user.toJson()
+ }
it('sends you a notification', async () => {
+ await createPostAction()
const expectedContent =
'Hey @al-capone how do you do?'
- const expected = {
- currentUser: {
- notifications: [
- {
- read: false,
- post: {
- content: expectedContent,
- },
- },
- ],
+ const expected = expect.objectContaining({
+ data: {
+ currentUser: { notifications: [{ read: false, post: { content: expectedContent } }] },
},
- }
+ })
+ const { query } = createTestClient(server)
await expect(
- client.request(query, {
- read: false,
- }),
+ query({ query: notificationQuery, variables: { read: false } }),
).resolves.toEqual(expected)
})
describe('who mentions me many times', () => {
- beforeEach(async () => {
+ const updatePostAction = async () => {
const updatedContent = `
One more mention to
@@ -132,41 +134,52 @@ describe('currentUser { notifications }', () => {
}
}
`
- authorClient = new GraphQLClient(host, {
- headers: authorHeaders,
+ authenticatedUser = await author.toJson()
+ await mutate({
+ mutation: updatePostMutation,
+ variables: {
+ id: 'p47',
+ title,
+ content: updatedContent,
+ },
})
- await authorClient.request(updatePostMutation, {
- id: post.id,
- title: post.title,
- content: updatedContent,
- })
- })
+ authenticatedUser = await user.toJson()
+ }
it('creates exactly one more notification', async () => {
+ await createPostAction()
+ await updatePostAction()
const expectedContent =
'
One more mention to
@al-capone
and again:
@al-capone
and again
@al-capone
'
- const expected = {
- currentUser: {
- notifications: [
- {
- read: false,
- post: {
- content: expectedContent,
- },
- },
- {
- read: false,
- post: {
- content: expectedContent,
- },
- },
- ],
+ const expected = expect.objectContaining({
+ data: {
+ currentUser: {
+ notifications: [
+ { read: false, post: { content: expectedContent } },
+ { read: false, post: { content: expectedContent } },
+ ],
+ },
},
- }
+ })
await expect(
- client.request(query, {
- read: false,
- }),
+ query({ query: notificationQuery, variables: { read: false } }),
+ ).resolves.toEqual(expected)
+ })
+ })
+
+ describe('but the author of the post blocked me', () => {
+ beforeEach(async () => {
+ await author.relateTo(user, 'blocked')
+ })
+
+ it('sends no notification', async () => {
+ await createPostAction()
+ const expected = expect.objectContaining({
+ data: { currentUser: { notifications: [] } },
+ })
+ const { query } = createTestClient(server)
+ await expect(
+ query({ query: notificationQuery, variables: { read: false } }),
).resolves.toEqual(expected)
})
})
@@ -204,46 +217,40 @@ describe('Hashtags', () => {
`
describe('authenticated', () => {
- let headers
beforeEach(async () => {
- headers = await login({
- email: 'test@example.org',
- password: '1234',
- })
- client = new GraphQLClient(host, {
- headers,
- })
+ authenticatedUser = await user.toJson()
})
describe('create a Post with Hashtags', () => {
beforeEach(async () => {
- await client.request(createPostMutation, {
- postId,
- postTitle,
- postContent,
+ await mutate({
+ mutation: createPostMutation,
+ variables: {
+ postId,
+ postTitle,
+ postContent,
+ },
})
})
- it('both Hashtags are created with the "id" set to thier "name"', async () => {
+ it('both Hashtags are created with the "id" set to their "name"', async () => {
const expected = [
- {
- id: 'Democracy',
- name: 'Democracy',
- },
- {
- id: 'Liberty',
- name: 'Liberty',
- },
+ { id: 'Democracy', name: 'Democracy' },
+ { id: 'Liberty', name: 'Liberty' },
]
await expect(
- client.request(postWithHastagsQuery, postWithHastagsVariables),
- ).resolves.toEqual({
- Post: [
- {
- tags: expect.arrayContaining(expected),
+ query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ Post: [
+ {
+ tags: expect.arrayContaining(expected),
+ },
+ ],
},
- ],
- })
+ }),
+ )
})
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => {
@@ -261,31 +268,28 @@ describe('Hashtags', () => {
`
it('only one previous Hashtag and the new Hashtag exists', async () => {
- await client.request(updatePostMutation, {
- postId,
- postTitle,
- updatedPostContent,
+ await mutate({
+ mutation: updatePostMutation,
+ variables: {
+ postId,
+ postTitle,
+ updatedPostContent,
+ },
})
const expected = [
- {
- id: 'Elections',
- name: 'Elections',
- },
- {
- id: 'Liberty',
- name: 'Liberty',
- },
+ { id: 'Elections', name: 'Elections' },
+ { id: 'Liberty', name: 'Liberty' },
]
await expect(
- client.request(postWithHastagsQuery, postWithHastagsVariables),
- ).resolves.toEqual({
- Post: [
- {
- tags: expect.arrayContaining(expected),
+ query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ data: {
+ Post: [{ tags: expect.arrayContaining(expected) }],
},
- ],
- })
+ }),
+ )
})
})
})
diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js
index d08309f0b..e245e84b5 100644
--- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js
+++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js
@@ -3,11 +3,13 @@ import cheerio from 'cheerio'
export default function(content) {
if (!content) return []
const $ = cheerio.load(content)
- let userIds = $('a.mention[data-mention-id]')
+ const userIds = $('a.mention[data-mention-id]')
.map((_, el) => {
return $(el).attr('data-mention-id')
})
.get()
- userIds = userIds.map(id => id.trim()).filter(id => !!id)
return userIds
+ .map(id => id.trim())
+ .filter(id => !!id)
+ .filter((id, index, allIds) => allIds.indexOf(id) === index)
}
diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js
index a631b64a3..9865eab0d 100644
--- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js
+++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js
@@ -6,6 +6,8 @@ const contentEmptyMentions =
'
Something inspirational about @bob-der-baumeister and @jenny-rostock.
' const contentWithPlainLinks = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' +const contentWithDuplicateIds = + 'One more mention to @al-capone and again: @al-capone and again @al-capone ' describe('extractMentionedUsers', () => { describe('content undefined', () => { @@ -18,6 +20,10 @@ describe('extractMentionedUsers', () => { expect(extractMentionedUsers(contentWithPlainLinks)).toEqual([]) }) + it('removes duplicates', () => { + expect(extractMentionedUsers(contentWithDuplicateIds)).toEqual(['you']) + }) + describe('given a link with .mention class and `data-mention-id` attribute ', () => { it('extracts ids', () => { expect(extractMentionedUsers(contentWithMentions)).toEqual(['u3']) diff --git a/backend/src/middleware/orderByMiddleware.spec.js b/backend/src/middleware/orderByMiddleware.spec.js index 450220cd6..7a42166cf 100644 --- a/backend/src/middleware/orderByMiddleware.spec.js +++ b/backend/src/middleware/orderByMiddleware.spec.js @@ -1,22 +1,29 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../seed/factories' -import { host } from '../jest/helpers' +import { gql } from '../jest/helpers' +import { neode as getNeode, getDriver } from '../bootstrap/neo4j' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../server' -let client -let headers -let query const factory = Factory() +const neode = getNeode() +const driver = getDriver() + +const { server } = createServer({ + context: () => { + return { + user: null, + neode, + driver, + } + }, +}) +const { query } = createTestClient(server) beforeEach(async () => { - const userParams = { name: 'Author', email: 'author@example.org', password: '1234' } - await factory.create('User', userParams) - await factory.authenticateAs(userParams) - await factory.create('Post', { title: 'first' }) - await factory.create('Post', { title: 'second' }) - await factory.create('Post', { title: 'third' }) - await factory.create('Post', { title: 'last' }) - headers = {} - client = new GraphQLClient(host, { headers }) + await neode.create('Post', { title: 'first' }) + await neode.create('Post', { title: 'second' }) + await neode.create('Post', { title: 'third' }) + await neode.create('Post', { title: 'last' }) }) afterEach(async () => { @@ -25,10 +32,6 @@ afterEach(async () => { describe('Query', () => { describe('Post', () => { - beforeEach(() => { - query = '{ Post { title } }' - }) - describe('orderBy', () => { it('createdAt descending is default', async () => { const posts = [ @@ -37,15 +40,21 @@ describe('Query', () => { { title: 'second' }, { title: 'first' }, ] - const expected = { Post: posts } - await expect(client.request(query)).resolves.toEqual(expected) + const expected = expect.objectContaining({ data: { Post: posts } }) + await expect( + query({ + query: gql` + { + Post { + title + } + } + `, + }), + ).resolves.toEqual(expected) }) describe('(orderBy: createdAt_asc)', () => { - beforeEach(() => { - query = '{ Post(orderBy: createdAt_asc) { title } }' - }) - it('orders by createdAt ascending', async () => { const posts = [ { title: 'first' }, @@ -53,8 +62,18 @@ describe('Query', () => { { title: 'third' }, { title: 'last' }, ] - const expected = { Post: posts } - await expect(client.request(query)).resolves.toEqual(expected) + const expected = expect.objectContaining({ data: { Post: posts } }) + await expect( + query({ + query: gql` + { + Post(orderBy: createdAt_asc) { + title + } + } + `, + }), + ).resolves.toEqual(expected) }) }) }) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 78f833c23..0f6f9e1c5 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) => { @@ -159,6 +159,7 @@ const permissions = shield( Badge: allow, PostsEmotionsCountByEmotion: allow, PostsEmotionsByCurrentUser: allow, + blockedUsers: isAuthenticated, }, Mutation: { '*': deny, @@ -195,6 +196,8 @@ const permissions = shield( resetPassword: allow, AddPostEmotions: isAuthenticated, RemovePostEmotions: isAuthenticated, + block: isAuthenticated, + unblock: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js new file mode 100644 index 000000000..b8690b8c1 --- /dev/null +++ b/backend/src/models/Notification.js @@ -0,0 +1,19 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'uuid', primary: true, default: uuid }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + read: { type: 'boolean', default: false }, + user: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'User', + direction: 'out', + }, + post: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'Post', + direction: 'in', + }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 2c1575423..fa578f8ad 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -71,4 +71,16 @@ module.exports = { eager: true, cascade: true, }, + blocked: { + type: 'relationship', + relationship: 'BLOCKED', + target: 'User', + direction: 'out', + }, + notifications: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'Notification', + direction: 'in', + }, } diff --git a/backend/src/models/index.js b/backend/src/models/index.js index b8dc451d7..6f6b300f8 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -7,4 +7,5 @@ export default { EmailAddress: require('./EmailAddress.js'), SocialMedia: require('./SocialMedia.js'), Post: require('./Post.js'), + Notification: require('./Notification.js'), } 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/posts.js b/backend/src/schema/resolvers/posts.js index 336f816f5..ea1f680bd 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,7 +1,74 @@ import uuid from 'uuid/v4' +import { neo4jgraphql } from 'neo4j-graphql-js' import fileUpload from './fileUpload' +import { getBlockedUsers, getBlockedByUsers } from './users.js' +import { mergeWith, isArray } from 'lodash' + +const filterForBlockedUsers = async (params, context) => { + if (!context.user) return params + const [blockedUsers, blockedByUsers] = await Promise.all([ + getBlockedUsers(context), + getBlockedByUsers(context), + ]) + const badIds = [...blockedByUsers.map(b => b.id), ...blockedUsers.map(b => b.id)] + params.filter = mergeWith( + params.filter, + { + author_not: { id_in: badIds }, + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue) + } + }, + ) + return params +} export default { + Query: { + Post: async (object, params, context, resolveInfo) => { + params = await filterForBlockedUsers(params, context) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + findPosts: async (object, params, context, resolveInfo) => { + params = await filterForBlockedUsers(params, context) + return neo4jgraphql(object, params, context, resolveInfo, false) + }, + PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId, data } = params + const transactionRes = await session.run( + `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + RETURN COUNT(DISTINCT emoted) as emotionsCount + `, + { postId, data }, + ) + session.close() + + const [emotionsCount] = transactionRes.records.map(record => { + return record.get('emotionsCount').low + }) + + return emotionsCount + }, + PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId } = params + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + RETURN collect(emoted.emotion) as emotion`, + { userId: context.user.id, postId }, + ) + + session.close() + + const [emotions] = transactionRes.records.map(record => { + return record.get('emotion') + }) + return emotions + }, + }, Mutation: { UpdatePost: async (object, params, context, resolveInfo) => { const { categoryIds } = params @@ -112,39 +179,4 @@ export default { return emoted }, }, - Query: { - PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { - const session = context.driver.session() - const { postId, data } = params - const transactionRes = await session.run( - `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() - RETURN COUNT(DISTINCT emoted) as emotionsCount - `, - { postId, data }, - ) - session.close() - - const [emotionsCount] = transactionRes.records.map(record => { - return record.get('emotionsCount').low - }) - - return emotionsCount - }, - PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { - const session = context.driver.session() - const { postId } = params - const transactionRes = await session.run( - `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) - RETURN collect(emoted.emotion) as emotion`, - { userId: context.user.id, postId }, - ) - - session.close() - - const [emotions] = transactionRes.records.map(record => { - return record.get('emotion') - }) - return emotions - }, - }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index b48be16db..15376c8a4 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -460,6 +460,7 @@ describe('emotions', () => { context: () => { return { user, + neode: instance, driver, } }, @@ -476,6 +477,7 @@ describe('emotions', () => { context: () => { return { user, + neode: instance, driver, } }, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 77e4ae2aa..74561c5fe 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -6,8 +6,45 @@ import Resolver from './helpers/Resolver' const instance = neode() +export const getBlockedUsers = async context => { + const { neode } = context + const userModel = neode.model('User') + let blockedUsers = neode + .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 +} + +export const getBlockedByUsers = async context => { + const { neode } = context + const userModel = neode.model('User') + let blockedByUsers = neode + .query() + .match('user', userModel) + .relationship(userModel.relationships().get('blocked')) + .to('blocked', userModel) + .where('blocked.id', context.user.id) + .return('user') + blockedByUsers = await blockedByUsers.execute() + blockedByUsers = blockedByUsers.records.map(r => r.get('user').properties) + return blockedByUsers +} + export default { Query: { + blockedUsers: async (object, args, context, resolveInfo) => { + try { + return getBlockedUsers(context) + } catch (e) { + throw new UserInputError(e.message) + } + }, User: async (object, args, context, resolveInfo) => { const { email } = args if (email) { @@ -20,6 +57,36 @@ export default { }, }, Mutation: { + block: 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:FOLLOWS]->(b:User {id: $args.id}) + DELETE r + `, + { currentUser, args }, + ) + const [user, blockedUser] = await Promise.all([ + instance.find('User', currentUser.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 { @@ -73,6 +140,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)', @@ -91,7 +164,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/resolvers/users/blockedUsers.spec.js b/backend/src/schema/resolvers/users/blockedUsers.spec.js new file mode 100644 index 000000000..887c34494 --- /dev/null +++ b/backend/src/schema/resolvers/users/blockedUsers.spec.js @@ -0,0 +1,393 @@ +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 authenticatedUser +let server + +beforeEach(() => { + authenticatedUser = undefined + ;({ server } = createServer({ + context: () => { + return { + user: authenticatedUser, + driver, + neode: instance, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, + } + }, + })) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('blockedUsers', () => { + let blockedUserQuery + beforeEach(() => { + blockedUserQuery = gql` + query { + blockedUsers { + id + name + isBlocked + } + } + ` + }) + + 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') + authenticatedUser = await currentUser.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, + }, + ], + }, + }), + ) + }) + }) +}) + +describe('block', () => { + 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', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + authenticatedUser = 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', () => { + 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 () => { + await currentUser.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('given both the current user and the to-be-blocked user write a post', () => { + let postQuery + + beforeEach(async () => { + const post1 = await instance.create('Post', { + id: 'p12', + title: 'A post written by the current user', + }) + const post2 = await instance.create('Post', { + id: 'p23', + title: 'A post written by the blocked user', + }) + await Promise.all([ + post1.relateTo(currentUser, 'author'), + post2.relateTo(blockedUser, 'author'), + ]) + postQuery = gql` + query { + Post(orderBy: createdAt_asc) { + id + title + author { + id + name + } + } + } + ` + }) + + const bothPostsAreInTheNewsfeed = async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p12', + title: 'A post written by the current user', + author: { + name: 'Current User', + id: 'u1', + }, + }, + { + id: 'p23', + title: 'A post written by the blocked user', + author: { + name: 'Blocked User', + id: 'u2', + }, + }, + ], + }, + }), + ) + } + + describe('from the perspective of the current user', () => { + it('both posts are in the newsfeed', bothPostsAreInTheNewsfeed) + + describe('but if the current user blocks the other user', () => { + beforeEach(async () => { + await currentUser.relateTo(blockedUser, 'blocked') + }) + + it("the blocked user's post won't show up in the newsfeed of the current user", async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p12', + title: 'A post written by the current user', + author: { name: 'Current User', id: 'u1' }, + }, + ], + }, + }), + ) + }) + }) + }) + + describe('from the perspective of the blocked user', () => { + beforeEach(async () => { + authenticatedUser = await blockedUser.toJson() + }) + + it('both posts are in the newsfeed', bothPostsAreInTheNewsfeed) + describe('but if the current user blocks the other user', () => { + beforeEach(async () => { + await currentUser.relateTo(blockedUser, 'blocked') + }) + + it("the current user's post won't show up in the newsfeed of the blocked user", async () => { + const { query } = createTestClient(server) + await expect(query({ query: postQuery })).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + id: 'p23', + title: 'A post written by the blocked user', + author: { name: 'Blocked User', id: 'u2' }, + }, + ], + }, + }), + ) + }) + }) + }) + }) + }) + }) +}) + +describe('unblock', () => { + 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', () => { + beforeEach(async () => { + currentUser = await instance.create('User', { + name: 'Current User', + id: 'u1', + }) + authenticatedUser = 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', () => { + beforeEach(async () => { + blockedUser = await 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 currentUser.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 } }, + }), + ) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index d2651215b..560669a7d 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -4,15 +4,16 @@ type Query { currentUser: User # Get the latest Network Statistics statistics: Statistics! - findPosts(filter: String!, limit: Int = 10): [Post]! + findPosts(query: String!, limit: Int = 10): [Post]! @cypher( statement: """ - CALL db.index.fulltext.queryNodes('full_text_search', $filter) + CALL db.index.fulltext.queryNodes('full_text_search', $query) YIELD node as post, score MATCH (post)<-[:WROTE]-(user:User) WHERE score >= 0.2 AND NOT user.deleted = true AND NOT user.disabled = true AND NOT post.deleted = true AND NOT post.disabled = true + AND NOT user.id in COALESCE($filter.author_not.id_in, []) RETURN post LIMIT $limit """ @@ -50,15 +51,6 @@ type Statistics { countShouts: Int! } -type Notification { - id: ID! - read: Boolean - user: User @relation(name: "NOTIFIED", direction: "OUT") - post: Post @relation(name: "NOTIFIED", direction: "IN") - comment: Comment @relation(name: "NOTIFIED", direction: "IN") - createdAt: String -} - type Location { id: ID! name: String! diff --git a/backend/src/schema/types/type/Category.gql b/backend/src/schema/types/type/Category.gql index 5920ebbdb..9ee628d76 100644 --- a/backend/src/schema/types/type/Category.gql +++ b/backend/src/schema/types/type/Category.gql @@ -10,4 +10,4 @@ type Category { posts: [Post]! @relation(name: "CATEGORIZED", direction: "IN") postCount: Int! @cypher(statement: "MATCH (this)<-[:CATEGORIZED]-(r:Post) RETURN COUNT(r)") -} \ No newline at end of file +} diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql new file mode 100644 index 000000000..e4bc16fec --- /dev/null +++ b/backend/src/schema/types/type/Notification.gql @@ -0,0 +1,7 @@ +type Notification { + id: ID! + read: Boolean + user: User @relation(name: "NOTIFIED", direction: "OUT") + post: Post @relation(name: "NOTIFIED", direction: "IN") + createdAt: String +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 2534463d1..e3e8f8450 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]! @@ -67,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") @@ -148,6 +152,8 @@ type Query { orderBy: [_UserOrdering] filter: _UserFilter ): [User] + + blockedUsers: [User] } type Mutation { @@ -164,4 +170,8 @@ type Mutation { ): User DeleteUser(id: ID!, resource: [Deletable]): User + + + block(id: ID!): User + unblock(id: ID!): User } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 07e4f0625..86f755a24 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -127,73 +127,35 @@ 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', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u4', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u5', - }), - f.relate('User', 'Blacklisted', { - from: 'u7', - to: 'u6', - }), - ]) + 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([ f.create('Category', { id: 'cat1', name: 'Just For Fun', - slug: 'justforfun', + slug: 'just-for-fun', icon: 'smile', }), f.create('Category', { id: 'cat2', - name: 'Happyness & Values', - slug: 'happyness-values', + name: 'Happiness & Values', + slug: 'happiness-values', icon: 'heart-o', }), f.create('Category', { @@ -211,13 +173,13 @@ import Factory from './factories' f.create('Category', { id: 'cat5', name: 'Animal Protection', - slug: 'animalprotection', + slug: 'animal-protection', icon: 'paw', }), f.create('Category', { id: 'cat6', - name: 'Humanrights Justice', - slug: 'humanrights-justice', + name: 'Human Rights & Justice', + slug: 'human-rights-justice', icon: 'balance-scale', }), f.create('Category', { @@ -253,19 +215,19 @@ import Factory from './factories' f.create('Category', { id: 'cat12', name: 'IT, Internet & Data Privacy', - slug: 'it-internet-dataprivacy', + slug: 'it-internet-data-privacy', icon: 'mouse-pointer', }), f.create('Category', { id: 'cat13', - name: 'Art, Curlure & Sport', + name: 'Art, Culture & Sport', slug: 'art-culture-sport', icon: 'paint-brush', }), f.create('Category', { id: 'cat14', name: 'Freedom of Speech', - slug: 'freedomofspeech', + slug: 'freedom-of-speech', icon: 'bullhorn', }), f.create('Category', { @@ -277,7 +239,7 @@ import Factory from './factories' f.create('Category', { id: 'cat16', name: 'Global Peace & Nonviolence', - slug: 'globalpeace-nonviolence', + slug: 'global-peace-nonviolence', icon: 'angellist', }), ]) diff --git a/backend/src/server.js b/backend/src/server.js index 5f13daeea..752cd96fb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,7 +3,7 @@ import helmet from 'helmet' import { ApolloServer } from 'apollo-server-express' import CONFIG, { requiredConfigs } from './config' import middleware from './middleware' -import { getDriver } from './bootstrap/neo4j' +import { neode as getNeode, getDriver } from './bootstrap/neo4j' import decode from './jwt/decode' import schema from './schema' @@ -16,6 +16,7 @@ Object.entries(requiredConfigs).map(entry => { }) const driver = getDriver() +const neode = getNeode() const createServer = options => { const defaults = { @@ -23,6 +24,7 @@ const createServer = options => { const user = await decode(driver, req.headers.authorization) return { driver, + neode, user, req, cypherParams: { diff --git a/backend/yarn.lock b/backend/yarn.lock index de67894e7..2a4ba2d1a 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -707,10 +707,18 @@ pirates "^4.0.0" source-map-support "^0.5.9" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.4.4": - version "7.4.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.5.tgz#582bb531f5f9dc67d2fcb682979894f75e253f12" - integrity sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ== +"@babel/runtime-corejs2@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.5.5.tgz#c3214c08ef20341af4187f1c9fbdc357fbec96b2" + integrity sha512-FYATQVR00NSNi7mUfpPDp7E8RYMXDuO8gaix7u/w3GekfUinKgX1AcTxs7SoiEmoEW9mbpjrwqWSW6zCmw5h8A== + dependencies: + core-js "^2.6.5" + regenerator-runtime "^0.13.2" + +"@babel/runtime@^7.0.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" + integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== dependencies: regenerator-runtime "^0.13.2" @@ -5638,7 +5646,7 @@ lodash.unescape@4.0.1: resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c" integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw= -lodash@4.17.15, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.11, lodash@~4.17.14, lodash@~4.17.15: +lodash@4.17.15, lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.11, lodash@~4.17.14, lodash@~4.17.15: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -6133,14 +6141,16 @@ neo4j-driver@^1.6.3, neo4j-driver@^1.7.3, neo4j-driver@~1.7.5: text-encoding-utf-8 "^1.0.2" uri-js "^4.2.2" -neo4j-graphql-js@^2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.6.3.tgz#8f28c2479adda08c90abcc32a784587ef49b8b95" - integrity sha512-WZdEqQ8EL9GOIB1ZccbLk1BZz5Dqdbk9i8BDXqxhp1SOI07P9y2cZ244f2Uz4zyES9AVXGmv+861N5xLhrSL2A== +neo4j-graphql-js@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.7.0.tgz#4f3f94436c494b1488d914022e00155fb7a58112" + integrity sha512-518T6KTDFs9qXa3i+uou7pUi0pBW+Yra2CR7cVcMeUpqJqh8CjxpGz8CJDX7K34MmlSZm3MlA4A1v4tAU1JePw== dependencies: + "@babel/runtime" "^7.5.5" + "@babel/runtime-corejs2" "^7.5.5" graphql "^14.2.1" graphql-auth-directives "^2.1.0" - lodash "^4.17.11" + lodash "^4.17.15" neo4j-driver "^1.7.3" neode@^0.3.1: diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 5a3819a9d..35b2d1346 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -11,6 +11,13 @@ Then("I should have one post in the select dropdown", () => { }); }); +Then("the search has no results", () => { + cy.get(".input .ds-select-dropdown").should($li => { + expect($li).to.have.length(1); + }); + cy.get(".ds-select-dropdown").should("contain", 'Nothing found'); +}); + Then("I should see the following posts in the select dropdown:", table => { table.hashes().forEach(({ title }) => { cy.get(".ds-select-dropdown").should("contain", title); diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 387273eff..e1eab98c1 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -362,3 +362,96 @@ Then("the notification gets marked as read", () => { Then("there are no notifications in the top menu", () => { cy.get(".notifications-menu").should("contain", "0"); }); + +Given("there is an annoying user called {string}", (name) => { + const annoyingParams = { + email: 'spammy-spammer@example.org', + password: '1234', + } + cy.factory().create('User', { + ...annoyingParams, + id: 'annoying-user', + name + }) +}) + +Given("I am on the profile page of the annoying user", (name) => { + cy.openPage('/profile/annoying-user/spammy-spammer'); +}) + +When("I visit the profile page of the annoying user", (name) => { + cy.openPage('/profile/annoying-user'); +}) + +When("I ", (name) => { + cy.openPage('/profile/annoying-user'); +}) + +When("I click on {string} from the content menu in the user info box", (button) => { + cy.get('.user-content-menu .content-menu-trigger') + .click() + cy.get('.popover .ds-menu-item-link') + .contains(button) + .click() +}) + +When ("I navigate to my {string} settings page", (settingsPage) => { + cy.get(".avatar-menu").click(); + cy.get(".avatar-menu-popover") + .find('a[href]').contains("Settings").click() + cy.contains('.ds-menu-item-link', settingsPage).click() +}) + +Given("I follow the user {string}", (name) => { + cy.neode() + .first('User', { name }).then((followed) => { + cy.neode() + .first('User', {name: narratorParams.name}) + .relateTo(followed, 'following') + }) +}) + +Given("\"Spammy Spammer\" wrote a post {string}", (title) => { + cy.factory() + .authenticateAs({ + email: 'spammy-spammer@example.org', + password: '1234', + }) + .create("Post", { title }) +}) + +Then("the list of posts of this user is empty", () => { + cy.get('.ds-card-content').not('.post-link') + cy.get('.main-container').find('.ds-space.hc-empty') +}) + +Then("nobody is following the user profile anymore", () => { + cy.get('.ds-card-content').not('.post-link') + cy.get('.main-container').contains('.ds-card-content', 'is not followed by anyone') +}) + +Given("I wrote a post {string}", (title) => { + cy.factory() + .authenticateAs(loginCredentials) + .create("Post", { title }) +}) + +When("I block the user {string}", (name) => { + cy.neode() + .first('User', { name }).then((blocked) => { + cy.neode() + .first('User', {name: narratorParams.name}) + .relateTo(blocked, 'blocked') + }) +}) + +When("I log in with:", (table) => { + const [firstRow] = table.hashes() + const { Email, Password } = firstRow + cy.login({email: Email, password: Password}) +}) + +Then("I see only one post with the title {string}", (title) => { + cy.get('.main-container').find('.post-link').should('have.length', 1) + cy.get('.main-container').contains('.post-link', title) +}) diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature new file mode 100644 index 000000000..9ab4fde6e --- /dev/null +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -0,0 +1,36 @@ +Feature: Block a User + As a user + I'd like to have a button to block another user + 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 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 diff --git a/cypress/integration/user_profile/blocked-users/Content.feature b/cypress/integration/user_profile/blocked-users/Content.feature new file mode 100644 index 000000000..edc0d63b9 --- /dev/null +++ b/cypress/integration/user_profile/blocked-users/Content.feature @@ -0,0 +1,22 @@ +Feature: Block a User + As a user + I'd like to have a button to block another user + 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 | + |