diff --git a/backend/package.json b/backend/package.json index 89fb87edf..80a611155 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,7 +61,7 @@ "dotenv": "~8.1.0", "express": "^4.17.1", "faker": "Marak/faker.js#master", - "graphql": "^14.5.0", + "graphql": "^14.5.3", "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.5", @@ -116,7 +116,7 @@ "babel-jest": "~24.9.0", "chai": "~4.2.0", "cucumber": "~5.1.0", - "eslint": "~6.2.1", + "eslint": "~6.2.2", "eslint-config-prettier": "~6.1.0", "eslint-config-standard": "~14.0.1", "eslint-plugin-import": "~2.18.2", diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js deleted file mode 100644 index 403b2044e..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ /dev/null @@ -1,96 +0,0 @@ -import extractMentionedUsers from './notifications/extractMentionedUsers' -import extractHashtags from './hashtags/extractHashtags' - -const notifyMentions = async (label, id, idsOfMentionedUsers, context) => { - if (!idsOfMentionedUsers.length) return - - const session = context.driver.session() - const createdAt = new Date().toISOString() - 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, - 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) - DELETE previousRelations - RETURN p, t - ` - const cypherCreateNewTagsAndRelations = ` - MATCH (p: Post { id: $postId}) - UNWIND $hashtags AS tagName - MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) - MERGE (p)-[:TAGGED]->(t) - RETURN p, t - ` - await session.run(cypherDeletePreviousRelations, { - postId, - }) - await session.run(cypherCreateNewTagsAndRelations, { - postId, - hashtags, - }) - session.close() -} - -const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { - const idsOfMentionedUsers = extractMentionedUsers(args.content) - const hashtags = extractHashtags(args.content) - - const post = await resolve(root, args, context, resolveInfo) - - 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: 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 deleted file mode 100644 index 2925f92cf..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ /dev/null @@ -1,392 +0,0 @@ -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' - -let server -let query -let mutate -let user -let authenticatedUser -const factory = Factory() -const driver = getDriver() -const instance = neode() -const categoryIds = ['cat9'] -const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { - id - title - content - } - } -` -const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]!) { - UpdatePost(id: $id, content: $content, title: $title, categoryIds: $categoryIds) { - title - content - } - } -` - -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 () => { - user = await instance.create('User', { - id: 'you', - name: 'Al Capone', - slug: 'al-capone', - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('notifications', () => { - const notificationQuery = gql` - query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - comment { - content - } - } - } - } - ` - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = user - }) - - describe('given another user', () => { - let postAuthor - beforeEach(async () => { - postAuthor = await instance.create('User', { - email: 'post-author@example.org', - password: '1234', - id: 'postAuthor', - }) - }) - - describe('who mentions me in a post', () => { - const title = 'Mentioning Al Capone' - const content = - 'Hey @al-capone how do you do?' - - const createPostAction = async () => { - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: createPostMutation, - variables: { id: 'p47', title, content, categoryIds }, - }) - authenticatedUser = await user.toJson() - } - - it('sends you a notification', async () => { - await createPostAction() - const expectedContent = - 'Hey @al-capone how do you do?' - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - - describe('who mentions me many times', () => { - const updatePostAction = async () => { - const updatedContent = ` - One more mention to - - @al-capone - - and again: - - @al-capone - - and again - - @al-capone - - ` - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: updatePostMutation, - variables: { - id: 'p47', - title, - content: updatedContent, - categoryIds, - }, - }) - 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 = expect.objectContaining({ - data: { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - }) - - describe('but the author of the post blocked me', () => { - beforeEach(async () => { - await postAuthor.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) - }) - }) - - 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) - }) - }) - }) - }) - }) -}) - -describe('Hashtags', () => { - const id = 'p135' - const title = 'Two Hashtags' - const content = - '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - const postWithHastagsQuery = gql` - query($id: ID) { - Post(id: $id) { - tags { - id - } - } - } - ` - const postWithHastagsVariables = { - id, - } - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = await user.toJson() - }) - - describe('create a Post with Hashtags', () => { - beforeEach(async () => { - await mutate({ - mutation: createPostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - }) - - 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, - }), - ).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', () => { - // The already existing Hashtag has no class at this point. - const content = - '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - - it('only one previous Hashtag and the new Hashtag exists', async () => { - await mutate({ - mutation: updatePostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - - const expected = [{ id: 'Elections' }, { id: 'Liberty' }] - await expect( - query({ - query: postWithHastagsQuery, - variables: postWithHastagsVariables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - Post: [ - { - tags: expect.arrayContaining(expected), - }, - ], - }, - }), - ) - }) - }) - }) - }) -}) diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js b/backend/src/middleware/hashtags/extractHashtags.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js rename to backend/src/middleware/hashtags/extractHashtags.js diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js b/backend/src/middleware/hashtags/extractHashtags.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js rename to backend/src/middleware/hashtags/extractHashtags.spec.js diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.js b/backend/src/middleware/hashtags/hashtagsMiddleware.js new file mode 100644 index 000000000..c9156398d --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.js @@ -0,0 +1,49 @@ +import extractHashtags from '../hashtags/extractHashtags' + +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) + DELETE previousRelations + RETURN p, t + ` + const cypherCreateNewTagsAndRelations = ` + MATCH (p: Post { id: $postId}) + UNWIND $hashtags AS tagName + MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) + MERGE (p)-[:TAGGED]->(t) + RETURN p, t + ` + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { + postId, + hashtags, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const hashtags = extractHashtags(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await updateHashtagsOfPost(post.id, hashtags, context) + } + + return post +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + }, +} diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js new file mode 100644 index 000000000..3f101f778 --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js @@ -0,0 +1,176 @@ +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' + +let server +let query +let mutate +let hashtagingUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` + +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 () => { + hashtagingUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('hashtags', () => { + const id = 'p135' + const title = 'Two Hashtags' + const postContent = + '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postWithHastagsQuery = gql` + query($id: ID) { + Post(id: $id) { + tags { + id + } + } + } + ` + const postWithHastagsVariables = { + id, + } + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await hashtagingUser.toJson() + }) + + describe('create a Post with Hashtags', () => { + beforeEach(async () => { + await mutate({ + mutation: createPostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + }) + + 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, + }), + ).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', () => { + // The already existing Hashtag has no class at this point. + const postContent = + '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + + it('only one previous Hashtag and the new Hashtag exists', async () => { + await mutate({ + mutation: updatePostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + + const expected = [ + { + id: 'Elections', + }, + { + id: 'Liberty', + }, + ] + await expect( + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 57bdabfc9..7774ccc15 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -12,7 +12,8 @@ import user from './userMiddleware' import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation/validationMiddleware' -import handleContentData from './handleHtmlContent/handleContentData' +import notifications from './notifications/notificationsMiddleware' +import hashtags from './hashtags/hashtagsMiddleware' import email from './email/emailMiddleware' import sentry from './sentryMiddleware' @@ -25,13 +26,16 @@ export default schema => { validation, sluggify, excerpt, - handleContentData, + notifications, + hashtags, xss, softDelete, user, includedFields, orderBy, - email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }), + email: email({ + isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT, + }), } let order = [ @@ -43,7 +47,8 @@ export default schema => { 'sluggify', 'excerpt', 'email', - 'handleContentData', + 'notifications', + 'hashtags', 'xss', 'softDelete', 'user', diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.js diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js new file mode 100644 index 000000000..c9dfe406c --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -0,0 +1,122 @@ +import extractMentionedUsers from './mentions/extractMentionedUsers' + +const notifyUsers = async (label, id, idsOfUsers, reason, context) => { + if (!idsOfUsers.length) return + + // Checked here, because it does not go through GraphQL checks at all in this file. + const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'] + if (!reasonsAllowed.includes(reason)) { + throw new Error('Notification reason is not allowed!') + } + if ( + (label === 'Post' && reason !== 'mentioned_in_post') || + (label === 'Comment' && !['mentioned_in_comment', 'comment_on_post'].includes(reason)) + ) { + throw new Error('Notification does not fit the reason!') + } + + const session = context.driver.session() + const createdAt = new Date().toISOString() + let cypher + switch (reason) { + case 'mentioned_in_post': { + cypher = ` + MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfUsers + AND NOT (user)<-[:BLOCKED]-(author) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + case 'mentioned_in_comment': { + cypher = ` + MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfUsers + AND NOT (user)<-[:BLOCKED]-(author) + AND NOT (user)<-[:BLOCKED]-(postAuthor) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + case 'comment_on_post': { + cypher = ` + MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfUsers + AND NOT (user)<-[:BLOCKED]-(author) + AND NOT (author)<-[:BLOCKED]-(user) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + } + await session.run(cypher, { + label, + id, + idsOfUsers, + reason, + createdAt, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context) + } + + return post +} + +const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + const comment = await resolve(root, args, context, resolveInfo) + + if (comment) { + await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context) + } + + return comment +} + +const handleCreateComment = async (resolve, root, args, context, resolveInfo) => { + const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo) + + if (comment) { + const session = context.driver.session() + const cypherFindUser = ` + MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) + RETURN user { .id } + ` + const result = await session.run(cypherFindUser, { + commentId: comment.id, + }) + session.close() + const [postAuthor] = await result.records.map(record => { + return record.get('user') + }) + if (context.user.id !== postAuthor.id) { + await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context) + } + } + + return comment +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + CreateComment: handleCreateComment, + UpdateComment: handleContentDataOfComment, + }, +} diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js new file mode 100644 index 000000000..624cedddc --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -0,0 +1,463 @@ +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' + +let server +let query +let mutate +let notifiedUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` +const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $commentContent: String!) { + CreateComment(id: $id, postId: $postId, content: $commentContent) { + id + content + } + } +` + +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 () => { + notifiedUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('notifications', () => { + const notificationQuery = gql` + query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + reason + post { + content + } + comment { + content + } + } + } + } + ` + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + }) + + describe('given another user', () => { + let title + let postContent + let postAuthor + const createPostAction = async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'p47', + title, + postContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + let commentContent + let commentAuthor + const createCommentOnPostAction = async () => { + await createPostAction() + authenticatedUser = await commentAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'c47', + postId: 'p47', + commentContent, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + describe('comments on my post', () => { + beforeEach(async () => { + title = 'My post' + postContent = 'My post content.' + postAuthor = notifiedUser + }) + + describe('commenter is not me', () => { + beforeEach(async () => { + commentContent = 'Commenters comment.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'commentauthor@example.org', + password: '1234', + }) + }) + + it('sends me a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'comment_on_post', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + it('sends me no notification if I have blocked the comment author', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('commenter is me', () => { + beforeEach(async () => { + commentContent = 'My comment.' + commentAuthor = notifiedUser + }) + + it('sends me no notification', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + + beforeEach(async () => { + postAuthor = await instance.create('User', { + id: 'postAuthor', + name: 'Mrs Post', + slug: 'mrs-post', + email: 'post-author@example.org', + password: '1234', + }) + }) + + describe('mentions me in a post', () => { + beforeEach(async () => { + title = 'Mentioning Al Capone' + postContent = + 'Hey @al-capone how do you do?' + }) + + it('sends me a notification', async () => { + await createPostAction() + const expectedContent = + 'Hey @al-capone how do you do?' + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + describe('many times', () => { + const updatePostAction = async () => { + const updatedContent = ` + One more mention to + + @al-capone + + and again: + + @al-capone + + and again + + @al-capone + + ` + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: updatePostMutation, + variables: { + id: 'p47', + title, + postContent: updatedContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.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 = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, + }, + }) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, '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) + }) + }) + }) + + describe('mentions me in a comment', () => { + beforeEach(async () => { + title = 'Post where I get mentioned in a comment' + postContent = 'Content of post where I get mentioned in a comment.' + }) + + describe('I am not blocked at all', () => { + beforeEach(async () => { + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@example.org', + password: '1234', + }) + }) + + it('sends a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_comment', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, 'blocked') + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@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) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js index b8690b8c1..b54a99574 100644 --- a/backend/src/models/Notification.js +++ b/backend/src/models/Notification.js @@ -1,9 +1,26 @@ 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 }, + id: { + type: 'uuid', + primary: true, + default: uuid, + }, + read: { + type: 'boolean', + default: false, + }, + reason: { + type: 'string', + valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'], + invalid: [null], + default: 'mentioned_in_post', + }, + createdAt: { + type: 'string', + isoDate: true, + default: () => new Date().toISOString(), + }, user: { type: 'relationship', relationship: 'NOTIFIED', diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 6b173514f..3ca7727e4 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -69,23 +69,29 @@ describe('currentUser notifications', () => { factory.create('User', neighborParams), factory.create('Notification', { id: 'post-mention-not-for-you', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'post-mention-already-seen', read: true, + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'post-mention-unseen', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'comment-mention-not-for-you', + reason: 'mentioned_in_comment', }), factory.create('Notification', { id: 'comment-mention-already-seen', read: true, + reason: 'mentioned_in_comment', }), factory.create('Notification', { id: 'comment-mention-unseen', + reason: 'mentioned_in_comment', }), ]) await factory.authenticateAs(neighborParams) @@ -287,9 +293,11 @@ describe('UpdateNotification', () => { factory.create('User', mentionedParams), factory.create('Notification', { id: 'post-mention-to-be-updated', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'comment-mention-to-be-updated', + reason: 'mentioned_in_comment', }), ]) await factory.authenticateAs(userParams) diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql new file mode 100644 index 000000000..a66c446be --- /dev/null +++ b/backend/src/schema/types/enum/ReasonNotification.gql @@ -0,0 +1,5 @@ +enum ReasonNotification { + mentioned_in_post + mentioned_in_comment + comment_on_post +} \ No newline at end of file diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql index 0f94c2301..a3543445f 100644 --- a/backend/src/schema/types/type/Notification.gql +++ b/backend/src/schema/types/type/Notification.gql @@ -1,8 +1,9 @@ type Notification { id: ID! read: Boolean + reason: ReasonNotification + createdAt: String 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/factories/index.js b/backend/src/seed/factories/index.js index df3886a6c..56518bd06 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -62,15 +62,26 @@ export default function Factory(options = {}) { lastResponse: null, neodeInstance, async authenticateAs({ email, password }) { - const headers = await authenticatedHeaders({ email, password }, seedServerHost) + const headers = await authenticatedHeaders( + { + email, + password, + }, + seedServerHost, + ) this.lastResponse = headers - this.graphQLClient = new GraphQLClient(seedServerHost, { headers }) + this.graphQLClient = new GraphQLClient(seedServerHost, { + headers, + }) return this }, async create(node, args = {}) { const { factory, mutation, variables } = this.factories[node](args) if (factory) { - this.lastResponse = await factory({ args, neodeInstance }) + this.lastResponse = await factory({ + args, + neodeInstance, + }) return this.lastResponse } else { this.lastResponse = await this.graphQLClient.request(mutation, variables) @@ -121,11 +132,15 @@ export default function Factory(options = {}) { }, async invite({ email }) { const mutation = ` mutation($email: String!) { invite( email: $email) } ` - this.lastResponse = await this.graphQLClient.request(mutation, { email }) + this.lastResponse = await this.graphQLClient.request(mutation, { + email, + }) return this }, async cleanDatabase() { - this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) + this.lastResponse = await cleanDatabase({ + driver: this.neo4jDriver, + }) return this }, async emote({ to, data }) { diff --git a/backend/yarn.lock b/backend/yarn.lock index 2f6f02d8e..6abdfaf0c 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1407,7 +1407,7 @@ acorn-globals@^4.1.0: acorn "^6.0.1" acorn-walk "^6.0.1" -acorn-jsx@^5.0.0: +acorn-jsx@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.2.tgz#84b68ea44b373c4f8686023a551f61a21b7c4a4f" integrity sha512-tiNTrP1MP0QrChmD2DdupCr6HWSFeKVw5d/dHTu4Y7rkAkRhU/Dt7dphAfIUyxtHpl/eBVip5uTNSpQJHylpAw== @@ -3395,10 +3395,10 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== -eslint@~6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.1.tgz#66c2e4fe8b6356b9f01e828adc3ad04030122df1" - integrity sha512-ES7BzEzr0Q6m5TK9i+/iTpKjclXitOdDK4vT07OqbkBT2/VcN/gO9EL1C4HlK3TAOXYv2ItcmbVR9jO1MR0fJg== +eslint@~6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.2.tgz#03298280e7750d81fcd31431f3d333e43d93f24f" + integrity sha512-mf0elOkxHbdyGX1IJEUsNBzCDdyoUgljF3rRlgfyYh0pwGnreLc0jjD6ZuleOibjmnUWZLY2eXwSooeOgGJ2jw== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" @@ -3409,7 +3409,7 @@ eslint@~6.2.1: eslint-scope "^5.0.0" eslint-utils "^1.4.2" eslint-visitor-keys "^1.1.0" - espree "^6.1.0" + espree "^6.1.1" esquery "^1.0.1" esutils "^2.0.2" file-entry-cache "^5.0.1" @@ -3438,13 +3438,13 @@ eslint@~6.2.1: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.0.tgz#a1e8aa65bf29a331d70351ed814a80e7534e0884" - integrity sha512-boA7CHRLlVWUSg3iL5Kmlt/xT3Q+sXnKoRYYzj1YeM10A76TEJBbotV5pKbnK42hEUIr121zTv+QLRM5LsCPXQ== +espree@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.1.tgz#7f80e5f7257fc47db450022d723e356daeb1e5de" + integrity sha512-EYbr8XZUhWbYCqQRW0duU5LxzL5bETN6AjKBGy1302qqzPaCH10QbRg3Wvco79Z8x9WbiE8HYB4e75xl6qUYvQ== dependencies: acorn "^7.0.0" - acorn-jsx "^5.0.0" + acorn-jsx "^5.0.2" eslint-visitor-keys "^1.1.0" esprima@^3.1.3: @@ -4193,10 +4193,10 @@ graphql-upload@^8.0.2: http-errors "^1.7.2" object-path "^0.11.4" -graphql@^14.2.1, graphql@^14.5.0: - version "14.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520" - integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw== +graphql@^14.2.1, graphql@^14.5.3: + version "14.5.3" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0" + integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg== dependencies: iterall "^1.2.2" diff --git a/cypress/README.md b/cypress/README.md index 92b1b8185..2dd662a66 100644 --- a/cypress/README.md +++ b/cypress/README.md @@ -1,6 +1,16 @@ # End-to-End Testing -## Configure cypress +## Setup with docker + +Are you running everything through docker? You're so lucky you don't have to +setup anything! + +Just: +``` +docker-compose up +``` + +## Setup without docker First, you have to tell cypress how to connect to your local neo4j database among other things. You can copy our template configuration and change the new @@ -11,16 +21,15 @@ Make sure you are at the root level of the project. Then: # in the top level folder Human-Connection/ $ cp cypress.env.template.json cypress.env.json ``` - -## Run Tests - -To run the tests, do this: +To start the services that are required for cypress testing, run this: ```bash # in the top level folder Human-Connection/ $ yarn cypress:setup ``` +## Run cypress + After verifying that there are no errors with the servers starting, open another tab in your terminal and run the following command: ```bash @@ -29,13 +38,12 @@ $ yarn cypress:run ![Console output after running cypress test](../.gitbook/assets/grafik%20%281%29.png) -After the test runs, you will also get some video footage of the test run which you can then analyse in more detail. -## Open Interactive Test Console +### Open Interactive Test Console If you are like me, you might want to see some visual output. The interactive cypress environment also helps at debugging your tests, you can even time travel between individual steps and see the exact state of the app. -To use this feature, you will still run the `yarn cypress:setup` above, but instead of `yarn cypress:run` open another tab in your terminal and run the following command: +To use this feature, instead of `yarn cypress:run` you would run the following command: ```bash $ yarn cypress:open diff --git a/deployment/digital-ocean/https/templates/ingress.template.yaml b/deployment/digital-ocean/https/templates/ingress.template.yaml index 9d0068e08..a1af35bc7 100644 --- a/deployment/digital-ocean/https/templates/ingress.template.yaml +++ b/deployment/digital-ocean/https/templates/ingress.template.yaml @@ -7,20 +7,21 @@ metadata: kubernetes.io/ingress.class: "nginx" certmanager.k8s.io/issuer: "letsencrypt-staging" certmanager.k8s.io/acme-challenge-type: http01 + nginx.ingress.kubernetes.io/proxy-body-size: 6m spec: tls: - - hosts: - # - nitro-mailserver.human-connection.org - - nitro-staging.human-connection.org - secretName: tls + - hosts: + # - nitro-mailserver.human-connection.org + - nitro-staging.human-connection.org + secretName: tls rules: - - host: nitro-staging.human-connection.org - http: - paths: - - path: / - backend: - serviceName: nitro-web - servicePort: 3000 + - host: nitro-staging.human-connection.org + http: + paths: + - path: / + backend: + serviceName: nitro-web + servicePort: 3000 # - host: nitro-mailserver.human-connection.org # http: # paths: diff --git a/deployment/human-connection/deployment-neo4j.yaml b/deployment/human-connection/deployment-neo4j.yaml index 2fcba9061..297f4b551 100644 --- a/deployment/human-connection/deployment-neo4j.yaml +++ b/deployment/human-connection/deployment-neo4j.yaml @@ -25,13 +25,20 @@ - name: nitro-neo4j image: humanconnection/neo4j:latest imagePullPolicy: Always + resources: + requests: + memory: "1G" + limits: + memory: "2G" env: - name: NEO4J_apoc_import_file_enabled value: "true" - name: NEO4J_dbms_memory_pagecache_size - value: 1G + value: "490M" - name: NEO4J_dbms_memory_heap_max__size - value: 1G + value: "500M" + - name: NEO4J_dbms_memory_heap_initial__size + value: "500M" - name: NEO4J_dbms_security_procedures_unrestricted value: "algo.*,apoc.*" envFrom: diff --git a/deployment/legacy-migration/maintenance-worker/migration/mongo/.env b/deployment/legacy-migration/maintenance-worker/migration/mongo/.env index 4c5f9e18c..13c36c143 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/mongo/.env +++ b/deployment/legacy-migration/maintenance-worker/migration/mongo/.env @@ -12,6 +12,6 @@ # On Windows this resolves to C:\Users\dornhoeschen\AppData\Local\Temp\mongo-export (MinGW) EXPORT_PATH='/tmp/mongo-export/' EXPORT_MONGOEXPORT_BIN='mongoexport' -MONGO_EXPORT_SPLIT_SIZE=100 +MONGO_EXPORT_SPLIT_SIZE=4000 # On Windows use something like this # EXPORT_MONGOEXPORT_BIN='C:\Program Files\MongoDB\Server\3.6\bin\mongoexport.exe' diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 016984d3b..41a88970f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -16,6 +16,30 @@ services: - webapp_node_modules:/nitro-web/node_modules command: yarn run dev user: root + factories: + image: humanconnection/nitro-backend:builder + build: + context: backend + target: builder + ports: + - 4001:4001 + networks: + - hc-network + volumes: + - ./backend:/nitro-backend + - factories_node_modules:/nitro-backend/node_modules + - uploads:/nitro-backend/public/uploads + depends_on: + - neo4j + environment: + - NEO4J_URI=bolt://neo4j:7687 + - GRAPHQL_PORT=4000 + - GRAPHQL_URI=http://localhost:4000 + - CLIENT_URI=http://localhost:3000 + - JWT_SECRET=b/&&7b78BF&fv/Vd + - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ + - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 + command: yarn run test:before:seeder backend: image: humanconnection/nitro-backend:builder build: @@ -42,5 +66,6 @@ services: volumes: webapp_node_modules: backend_node_modules: + factories_node_modules: neo4j-data: uploads: diff --git a/package.json b/package.json index 30f122f08..31670214e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "codecov": "^3.5.0", "cross-env": "^5.2.0", "cypress": "^3.4.1", - "cypress-cucumber-preprocessor": "^1.15.0", + "cypress-cucumber-preprocessor": "^1.15.1", "cypress-file-upload": "^3.3.3", "cypress-plugin-retries": "^1.2.2", "dotenv": "^8.1.0", @@ -34,4 +34,4 @@ "npm-run-all": "^4.1.5", "slug": "^1.1.0" } -} \ No newline at end of file +} diff --git a/webapp/components/Comment.spec.js b/webapp/components/Comment.spec.js index 4fdc48bbd..b9be448e4 100644 --- a/webapp/components/Comment.spec.js +++ b/webapp/components/Comment.spec.js @@ -8,7 +8,7 @@ const localVue = createLocalVue() localVue.use(Vuex) localVue.use(Styleguide) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Comment.vue', () => { let propsData diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 6d3b05eff..74b3f893c 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -15,7 +15,7 @@ - + - +
@@ -62,12 +62,13 @@ + + diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/Editor.vue index 7791facb6..33fe6b5d4 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/Editor.vue @@ -1,203 +1,52 @@ diff --git a/webapp/components/Editor/LinkInput.vue b/webapp/components/Editor/LinkInput.vue new file mode 100644 index 000000000..dede19302 --- /dev/null +++ b/webapp/components/Editor/LinkInput.vue @@ -0,0 +1,33 @@ + + + diff --git a/webapp/components/Editor/MenuBar.vue b/webapp/components/Editor/MenuBar.vue new file mode 100644 index 000000000..4e43050e9 --- /dev/null +++ b/webapp/components/Editor/MenuBar.vue @@ -0,0 +1,74 @@ + + + diff --git a/webapp/components/Editor/MenuBarButton.vue b/webapp/components/Editor/MenuBarButton.vue new file mode 100644 index 000000000..49e480ca5 --- /dev/null +++ b/webapp/components/Editor/MenuBarButton.vue @@ -0,0 +1,16 @@ + + + diff --git a/webapp/components/Editor/SuggestionList.vue b/webapp/components/Editor/SuggestionList.vue new file mode 100644 index 000000000..b351e6b74 --- /dev/null +++ b/webapp/components/Editor/SuggestionList.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/webapp/components/PostCard/index.spec.js b/webapp/components/PostCard/index.spec.js index 390396383..36b8bccda 100644 --- a/webapp/components/PostCard/index.spec.js +++ b/webapp/components/PostCard/index.spec.js @@ -10,7 +10,7 @@ localVue.use(Vuex) localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['v-popover'] = '' describe('PostCard', () => { diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/index.vue index c0cc1a9a6..7c79fe9eb 100644 --- a/webapp/components/PostCard/index.vue +++ b/webapp/components/PostCard/index.vue @@ -13,9 +13,9 @@
- + - +
@@ -42,7 +42,7 @@ :icon="category.icon" />
- +
@@ -63,7 +63,7 @@ :is-owner="isAuthor" />
-
+ diff --git a/webapp/components/User/index.vue b/webapp/components/User/index.vue index 0fefe4eb3..684220f38 100644 --- a/webapp/components/User/index.vue +++ b/webapp/components/User/index.vue @@ -25,9 +25,9 @@
- + - +
diff --git a/webapp/components/notifications/Notification/Notification.spec.js b/webapp/components/notifications/Notification/Notification.spec.js index 8fbc524fb..279500f7f 100644 --- a/webapp/components/notifications/Notification/Notification.spec.js +++ b/webapp/components/notifications/Notification/Notification.spec.js @@ -8,16 +8,17 @@ const localVue = createLocalVue() localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Notification', () => { let stubs let mocks let propsData + let wrapper beforeEach(() => { propsData = {} mocks = { - $t: jest.fn(), + $t: key => key, } stubs = { NuxtLink: RouterLinkStub, @@ -33,37 +34,159 @@ describe('Notification', () => { }) } - describe('given a notification', () => { + describe('given a notification about a comment on a post', () => { beforeEach(() => { propsData.notification = { - post: { - title: "It's a title", - id: 'post-1', - slug: 'its-a-title', - contentExcerpt: '@jenny-rostock is the best', + reason: 'comment_on_post', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, }, } }) + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.comment_on_post', + ) + }) it('renders title', () => { - expect(Wrapper().text()).toContain("It's a title") + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') }) - it('renders the contentExcerpt', () => { - expect(Wrapper().text()).toContain('@jenny-rostock is the best') + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') }) - it('has no class "read"', () => { - expect(Wrapper().classes()).not.toContain('read') + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') }) describe('that is read', () => { beforeEach(() => { propsData.notification.read = true + wrapper = Wrapper() }) it('has class "read"', () => { - expect(Wrapper().classes()).toContain('read') + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a post', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_post', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: + '@jenny-rostock is the best on this post.', + }, + comment: null, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_post', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.') + }) + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a comment', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_comment', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, + }, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_comment', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') + }) + + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') + }) + + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') }) }) }) diff --git a/webapp/components/notifications/Notification/Notification.vue b/webapp/components/notifications/Notification/Notification.vue index 6aa4a5eeb..193b5f67b 100644 --- a/webapp/components/notifications/Notification/Notification.vue +++ b/webapp/components/notifications/Notification/Notification.vue @@ -1,6 +1,6 @@ diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 99689ec44..bb70ed36e 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -14,7 +14,7 @@ "all": "Alle" }, "general": { - "header": "Filtern nach..." + "header": "Filtern nach …" }, "followers": { "label": "Benutzern, denen ich folge" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Schreib etwas Inspirierendes...", + "placeholder": "Schreib etwas Inspirierendes …", "mention": { "noUsersFound": "Keine Benutzer gefunden" }, @@ -132,7 +132,9 @@ }, "notifications": { "menu": { - "mentioned": "hat dich in einem {resource} erwähnt" + "mentioned_in_post": "Hat dich in einem Beitrag erwähnt …", + "mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …", + "comment_on_post": "Hat deinen Beitrag kommentiert …" } }, "search": { @@ -304,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar" + "unavailable-placeholder": "… dieser Kommentar ist nicht mehr verfügbar" }, "menu": { "edit": "Kommentar bearbeiten", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 9c9d1cd73..94451fa88 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -14,7 +14,7 @@ "all": "All" }, "general": { - "header": "Filter by..." + "header": "Filter by …" }, "followers": { "label": "Users I follow" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Leave your inspirational thoughts...", + "placeholder": "Leave your inspirational thoughts …", "mention": { "noUsersFound": "No users found" }, @@ -132,7 +132,9 @@ }, "notifications": { "menu": { - "mentioned": "mentioned you in a {resource}" + "mentioned_in_post": "Mentioned you in a post …", + "mentioned_in_comment": "Mentioned you in a comment …", + "comment_on_post": "Commented on your post …" } }, "search": { @@ -304,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...this comment is not available anymore" + "unavailable-placeholder": "… this comment is not available anymore" }, "menu": { "edit": "Edit Comment", diff --git a/webapp/package.json b/webapp/package.json index 9ed991e62..6357e06e5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -61,7 +61,7 @@ "apollo-client": "~2.6.4", "cookie-universal-nuxt": "~2.0.17", "cross-env": "~5.2.0", - "date-fns": "2.0.0", + "date-fns": "2.0.1", "express": "~4.17.1", "graphql": "~14.5.3", "isemail": "^3.2.0", diff --git a/webapp/pages/admin/index.vue b/webapp/pages/admin/index.vue index ca8ce4df7..f764238e3 100644 --- a/webapp/pages/admin/index.vue +++ b/webapp/pages/admin/index.vue @@ -1,23 +1,23 @@ diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index 6dacd6069..3a97e3709 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -15,7 +15,7 @@ localVue.use(Filters) localVue.use(VTooltip) localVue.use(InfiniteScroll) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['router-link'] = '' config.stubs['nuxt-link'] = '' diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index b9db7aa37..afc566940 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -36,7 +36,7 @@ - + - +