diff --git a/backend/package.json b/backend/package.json index da76c385d..117c14830 100644 --- a/backend/package.json +++ b/backend/package.json @@ -90,7 +90,7 @@ "minimatch": "^3.0.4", "neo4j-driver": "~1.7.5", "neo4j-graphql-js": "^2.7.1", - "neode": "^0.3.1", + "neode": "^0.3.2", "node-fetch": "~2.6.0", "nodemailer": "^6.3.0", "npm-run-all": "~4.1.5", @@ -98,7 +98,7 @@ "sanitize-html": "~1.20.1", "slug": "~1.1.0", "trunc-html": "~1.1.2", - "uuid": "~3.3.2", + "uuid": "~3.3.3", "wait-on": "~3.3.0" }, "devDependencies": { @@ -115,14 +115,14 @@ "chai": "~4.2.0", "cucumber": "~5.1.0", "eslint": "~6.2.0", - "eslint-config-prettier": "~6.0.0", + "eslint-config-prettier": "~6.1.0", "eslint-config-standard": "~13.0.1", "eslint-plugin-import": "~2.18.2", "eslint-plugin-jest": "~22.15.1", "eslint-plugin-node": "~9.1.0", "eslint-plugin-prettier": "~3.1.0", "eslint-plugin-promise": "~4.2.1", - "eslint-plugin-standard": "~4.0.0", + "eslint-plugin-standard": "~4.0.1", "graphql-request": "~1.8.2", "jest": "~24.9.0", "nodemon": "~1.19.1", diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js index 287ba34e6..c17d2e5fb 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.js @@ -1,39 +1,57 @@ import extractMentionedUsers from './notifications/extractMentionedUsers' import extractHashtags from './hashtags/extractHashtags' -const notify = async (postId, idsOfMentionedUsers, context) => { +const notifyMentions = async (label, id, idsOfMentionedUsers, context) => { + if (!idsOfMentionedUsers.length) return + const session = context.driver.session() const createdAt = new Date().toISOString() - const cypher = ` - MATCH(p:Post {id: $postId})<-[: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 (p)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u) + let cypher + if (label === 'Post') { + cypher = ` + MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfMentionedUsers + AND NOT (user)<-[:BLOCKED]-(author) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) + MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) ` + } else { + cypher = ` + MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfMentionedUsers + AND NOT (user)<-[:BLOCKED]-(author) + AND NOT (user)<-[:BLOCKED]-(postAuthor) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) + MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + } await session.run(cypher, { idsOfMentionedUsers, + label, createdAt, - postId, + id, }) session.close() } const updateHashtagsOfPost = async (postId, hashtags, context) => { + if (!hashtags.length) return + const session = context.driver.session() // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted // and no new Hashtags and relations will be created. const cypherDeletePreviousRelations = ` - MATCH (p:Post { id: $postId })-[previousRelations:TAGGED]->(t:Tag) + MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) DELETE previousRelations RETURN p, t ` const cypherCreateNewTagsAndRelations = ` - MATCH (p:Post { id: $postId}) + MATCH (p: Post { id: $postId}) UNWIND $hashtags AS tagName - MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false }) + MERGE (t: Tag { id: tagName, name: tagName, disabled: false, deleted: false }) MERGE (p)-[:TAGGED]->(t) RETURN p, t ` @@ -47,24 +65,32 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => { session.close() } -const handleContentData = async (resolve, root, args, context, resolveInfo) => { - // extract user ids before xss-middleware removes classes via the following "resolve" call +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const idsOfMentionedUsers = extractMentionedUsers(args.content) - // extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call const hashtags = extractHashtags(args.content) - // removes classes from the content const post = await resolve(root, args, context, resolveInfo) - await notify(post.id, idsOfMentionedUsers, context) + await notifyMentions('Post', post.id, idsOfMentionedUsers, context) await updateHashtagsOfPost(post.id, hashtags, context) return post } +const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { + const idsOfMentionedUsers = extractMentionedUsers(args.content) + const comment = await resolve(root, args, context, resolveInfo) + + await notifyMentions('Comment', comment.id, idsOfMentionedUsers, context) + + return comment +} + export default { Mutation: { - CreatePost: handleContentData, - UpdatePost: handleContentData, + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + CreateComment: handleContentDataOfComment, + UpdateComment: handleContentDataOfComment, }, } diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js index 7f77b4589..2925f92cf 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js @@ -75,6 +75,9 @@ describe('notifications', () => { post { content } + comment { + content + } } } } @@ -86,12 +89,12 @@ describe('notifications', () => { }) describe('given another user', () => { - let author + let postAuthor beforeEach(async () => { - author = await instance.create('User', { - email: 'author@example.org', + postAuthor = await instance.create('User', { + email: 'post-author@example.org', password: '1234', - id: 'author', + id: 'postAuthor', }) }) @@ -101,7 +104,7 @@ describe('notifications', () => { 'Hey @al-capone how do you do?' const createPostAction = async () => { - authenticatedUser = await author.toJson() + authenticatedUser = await postAuthor.toJson() await mutate({ mutation: createPostMutation, variables: { id: 'p47', title, content, categoryIds }, @@ -115,12 +118,27 @@ describe('notifications', () => { 'Hey @al-capone how do you do?' const expected = expect.objectContaining({ data: { - currentUser: { notifications: [{ read: false, post: { content: expectedContent } }] }, + currentUser: { + notifications: [ + { + read: false, + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, }, }) const { query } = createTestClient(server) await expect( - query({ query: notificationQuery, variables: { read: false } }), + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), ).resolves.toEqual(expected) }) @@ -140,7 +158,7 @@ describe('notifications', () => { @al-capone ` - authenticatedUser = await author.toJson() + authenticatedUser = await postAuthor.toJson() await mutate({ mutation: updatePostMutation, variables: { @@ -162,31 +180,114 @@ describe('notifications', () => { data: { currentUser: { notifications: [ - { read: false, post: { content: expectedContent } }, - { read: false, post: { content: expectedContent } }, + { + read: false, + post: { + content: expectedContent, + }, + comment: null, + }, + { + read: false, + post: { + content: expectedContent, + }, + comment: null, + }, ], }, }, }) await expect( - query({ query: notificationQuery, variables: { 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') + await postAuthor.relateTo(user, 'blocked') }) it('sends no notification', async () => { await createPostAction() const expected = expect.objectContaining({ - data: { currentUser: { notifications: [] } }, + data: { + currentUser: { + notifications: [], + }, + }, }) const { query } = createTestClient(server) await expect( - query({ query: notificationQuery, variables: { read: false } }), + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me and a mentioner mentions me in a comment', () => { + const createCommentOnPostAction = async () => { + await createPostAction() + const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $commentContent: String!) { + CreateComment(id: $id, postId: $postId, content: $commentContent) { + id + content + } + } + ` + authenticatedUser = await commentMentioner.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'c47', + postId: 'p47', + commentContent: + 'One mention of me with .', + }, + }) + authenticatedUser = await user.toJson() + } + let commentMentioner + + beforeEach(async () => { + await postAuthor.relateTo(user, 'blocked') + commentMentioner = await instance.create('User', { + id: 'mentioner', + name: 'Mr Mentioner', + slug: 'mr-mentioner', + email: 'mentioner@example.org', + password: '1234', + }) + }) + + it('sends no notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), ).resolves.toEqual(expected) }) }) @@ -234,7 +335,10 @@ describe('Hashtags', () => { it('both Hashtags are created with the "id" set to their "name"', async () => { const expected = [{ id: 'Democracy' }, { id: 'Liberty' }] await expect( - query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }), + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), ).resolves.toEqual( expect.objectContaining({ data: { @@ -266,11 +370,18 @@ describe('Hashtags', () => { const expected = [{ id: 'Elections' }, { id: 'Liberty' }] await expect( - query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }), + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), ).resolves.toEqual( expect.objectContaining({ data: { - Post: [{ tags: expect.arrayContaining(expected) }], + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], }, }), ) diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 47bb0d515..6b173514f 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -27,7 +27,7 @@ afterEach(async () => { }) describe('Notification', () => { - const query = gql` + const notificationQuery = gql` query { Notification { id @@ -38,19 +38,24 @@ describe('Notification', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(query)).rejects.toThrow('Not Authorised') + await expect(client.request(notificationQuery)).rejects.toThrow('Not Authorised') }) }) }) -describe('currentUser { notifications }', () => { +describe('currentUser notifications', () => { const variables = {} describe('authenticated', () => { let headers beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) }) describe('given some notifications', () => { @@ -62,24 +67,92 @@ describe('currentUser { notifications }', () => { } await Promise.all([ factory.create('User', neighborParams), - factory.create('Notification', { id: 'not-for-you' }), - factory.create('Notification', { id: 'already-seen', read: true }), + factory.create('Notification', { + id: 'post-mention-not-for-you', + }), + factory.create('Notification', { + id: 'post-mention-already-seen', + read: true, + }), + factory.create('Notification', { + id: 'post-mention-unseen', + }), + factory.create('Notification', { + id: 'comment-mention-not-for-you', + }), + factory.create('Notification', { + id: 'comment-mention-already-seen', + read: true, + }), + factory.create('Notification', { + id: 'comment-mention-unseen', + }), ]) - await factory.create('Notification', { id: 'unseen' }) await factory.authenticateAs(neighborParams) await factory.create('Post', { id: 'p1', categoryIds }) await Promise.all([ - factory.relate('Notification', 'User', { from: 'not-for-you', to: 'neighbor' }), - factory.relate('Notification', 'Post', { from: 'p1', to: 'not-for-you', categoryIds }), - factory.relate('Notification', 'User', { from: 'unseen', to: 'you' }), - factory.relate('Notification', 'Post', { from: 'p1', to: 'unseen', categoryIds }), - factory.relate('Notification', 'User', { from: 'already-seen', to: 'you' }), - factory.relate('Notification', 'Post', { from: 'p1', to: 'already-seen', categoryIds }), + factory.relate('Notification', 'User', { + from: 'post-mention-not-for-you', + to: 'neighbor', + }), + factory.relate('Notification', 'Post', { + from: 'p1', + to: 'post-mention-not-for-you', + }), + factory.relate('Notification', 'User', { + from: 'post-mention-unseen', + to: 'you', + }), + factory.relate('Notification', 'Post', { + from: 'p1', + to: 'post-mention-unseen', + }), + factory.relate('Notification', 'User', { + from: 'post-mention-already-seen', + to: 'you', + }), + factory.relate('Notification', 'Post', { + from: 'p1', + to: 'post-mention-already-seen', + }), + ]) + // Comment and its notifications + await Promise.all([ + factory.create('Comment', { + id: 'c1', + postId: 'p1', + }), + ]) + await Promise.all([ + factory.relate('Notification', 'User', { + from: 'comment-mention-not-for-you', + to: 'neighbor', + }), + factory.relate('Notification', 'Comment', { + from: 'c1', + to: 'comment-mention-not-for-you', + }), + factory.relate('Notification', 'User', { + from: 'comment-mention-unseen', + to: 'you', + }), + factory.relate('Notification', 'Comment', { + from: 'c1', + to: 'comment-mention-unseen', + }), + factory.relate('Notification', 'User', { + from: 'comment-mention-already-seen', + to: 'you', + }), + factory.relate('Notification', 'Comment', { + from: 'c1', + to: 'comment-mention-already-seen', + }), ]) }) describe('filter for read: false', () => { - const query = gql` + const queryCurrentUserNotificationsFilterRead = gql` query($read: Boolean) { currentUser { notifications(read: $read, orderBy: createdAt_desc) { @@ -87,6 +160,9 @@ describe('currentUser { notifications }', () => { post { id } + comment { + id + } } } } @@ -95,15 +171,32 @@ describe('currentUser { notifications }', () => { it('returns only unread notifications of current user', async () => { const expected = { currentUser: { - notifications: [{ id: 'unseen', post: { id: 'p1' } }], + notifications: expect.arrayContaining([ + { + id: 'post-mention-unseen', + post: { + id: 'p1', + }, + comment: null, + }, + { + id: 'comment-mention-unseen', + post: null, + comment: { + id: 'c1', + }, + }, + ]), }, } - await expect(client.request(query, variables)).resolves.toEqual(expected) + await expect( + client.request(queryCurrentUserNotificationsFilterRead, variables), + ).resolves.toEqual(expected) }) }) describe('no filters', () => { - const query = gql` + const queryCurrentUserNotifications = gql` query { currentUser { notifications(orderBy: createdAt_desc) { @@ -111,6 +204,9 @@ describe('currentUser { notifications }', () => { post { id } + comment { + id + } } } } @@ -118,13 +214,41 @@ describe('currentUser { notifications }', () => { it('returns all notifications of current user', async () => { const expected = { currentUser: { - notifications: [ - { id: 'unseen', post: { id: 'p1' } }, - { id: 'already-seen', post: { id: 'p1' } }, - ], + notifications: expect.arrayContaining([ + { + id: 'post-mention-unseen', + post: { + id: 'p1', + }, + comment: null, + }, + { + id: 'post-mention-already-seen', + post: { + id: 'p1', + }, + comment: null, + }, + { + id: 'comment-mention-unseen', + comment: { + id: 'c1', + }, + post: null, + }, + { + id: 'comment-mention-already-seen', + comment: { + id: 'c1', + }, + post: null, + }, + ]), }, } - await expect(client.request(query, variables)).resolves.toEqual(expected) + await expect(client.request(queryCurrentUserNotifications, variables)).resolves.toEqual( + expected, + ) }) }) }) @@ -132,7 +256,7 @@ describe('currentUser { notifications }', () => { }) describe('UpdateNotification', () => { - const mutation = gql` + const mutationUpdateNotification = gql` mutation($id: ID!, $read: Boolean) { UpdateNotification(id: $id, read: $read) { id @@ -140,9 +264,16 @@ describe('UpdateNotification', () => { } } ` - const variables = { id: 'to-be-updated', read: true } + const variablesPostUpdateNotification = { + id: 'post-mention-to-be-updated', + read: true, + } + const variablesCommentUpdateNotification = { + id: 'comment-mention-to-be-updated', + read: true, + } - describe('given a notifications', () => { + describe('given some notifications', () => { let headers beforeEach(async () => { @@ -152,42 +283,105 @@ describe('UpdateNotification', () => { password: '1234', slug: 'mentioned', } - await factory.create('User', mentionedParams) - await factory.create('Notification', { id: 'to-be-updated' }) + await Promise.all([ + factory.create('User', mentionedParams), + factory.create('Notification', { + id: 'post-mention-to-be-updated', + }), + factory.create('Notification', { + id: 'comment-mention-to-be-updated', + }), + ]) await factory.authenticateAs(userParams) await factory.create('Post', { id: 'p1', categoryIds }) await Promise.all([ - factory.relate('Notification', 'User', { from: 'to-be-updated', to: 'mentioned-1' }), - factory.relate('Notification', 'Post', { from: 'p1', to: 'to-be-updated' }), + factory.relate('Notification', 'User', { + from: 'post-mention-to-be-updated', + to: 'mentioned-1', + }), + factory.relate('Notification', 'Post', { + from: 'p1', + to: 'post-mention-to-be-updated', + }), + ]) + // Comment and its notifications + await Promise.all([ + factory.create('Comment', { + id: 'c1', + postId: 'p1', + }), + ]) + await Promise.all([ + factory.relate('Notification', 'User', { + from: 'comment-mention-to-be-updated', + to: 'mentioned-1', + }), + factory.relate('Notification', 'Comment', { + from: 'p1', + to: 'comment-mention-to-be-updated', + }), ]) }) describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect( + client.request(mutationUpdateNotification, variablesPostUpdateNotification), + ).rejects.toThrow('Not Authorised') }) }) describe('authenticated', () => { beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) }) it('throws authorization error', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect( + client.request(mutationUpdateNotification, variablesPostUpdateNotification), + ).rejects.toThrow('Not Authorised') }) describe('and owner', () => { beforeEach(async () => { - headers = await login({ email: 'mentioned@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'mentioned@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) }) - it('updates notification', async () => { - const expected = { UpdateNotification: { id: 'to-be-updated', read: true } } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + it('updates post notification', async () => { + const expected = { + UpdateNotification: { + id: 'post-mention-to-be-updated', + read: true, + }, + } + await expect( + client.request(mutationUpdateNotification, variablesPostUpdateNotification), + ).resolves.toEqual(expected) + }) + + it('updates comment notification', async () => { + const expected = { + UpdateNotification: { + id: 'comment-mention-to-be-updated', + read: true, + }, + } + await expect( + client.request(mutationUpdateNotification, variablesCommentUpdateNotification), + ).resolves.toEqual(expected) }) }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 560669a7d..e0a2c328b 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -123,4 +123,3 @@ type SharedInboxEndpoint { id: ID! uri: String } - diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql index e4bc16fec..0f94c2301 100644 --- a/backend/src/schema/types/type/Notification.gql +++ b/backend/src/schema/types/type/Notification.gql @@ -3,5 +3,6 @@ type Notification { 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 } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index a0640f28e..86f755a24 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -44,42 +44,49 @@ import Factory from './factories' f.create('User', { id: 'u1', name: 'Peter Lustig', + slug: 'peter-lustig', role: 'admin', email: 'admin@example.org', }), f.create('User', { id: 'u2', name: 'Bob der Baumeister', + slug: 'bob-der-baumeister', role: 'moderator', email: 'moderator@example.org', }), f.create('User', { id: 'u3', name: 'Jenny Rostock', + slug: 'jenny-rostock', role: 'user', email: 'user@example.org', }), f.create('User', { id: 'u4', - name: 'Tick', + name: 'Huey (Tick)', + slug: 'huey-tick', role: 'user', - email: 'tick@example.org', + email: 'huey@example.org', }), f.create('User', { id: 'u5', - name: 'Trick', + name: 'Dewey (Trick)', + slug: 'dewey-trick', role: 'user', - email: 'trick@example.org', + email: 'dewey@example.org', }), f.create('User', { id: 'u6', - name: 'Track', + name: 'Louie (Track)', + slug: 'louie-track', role: 'user', - email: 'track@example.org', + email: 'louie@example.org', }), f.create('User', { id: 'u7', name: 'Dagobert', + slug: 'dagobert', role: 'user', email: 'dagobert@example.org', }), @@ -99,15 +106,15 @@ import Factory from './factories' password: '1234', }), Factory().authenticateAs({ - email: 'tick@example.org', + email: 'huey@example.org', password: '1234', }), Factory().authenticateAs({ - email: 'trick@example.org', + email: 'dewey@example.org', password: '1234', }), Factory().authenticateAs({ - email: 'track@example.org', + email: 'louie@example.org', password: '1234', }), ]) @@ -260,6 +267,10 @@ import Factory from './factories' 'Hey @jenny-rostock, what\'s up?' const mention2 = 'Hey @jenny-rostock, here is another notification for you!' + const hashtag1 = + 'See #NaturphilosophieYoga can really help you!' + const hashtagAndMention1 = + 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' await Promise.all([ asAdmin.create('Post', { @@ -272,6 +283,8 @@ import Factory from './factories' }), asUser.create('Post', { id: 'p2', + title: `Nature Philosophy Yoga`, + content: `${hashtag1}`, }), asTick.create('Post', { id: 'p3', @@ -293,6 +306,8 @@ import Factory from './factories' asUser.create('Post', { id: 'p8', image: faker.image.unsplash.nature(), + title: `Quantum Flow Theory explains Quantum Gravity`, + content: `${hashtagAndMention1}`, }), asTick.create('Post', { id: 'p9', @@ -639,6 +654,11 @@ import Factory from './factories' }), ]) + const mentionInComment1 = + 'I heard @jenny-rostock, practice it since 3 years now.' + const mentionInComment2 = + 'Did @peter-lustig told you?' + await Promise.all([ asUser.create('Comment', { id: 'c1', @@ -655,6 +675,12 @@ import Factory from './factories' asTrick.create('Comment', { id: 'c4', postId: 'p2', + content: `${mentionInComment1}`, + }), + asUser.create('Comment', { + id: 'c4-1', + postId: 'p2', + content: `${mentionInComment2}`, }), asModerator.create('Comment', { id: 'c5', diff --git a/backend/yarn.lock b/backend/yarn.lock index 77c92db0c..f75f70a8c 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -3267,10 +3267,10 @@ escodegen@^1.9.1: optionalDependencies: source-map "~0.6.1" -eslint-config-prettier@~6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#f429a53bde9fc7660e6353910fd996d6284d3c25" - integrity sha512-vDrcCFE3+2ixNT5H83g28bO/uYAwibJxerXPj+E7op4qzBCsAV36QfvdAyVOoNxKAH2Os/e01T/2x++V0LPukA== +eslint-config-prettier@~6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.1.0.tgz#e6f678ba367fbd1273998d5510f76f004e9dce7b" + integrity sha512-k9fny9sPjIBQ2ftFTesJV21Rg4R/7a7t7LCtZVrYQiHEp8Nnuk3EGaDmsKSAnsPj0BYcgB2zxzHa2NTkIxcOLg== dependencies: get-stdin "^6.0.0" @@ -3351,10 +3351,10 @@ eslint-plugin-promise@~4.2.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== -eslint-plugin-standard@~4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.0.tgz#f845b45109c99cd90e77796940a344546c8f6b5c" - integrity sha512-OwxJkR6TQiYMmt1EsNRMe5qG3GsbjlcOhbGUBY4LtavF9DsLaTcoR+j2Tdjqi23oUwKNUqX7qcn5fPStafMdlA== +eslint-plugin-standard@~4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4" + integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ== eslint-scope@3.7.1: version "3.7.1" @@ -6163,7 +6163,7 @@ neo-async@^2.6.0: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== -neo4j-driver@^1.6.3, neo4j-driver@^1.7.3, neo4j-driver@~1.7.5: +neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.5: version "1.7.5" resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4" integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw== @@ -6184,14 +6184,14 @@ neo4j-graphql-js@^2.7.1: lodash "^4.17.15" neo4j-driver "^1.7.3" -neode@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.1.tgz#d40147bf20d6951b69c9d392fbdd322aeca07816" - integrity sha512-SdaJmdjQ3PWOH6W1H8Xgd2CLyJs+BPPXPt0jOVNs7naeQH8nWPP6ixDqI6NWDCxwecTdNl//fpAicB9I6hCwEw== +neode@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.2.tgz#ced277e1daba26a77c48f5857c30af054f11c7df" + integrity sha512-Bm4GBXdXunv8cqUUkJtksIGHDnYdBJf4UHwzFgXbJiDKBAdqfjhzwAPAhf1PrvlFmR4vJva2Bh/XvIghYOiKrA== dependencies: "@hapi/joi" "^15.1.0" dotenv "^4.0.0" - neo4j-driver "^1.6.3" + neo4j-driver "^1.7.5" uuid "^3.3.2" next-tick@^1.0.0: @@ -8552,10 +8552,10 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== +uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" + integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ== v8-compile-cache@^2.0.3: version "2.0.3" diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 608f6468d..7192b097b 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -1,6 +1,6 @@ import { Given, When, Then } from "cypress-cucumber-preprocessor/steps"; import { getLangByName } from "../../support/helpers"; -import slugify from 'slug' +import slugify from "slug"; /* global cy */ @@ -12,7 +12,7 @@ let loginCredentials = { }; const narratorParams = { name: "Peter Pan", - slug: 'peter-pan', + slug: "peter-pan", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg", ...loginCredentials }; @@ -174,10 +174,10 @@ When("I press {string}", label => { Given("we have the following posts in our database:", table => { table.hashes().forEach(({ Author, ...postAttributes }, i) => { - Author = Author || `author-${i}` + Author = Author || `author-${i}`; const userAttributes = { name: Author, - email: `${slugify(Author, {lower: true})}@example.org`, + email: `${slugify(Author, { lower: true })}@example.org`, password: "1234" }; postAttributes.deleted = Boolean(postAttributes.deleted); @@ -363,95 +363,106 @@ 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) => { +Given("there is an annoying user called {string}", name => { const annoyingParams = { - email: 'spammy-spammer@example.org', - password: '1234', - } - cy.factory().create('User', { + email: "spammy-spammer@example.org", + password: "1234" + }; + cy.factory().create("User", { ...annoyingParams, - id: 'annoying-user', + id: "annoying-user", name - }) -}) + }); +}); -Given("I am on the profile page of the annoying user", (name) => { - cy.openPage('/profile/annoying-user/spammy-spammer'); -}) +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 visit the profile page of the annoying user", name => { + cy.openPage("/profile/annoying-user"); +}); -When("I ", (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 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({ force: true }); + } +); -When ("I navigate to my {string} settings page", (settingsPage) => { +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() -}) + .find("a[href]") + .contains("Settings") + .click(); + cy.contains(".ds-menu-item-link", settingsPage).click(); +}); -Given("I follow the user {string}", (name) => { +Given("I follow the user {string}", name => { cy.neode() - .first('User', { name }).then((followed) => { + .first("User", { name }) + .then(followed => { cy.neode() - .first('User', {name: narratorParams.name}) - .relateTo(followed, 'following') - }) -}) + .first("User", { name: narratorParams.name }) + .relateTo(followed, "following"); + }); +}); -Given("\"Spammy Spammer\" wrote a post {string}", (title) => { +Given('"Spammy Spammer" wrote a post {string}', title => { cy.factory() .authenticateAs({ - email: 'spammy-spammer@example.org', - password: '1234', + email: "spammy-spammer@example.org", + password: "1234" }) - .create("Post", { title }) -}) + .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') -}) + 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') -}) + 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) => { +Given("I wrote a post {string}", title => { cy.factory() .authenticateAs(loginCredentials) - .create("Post", { title }) -}) + .create("Post", { title }); +}); -When("I block the user {string}", (name) => { +When("I block the user {string}", name => { cy.neode() - .first('User', { name }).then((blocked) => { + .first("User", { name }) + .then(blocked => { cy.neode() - .first('User', {name: narratorParams.name}) - .relateTo(blocked, 'blocked') - }) -}) + .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}) -}) +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) -}) +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/deployment/human-connection/deployment-neo4j.yaml b/deployment/human-connection/deployment-neo4j.yaml index afc03ca0d..2fcba9061 100644 --- a/deployment/human-connection/deployment-neo4j.yaml +++ b/deployment/human-connection/deployment-neo4j.yaml @@ -32,6 +32,8 @@ value: 1G - name: NEO4J_dbms_memory_heap_max__size value: 1G + - name: NEO4J_dbms_security_procedures_unrestricted + value: "algo.*,apoc.*" envFrom: - configMapRef: name: configmap diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index c53d0a231..6d3b05eff 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -10,7 +10,7 @@
- + diff --git a/webapp/components/CommentForm/CommentForm.spec.js b/webapp/components/CommentForm/CommentForm.spec.js index 5e7c952af..07069e2d5 100644 --- a/webapp/components/CommentForm/CommentForm.spec.js +++ b/webapp/components/CommentForm/CommentForm.spec.js @@ -24,9 +24,15 @@ describe('CommentForm.vue', () => { mutate: jest .fn() .mockResolvedValueOnce({ - data: { CreateComment: { contentExcerpt: 'this is a comment' } }, + data: { + CreateComment: { + contentExcerpt: 'this is a comment', + }, + }, }) - .mockRejectedValue({ message: 'Ouch!' }), + .mockRejectedValue({ + message: 'Ouch!', + }), }, $toast: { error: jest.fn(), @@ -34,7 +40,9 @@ describe('CommentForm.vue', () => { }, } propsData = { - post: { id: 1 }, + post: { + id: 1, + }, } }) @@ -49,7 +57,12 @@ describe('CommentForm.vue', () => { getters, }) const Wrapper = () => { - return mount(CommentForm, { mocks, localVue, propsData, store }) + return mount(CommentForm, { + mocks, + localVue, + propsData, + store, + }) } beforeEach(() => { diff --git a/webapp/components/CommentForm/CommentForm.vue b/webapp/components/CommentForm/CommentForm.vue index 795727578..823b47fa6 100644 --- a/webapp/components/CommentForm/CommentForm.vue +++ b/webapp/components/CommentForm/CommentForm.vue @@ -2,7 +2,13 @@