From 4bfc0ff2bf92634b7aff60ac21a4c22c81894471 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 30 Aug 2019 17:36:29 +0200 Subject: [PATCH 01/31] Refactor comments.spec, refactor post factory --- backend/src/schema/resolvers/comments.spec.js | 243 +++++++----------- backend/src/seed/factories/posts.js | 80 +++--- 2 files changed, 129 insertions(+), 194 deletions(-) diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index 0b6d5f727..87518cc02 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -1,48 +1,38 @@ import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' import { host, login, gql } from '../../jest/helpers' -import { neode } from '../../bootstrap/neo4j' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../../server' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' + +const driver = getDriver() +const neode = getNeode() +const factory = Factory() let client -let createCommentVariables -let createCommentVariablesSansPostId -let createCommentVariablesWithNonExistentPost -let userParams let headers -const factory = Factory() -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 - } - } -` -const createCommentMutation = gql` - mutation($id: ID, $postId: ID!, $content: String!) { - CreateComment(id: $id, postId: $postId, content: $content) { - id - content - } - } -` -const createPostVariables = { - id: 'p1', - title: 'post to comment on', - content: 'please comment on me', - categoryIds, -} +let variables +let mutate +let authenticatedUser + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + user: authenticatedUser, + } + }, + }) + const client = createTestClient(server) + mutate = client.mutate +}) beforeEach(async () => { - userParams = { - name: 'TestUser', - email: 'test@example.org', - password: '1234', - } - await factory.create('User', userParams) - await instance.create('Category', { + variables = {} + await neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', @@ -53,144 +43,97 @@ afterEach(async () => { await factory.cleanDatabase() }) +const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + content + author { + name + } + } + } +` describe('CreateComment', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { - createCommentVariables = { + variables = { + ...variables, postId: 'p1', content: "I'm not authorised to comment", } - client = new GraphQLClient(host) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const result = await mutate({ mutation: createCommentMutation, variables }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { - headers, - }) - createCommentVariables = { - postId: 'p1', - content: "I'm authorised to comment", - } - await client.request(createPostMutation, createPostVariables) + const user = await neode.create('User', { name: 'Author' }) + authenticatedUser = await user.toJson() }) - it('creates a comment', async () => { - const expected = { - CreateComment: { + describe('given a post', () => { + beforeEach(async () => { + await factory.create('Post', { categoryIds, id: 'p1' }) + variables = { + ...variables, + postId: 'p1', content: "I'm authorised to comment", - }, - } - - await expect( - client.request(createCommentMutation, createCommentVariables), - ).resolves.toMatchObject(expected) - }) - - it('assigns the authenticated user as author', async () => { - await client.request(createCommentMutation, createCommentVariables) - - const { User } = await client.request(gql` - { - User(name: "TestUser") { - comments { - content - } - } } - `) + }) - expect(User).toEqual([ - { - comments: [ - { - content: "I'm authorised to comment", - }, - ], - }, - ]) - }) + it('creates a comment', async () => { + await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject( + { + data: { CreateComment: { content: "I'm authorised to comment" } }, + }, + ) + }) - it('throw an error if an empty string is sent from the editor as content', async () => { - createCommentVariables = { - postId: 'p1', - content: '

', - } + it('assigns the authenticated user as author', async () => { + await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject( + { + data: { CreateComment: { author: { name: 'Author' } } }, + }, + ) + }) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) + describe('comment content is empty', () => { + beforeEach(() => { + variables = { ...variables, content: '

' } + }) - it('throws an error if a comment sent from the editor does not contain a single character', async () => { - createCommentVariables = { - postId: 'p1', - content: '

', - } + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) + }) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) + describe('comment content contains only whitespaces', () => { + beforeEach(() => { + variables = { ...variables, content: '

' } + }) - it('throws an error if postId is sent as an empty string', async () => { - createCommentVariables = { - postId: 'p1', - content: '', - } + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) + }) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) + describe('invalid post id', () => { + beforeEach(() => { + variables = { ...variables, postId: 'does-not-exist' } + }) - it('throws an error if content is sent as an string of empty characters', async () => { - createCommentVariables = { - postId: 'p1', - content: ' ', - } - - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) - - it('throws an error if postId is sent as an empty string', async () => { - createCommentVariablesSansPostId = { - postId: '', - content: 'this comment should not be created', - } - - await expect( - client.request(createCommentMutation, createCommentVariablesSansPostId), - ).rejects.toThrow('Comment cannot be created without a post!') - }) - - it('throws an error if postId is sent as an string of empty characters', async () => { - createCommentVariablesSansPostId = { - postId: ' ', - content: 'this comment should not be created', - } - - await expect( - client.request(createCommentMutation, createCommentVariablesSansPostId), - ).rejects.toThrow('Comment cannot be created without a post!') - }) - - it('throws an error if the post does not exist in the database', async () => { - createCommentVariablesWithNonExistentPost = { - postId: 'p2', - content: "comment should not be created cause the post doesn't exist", - } - - await expect( - client.request(createCommentMutation, createCommentVariablesWithNonExistentPost), - ).rejects.toThrow('Comment cannot be created without a post!') + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment cannot be created without a post!') + }) + }) }) }) }) diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index f2f1432dc..82128359b 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -1,51 +1,43 @@ import faker from 'faker' +import slugify from 'slug' import uuid from 'uuid/v4' -export default function(params) { - const { - id = uuid(), - slug = '', - title = faker.lorem.sentence(), - content = [ - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - ].join('. '), - image = faker.image.unsplash.imageUrl(), - visibility = 'public', - deleted = false, - categoryIds, - } = params - +export default function create() { return { - mutation: ` - mutation( - $id: ID! - $slug: String - $title: String! - $content: String! - $image: String - $visibility: Visibility - $deleted: Boolean - $categoryIds: [ID] - ) { - CreatePost( - id: $id - slug: $slug - title: $title - content: $content - image: $image - visibility: $visibility - deleted: $deleted - categoryIds: $categoryIds - ) { - title - content - } + factory: async ({ args, neodeInstance }) => { + const defaults = { + id: uuid(), + slug: '', + title: faker.lorem.sentence(), + content: [ + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + ].join('. '), + image: faker.image.unsplash.imageUrl(), + visibility: 'public', + deleted: false, + categoryIds: [], } - `, - variables: { id, slug, title, content, image, visibility, deleted, categoryIds }, + defaults.slug = slugify(defaults.title, { lower: true }) + args = { + ...defaults, + ...args, + } + const { categoryIds } = args + if (!categoryIds.length) throw new Error('CategoryIds are empty!') + const categories = await Promise.all( + categoryIds.map(c => { + return neodeInstance.find('Category', c) + }), + ) + const author = args.author || (await neodeInstance.create('User', args)) + const post = await neodeInstance.create('Post', args) + await post.relateTo(author, 'author') + await Promise.all(categories.map(c => c.relateTo(post, 'post'))) + return post + }, } } From 0f64bbb71fee7c75f631989aa5e42e65c850228c Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 30 Aug 2019 18:15:18 +0200 Subject: [PATCH 02/31] Soft delete for comments implemented --- .../src/middleware/permissionsMiddleware.js | 6 +- backend/src/models/Comment.js | 34 +++ backend/src/models/index.js | 1 + backend/src/schema/resolvers/comments.js | 16 +- backend/src/schema/resolvers/comments.spec.js | 225 +++++++----------- backend/src/seed/factories/comments.js | 34 +-- 6 files changed, 158 insertions(+), 158 deletions(-) create mode 100644 backend/src/models/Comment.js diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 2a52e54af..061603b6b 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -91,13 +91,11 @@ const isAuthor = rule({ resourceId, }, ) + session.close() const [author] = result.records.map(record => { return record.get('author') }) - const { - properties: { id: authorId }, - } = author - session.close() + const authorId = author && author.properties && author.properties.id return authorId === user.id }) diff --git a/backend/src/models/Comment.js b/backend/src/models/Comment.js new file mode 100644 index 000000000..7b130cc79 --- /dev/null +++ b/backend/src/models/Comment.js @@ -0,0 +1,34 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + content: { type: 'string', disallow: [null], min: 3 }, + contentExcerpt: { type: 'string', allow: [null] }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + post: { + type: 'relationship', + relationship: 'COMMENTS', + target: 'Post', + direction: 'out', + }, + author: { + type: 'relationship', + relationship: 'WROTE', + target: 'User', + direction: 'in', + }, + disabledBy: { + type: 'relationship', + relationship: 'WROTE', + target: 'User', + direction: 'in', + }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 5a4510ac6..4f0b40cee 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -7,5 +7,6 @@ export default { EmailAddress: require('./EmailAddress.js'), SocialMedia: require('./SocialMedia.js'), Post: require('./Post.js'), + Comment: require('./Comment.js'), Category: require('./Category.js'), } diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index c8fb8db3f..dd55e8f44 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -47,9 +47,19 @@ export default { session.close() return commentReturnedWithAuthor }, - DeleteComment: async (object, params, context, resolveInfo) => { - const comment = await neo4jgraphql(object, params, context, resolveInfo, false) - + DeleteComment: async (object, args, context, resolveInfo) => { + const session = context.driver.session() + const transactionRes = await session.run( + ` + MATCH (comment:Comment {id: $commentId}) + SET comment.deleted = TRUE + SET comment.content = 'DELETED' + SET comment.contentExcerpt = 'DELETED' + RETURN comment + `, + { commentId: args.id }, + ) + const [comment] = transactionRes.records.map(record => record.get('comment').properties) return comment }, }, diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index 87518cc02..b3cf1fda2 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -1,6 +1,5 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' +import { gql } from '../../jest/helpers' import { createTestClient } from 'apollo-server-testing' import createServer from '../../server' import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' @@ -9,13 +8,10 @@ const driver = getDriver() const neode = getNeode() const factory = Factory() -let client -let headers -const categoryIds = ['cat9'] - let variables let mutate let authenticatedUser +let commentAuthor beforeAll(() => { const { server } = createServer({ @@ -54,6 +50,26 @@ const createCommentMutation = gql` } } ` +const setupPostAndComment = async () => { + commentAuthor = await factory.create('User') + await factory.create('Post', { + id: 'p1', + content: 'Post to be commented', + categoryIds: ['cat9'], + }) + await factory.create('Comment', { + id: 'c456', + postId: 'p1', + author: commentAuthor, + content: 'Comment to be deleted', + }) + variables = { + ...variables, + id: 'c456', + content: 'The comment is updated', + } +} + describe('CreateComment', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { @@ -62,8 +78,8 @@ describe('CreateComment', () => { postId: 'p1', content: "I'm not authorised to comment", } - const result = await mutate({ mutation: createCommentMutation, variables }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + const { errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) @@ -75,7 +91,7 @@ describe('CreateComment', () => { describe('given a post', () => { beforeEach(async () => { - await factory.create('Post', { categoryIds, id: 'p1' }) + await factory.create('Post', { categoryIds: ['cat9'], id: 'p1' }) variables = { ...variables, postId: 'p1', @@ -138,193 +154,128 @@ describe('CreateComment', () => { }) }) -describe('ManageComments', () => { - let authorParams - beforeEach(async () => { - authorParams = { - email: 'author@example.org', - password: '1234', - } - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { - id: 'p1', - content: 'Post to be commented', - categoryIds, - }) - await asAuthor.create('Comment', { - id: 'c456', - postId: 'p1', - content: 'Comment to be deleted', - }) - }) - - describe('UpdateComment', () => { - const updateCommentMutation = gql` - mutation($content: String!, $id: ID!) { - UpdateComment(content: $content, id: $id) { - id - content - } +describe('UpdateComment', () => { + const updateCommentMutation = gql` + mutation($content: String!, $id: ID!) { + UpdateComment(content: $content, id: $id) { + id + content } - ` - - let updateCommentVariables = { - id: 'c456', - content: 'The comment is updated', } + ` + + describe('given a post and a comment', () => { + beforeEach(setupPostAndComment) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + const randomGuy = await factory.create('User') + authenticatedUser = await randomGuy.toJson() }) it('throws authorization error', async () => { - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = await commentAuthor.toJson() }) it('updates the comment', async () => { const expected = { - UpdateComment: { - id: 'c456', - content: 'The comment is updated', - }, + data: { UpdateComment: { id: 'c456', content: 'The comment is updated' } }, } - await expect( - client.request(updateCommentMutation, updateCommentVariables), - ).resolves.toEqual(expected) - }) - - it('throw an error if an empty string is sent from the editor as content', async () => { - updateCommentVariables = { - id: 'c456', - content: '

', - } - - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', + await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject( + expected, ) }) - it('throws an error if a comment sent from the editor does not contain a single letter character', async () => { - updateCommentVariables = { - id: 'c456', - content: '

', - } + describe('if `content` empty', () => { + beforeEach(() => { + variables = { ...variables, content: '

' } + }) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) + it('throws InputError', async () => { + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) }) - it('throws an error if commentId is sent as an empty string', async () => { - updateCommentVariables = { - id: '', - content: '

Hello

', - } + describe('if comment does not exist for given id', () => { + beforeEach(() => { + variables = { ...variables, id: 'does-not-exist' } + }) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised!', - ) - }) - - it('throws an error if the comment does not exist in the database', async () => { - updateCommentVariables = { - id: 'c1000', - content: '

Hello

', - } - - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised!', - ) + it('returns null', async () => { + const { data, errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(data).toMatchObject({ UpdateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) }) }) }) +}) - describe('DeleteComment', () => { - const deleteCommentMutation = gql` - mutation($id: ID!) { - DeleteComment(id: $id) { - id - } +describe('DeleteComment', () => { + const deleteCommentMutation = gql` + mutation($id: ID!) { + DeleteComment(id: $id) { + id + content + contentExcerpt + deleted } - ` - - const deleteCommentVariables = { - id: 'c456', } + ` + + describe('given a post and a comment', () => { + beforeEach(setupPostAndComment) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const result = await mutate({ mutation: deleteCommentMutation, variables }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + const randomGuy = await factory.create('User') + authenticatedUser = await randomGuy.toJson() }) it('throws authorization error', async () => { - await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: deleteCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = await commentAuthor.toJson() }) - it('deletes the comment', async () => { + it('marks the comment as deleted and blacks out content', async () => { + const { data } = await mutate({ mutation: deleteCommentMutation, variables }) const expected = { DeleteComment: { id: 'c456', + deleted: true, + content: 'DELETED', + contentExcerpt: 'DELETED', }, } - await expect( - client.request(deleteCommentMutation, deleteCommentVariables), - ).resolves.toEqual(expected) + expect(data).toMatchObject(expected) }) }) }) diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index 20933e947..87f46f358 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -1,21 +1,27 @@ import faker from 'faker' import uuid from 'uuid/v4' -export default function(params) { - const { - id = uuid(), - postId = 'p6', - content = [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), - } = params - +export default function create() { return { - mutation: ` - mutation($id: ID!, $postId: ID!, $content: String!) { - CreateComment(id: $id, postId: $postId, content: $content) { - id - } + factory: async ({ args, neodeInstance }) => { + const defaults = { + id: uuid(), + content: [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), } - `, - variables: { id, postId, content }, + args = { + ...defaults, + ...args, + } + const { postId } = args + if (!postId) throw new Error('PostId is missing!') + const post = await neodeInstance.find('Post', postId) + delete args.postId + const author = args.author || (await neodeInstance.create('User', args)) + delete args.author + const comment = await neodeInstance.create('Comment', args) + await comment.relateTo(post, 'post') + await comment.relateTo(author, 'author') + return comment + }, } } From 2d67c5f172b0ea6f0ce2d5c1c49b4047bca24a76 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 31 Aug 2019 00:22:10 +0200 Subject: [PATCH 03/31] Refactored entire softDeleteMiddleware spec --- .../middleware/softDeleteMiddleware.spec.js | 291 ++++++++++++------ backend/src/seed/factories/posts.js | 1 + 2 files changed, 195 insertions(+), 97 deletions(-) diff --git a/backend/src/middleware/softDeleteMiddleware.spec.js b/backend/src/middleware/softDeleteMiddleware.spec.js index 03c97c020..673c3026f 100644 --- a/backend/src/middleware/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDeleteMiddleware.spec.js @@ -1,24 +1,30 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../seed/factories' -import { host, login } from '../jest/helpers' -import { neode } from '../bootstrap/neo4j' +import { gql } from '../jest/helpers' +import { neode as getNeode, getDriver } from '../bootstrap/neo4j' +import createServer from '../server' +import { createTestClient } from 'apollo-server-testing' const factory = Factory() -const instance = neode() +const neode = getNeode() +const driver = getDriver() -let client let query +let mutate +let graphqlQuery let action const categoryIds = ['cat9'] +let authenticatedUser +let user +let moderator +let troll beforeAll(async () => { // For performance reasons we do this only once - await Promise.all([ - factory.create('User', { id: 'u1', role: 'user', email: 'user@example.org', password: '1234' }), + const users = await Promise.all([ + factory.create('User', { id: 'u1', role: 'user' }), factory.create('User', { id: 'm1', role: 'moderator', - email: 'moderator@example.org', password: '1234', }), factory.create('User', { @@ -27,21 +33,30 @@ beforeAll(async () => { name: 'Offensive Name', avatar: '/some/offensive/avatar.jpg', about: 'This self description is very offensive', - email: 'troll@example.org', - password: '1234', - }), - instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', }), ]) - await factory.authenticateAs({ email: 'user@example.org', password: '1234' }) + user = users[0] + moderator = users[1] + troll = users[2] + + await neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) + await Promise.all([ - factory.follow({ id: 'u2', type: 'User' }), - factory.create('Post', { id: 'p1', title: 'Deleted post', deleted: true, categoryIds }), + user.relateTo(troll, 'following'), factory.create('Post', { + author: user, + id: 'p1', + title: 'Deleted post', + deleted: true, + categoryIds, + }), + factory.create('Post', { + author: user, id: 'p3', title: 'Publicly visible post', deleted: false, @@ -51,32 +66,56 @@ beforeAll(async () => { await Promise.all([ factory.create('Comment', { + author: user, id: 'c2', postId: 'p3', content: 'Enabled comment on public post', }), ]) - await Promise.all([factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' })]) - - const asTroll = Factory() - await asTroll.authenticateAs({ email: 'troll@example.org', password: '1234' }) - await asTroll.create('Post', { + await factory.create('Post', { id: 'p2', + author: troll, title: 'Disabled post', content: 'This is an offensive post content', + contentExcerpt: 'This is an offensive post content', image: '/some/offensive/image.jpg', deleted: false, categoryIds, }) - await asTroll.create('Comment', { id: 'c1', postId: 'p3', content: 'Disabled comment' }) - await Promise.all([asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' })]) + await factory.create('Comment', { + id: 'c1', + author: troll, + postId: 'p3', + content: 'Disabled comment', + contentExcerpt: 'Disabled comment', + }) - const asModerator = Factory() - await asModerator.authenticateAs({ email: 'moderator@example.org', password: '1234' }) - await asModerator.mutate('mutation { disable( id: "p2") }') - await asModerator.mutate('mutation { disable( id: "c1") }') - await asModerator.mutate('mutation { disable( id: "u2") }') + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + const client = createTestClient(server) + query = client.query + mutate = client.mutate + + authenticatedUser = await moderator.toJson() + const disableMutation = gql` + mutation($id: ID!) { + disable(id: $id) + } + ` + await Promise.all([ + mutate({ mutation: disableMutation, variables: { id: 'c1' } }), + mutate({ mutation: disableMutation, variables: { id: 'u2' } }), + mutate({ mutation: disableMutation, variables: { id: 'p2' } }), + ]) + authenticatedUser = null }) afterAll(async () => { @@ -85,93 +124,122 @@ afterAll(async () => { describe('softDeleteMiddleware', () => { describe('read disabled content', () => { - let user - let post - let comment + let subject const beforeComment = async () => { - query = '{ User(id: "u1") { following { comments { content contentExcerpt } } } }' - const response = await action() - comment = response.User[0].following[0].comments[0] + graphqlQuery = gql` + { + User(id: "u1") { + following { + comments { + content + contentExcerpt + } + } + } + } + ` + const { data } = await action() + subject = data.User[0].following[0].comments[0] } const beforeUser = async () => { - query = '{ User(id: "u1") { following { name about avatar } } }' - const response = await action() - user = response.User[0].following[0] + graphqlQuery = gql` + { + User(id: "u1") { + following { + name + about + avatar + } + } + } + ` + const { data } = await action() + subject = data.User[0].following[0] } const beforePost = async () => { - query = - '{ User(id: "u1") { following { contributions { title image content contentExcerpt } } } }' - const response = await action() - post = response.User[0].following[0].contributions[0] + graphqlQuery = gql` + { + User(id: "u1") { + following { + contributions { + title + image + content + contentExcerpt + } + } + } + } + ` + const { data } = await action() + subject = data.User[0].following[0].contributions[0] } action = () => { - return client.request(query) + return query({ query: graphqlQuery }) } describe('as moderator', () => { beforeEach(async () => { - const headers = await login({ email: 'moderator@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await moderator.toJson() }) describe('User', () => { beforeEach(beforeUser) - it('displays name', () => expect(user.name).toEqual('Offensive Name')) + it('displays name', () => expect(subject.name).toEqual('Offensive Name')) it('displays about', () => - expect(user.about).toEqual('This self description is very offensive')) - it('displays avatar', () => expect(user.avatar).toEqual('/some/offensive/avatar.jpg')) + expect(subject.about).toEqual('This self description is very offensive')) + it('displays avatar', () => expect(subject.avatar).toEqual('/some/offensive/avatar.jpg')) }) describe('Post', () => { beforeEach(beforePost) - it('displays title', () => expect(post.title).toEqual('Disabled post')) + it('displays title', () => expect(subject.title).toEqual('Disabled post')) it('displays content', () => - expect(post.content).toEqual('This is an offensive post content')) + expect(subject.content).toEqual('This is an offensive post content')) it('displays contentExcerpt', () => - expect(post.contentExcerpt).toEqual('This is an offensive post content')) - it('displays image', () => expect(post.image).toEqual('/some/offensive/image.jpg')) + expect(subject.contentExcerpt).toEqual('This is an offensive post content')) + it('displays image', () => expect(subject.image).toEqual('/some/offensive/image.jpg')) }) describe('Comment', () => { beforeEach(beforeComment) - it('displays content', () => expect(comment.content).toEqual('Disabled comment')) + it('displays content', () => expect(subject.content).toEqual('Disabled comment')) it('displays contentExcerpt', () => - expect(comment.contentExcerpt).toEqual('Disabled comment')) + expect(subject.contentExcerpt).toEqual('Disabled comment')) }) }) describe('as user', () => { beforeEach(async () => { - const headers = await login({ email: 'user@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) describe('User', () => { beforeEach(beforeUser) - it('displays name', () => expect(user.name).toEqual('UNAVAILABLE')) - it('obfuscates about', () => expect(user.about).toEqual('UNAVAILABLE')) - it('obfuscates avatar', () => expect(user.avatar).toEqual('UNAVAILABLE')) + it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE')) + it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE')) + it('obfuscates avatar', () => expect(subject.avatar).toEqual('UNAVAILABLE')) }) describe('Post', () => { beforeEach(beforePost) - it('obfuscates title', () => expect(post.title).toEqual('UNAVAILABLE')) - it('obfuscates content', () => expect(post.content).toEqual('UNAVAILABLE')) - it('obfuscates contentExcerpt', () => expect(post.contentExcerpt).toEqual('UNAVAILABLE')) - it('obfuscates image', () => expect(post.image).toEqual('UNAVAILABLE')) + it('obfuscates title', () => expect(subject.title).toEqual('UNAVAILABLE')) + it('obfuscates content', () => expect(subject.content).toEqual('UNAVAILABLE')) + it('obfuscates contentExcerpt', () => expect(subject.contentExcerpt).toEqual('UNAVAILABLE')) + it('obfuscates image', () => expect(subject.image).toEqual('UNAVAILABLE')) }) describe('Comment', () => { beforeEach(beforeComment) - it('obfuscates content', () => expect(comment.content).toEqual('UNAVAILABLE')) - it('obfuscates contentExcerpt', () => expect(comment.contentExcerpt).toEqual('UNAVAILABLE')) + it('obfuscates content', () => expect(subject.content).toEqual('UNAVAILABLE')) + it('obfuscates contentExcerpt', () => expect(subject.contentExcerpt).toEqual('UNAVAILABLE')) }) }) }) @@ -179,43 +247,57 @@ describe('softDeleteMiddleware', () => { describe('Query', () => { describe('Post', () => { beforeEach(async () => { - query = '{ Post { title } }' + graphqlQuery = gql` + { + Post { + title + } + } + ` }) describe('as user', () => { beforeEach(async () => { - const headers = await login({ email: 'user@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('hides deleted or disabled posts', async () => { - const expected = { Post: [{ title: 'Publicly visible post' }] } - await expect(action()).resolves.toEqual(expected) + const expected = { data: { Post: [{ title: 'Publicly visible post' }] } } + await expect(action()).resolves.toMatchObject(expected) }) }) describe('as moderator', () => { beforeEach(async () => { - const headers = await login({ email: 'moderator@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await moderator.toJson() }) it('shows disabled but hides deleted posts', async () => { const expected = [{ title: 'Disabled post' }, { title: 'Publicly visible post' }] - const { Post } = await action() + const { + data: { Post }, + } = await action() await expect(Post).toEqual(expect.arrayContaining(expected)) }) }) describe('.comments', () => { beforeEach(async () => { - query = '{ Post(id: "p3") { title comments { content } } }' + graphqlQuery = gql` + { + Post(id: "p3") { + title + comments { + content + } + } + } + ` }) describe('as user', () => { beforeEach(async () => { - const headers = await login({ email: 'user@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('conceals disabled comments', async () => { @@ -224,7 +306,9 @@ describe('softDeleteMiddleware', () => { { content: 'UNAVAILABLE' }, ] const { - Post: [{ comments }], + data: { + Post: [{ comments }], + }, } = await action() await expect(comments).toEqual(expect.arrayContaining(expected)) }) @@ -232,8 +316,7 @@ describe('softDeleteMiddleware', () => { describe('as moderator', () => { beforeEach(async () => { - const headers = await login({ email: 'moderator@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await moderator.toJson() }) it('shows disabled comments', async () => { @@ -242,7 +325,9 @@ describe('softDeleteMiddleware', () => { { content: 'Disabled comment' }, ] const { - Post: [{ comments }], + data: { + Post: [{ comments }], + }, } = await action() await expect(comments).toEqual(expect.arrayContaining(expected)) }) @@ -251,58 +336,70 @@ describe('softDeleteMiddleware', () => { describe('filter (deleted: true)', () => { beforeEach(() => { - query = '{ Post(deleted: true) { title } }' + graphqlQuery = gql` + { + Post(deleted: true) { + title + } + } + ` }) describe('as user', () => { beforeEach(async () => { - const headers = await login({ email: 'user@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorisation error', async () => { - await expect(action()).rejects.toThrow('Not Authorised!') + const { data, errors } = await action() + expect(data).toEqual({ Post: null }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('as moderator', () => { beforeEach(async () => { - const headers = await login({ email: 'moderator@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await moderator.toJson() }) it('shows deleted posts', async () => { - const expected = { Post: [{ title: 'Deleted post' }] } - await expect(action()).resolves.toEqual(expected) + const expected = { data: { Post: [{ title: 'Deleted post' }] } } + await expect(action()).resolves.toMatchObject(expected) }) }) }) describe('filter (disabled: true)', () => { beforeEach(() => { - query = '{ Post(disabled: true) { title } }' + graphqlQuery = gql` + { + Post(disabled: true) { + title + } + } + ` }) describe('as user', () => { beforeEach(async () => { - const headers = await login({ email: 'user@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorisation error', async () => { - await expect(action()).rejects.toThrow('Not Authorised!') + const { data, errors } = await action() + expect(data).toEqual({ Post: null }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('as moderator', () => { beforeEach(async () => { - const headers = await login({ email: 'moderator@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await moderator.toJson() }) it('shows disabled posts', async () => { - const expected = { Post: [{ title: 'Disabled post' }] } - await expect(action()).resolves.toEqual(expected) + const expected = { data: { Post: [{ title: 'Disabled post' }] } } + await expect(action()).resolves.toMatchObject(expected) }) }) }) diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 82128359b..8344e6c89 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -34,6 +34,7 @@ export default function create() { }), ) const author = args.author || (await neodeInstance.create('User', args)) + delete args.author const post = await neodeInstance.create('Post', args) await post.relateTo(author, 'author') await Promise.all(categories.map(c => c.relateTo(post, 'post'))) From 36ba4cc208e67a1b4ae023d4a120c0f46d6c54b8 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 31 Aug 2019 00:40:36 +0200 Subject: [PATCH 04/31] Refactor filterBubble spec --- .../filterBubble/filterBubble.spec.js | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js index c71332db6..4dfcb76d1 100644 --- a/backend/src/middleware/filterBubble/filterBubble.spec.js +++ b/backend/src/middleware/filterBubble/filterBubble.spec.js @@ -1,10 +1,16 @@ -import { GraphQLClient } from 'graphql-request' -import { host, login } from '../../jest/helpers' +import { gql } from '../../jest/helpers' import Factory from '../../seed/factories' -import { neode } from '../../bootstrap/neo4j' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../../server' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' const factory = Factory() -const instance = neode() +const neode = getNeode() +const driver = getDriver() + +let authenticatedUser +let user +let query const currentUserParams = { id: 'u1', @@ -26,24 +32,42 @@ const randomAuthorParams = { const categoryIds = ['cat9'] beforeEach(async () => { - await Promise.all([ + const [currentUser, followedAuthor, randomAuthor] = await Promise.all([ factory.create('User', currentUserParams), factory.create('User', followedAuthorParams), factory.create('User', randomAuthorParams), ]) - await instance.create('Category', { + user = currentUser + await neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', }) - const [asYourself, asFollowedUser, asSomeoneElse] = await Promise.all([ - Factory().authenticateAs(currentUserParams), - Factory().authenticateAs(followedAuthorParams), - Factory().authenticateAs(randomAuthorParams), - ]) - await asYourself.follow({ id: 'u2', type: 'User' }) - await asFollowedUser.create('Post', { title: 'This is the post of a followed user', categoryIds }) - await asSomeoneElse.create('Post', { title: 'This is some random post', categoryIds }) + await currentUser.relateTo(followedAuthor, 'following') + await factory.create('Post', { + author: followedAuthor, + title: 'This is the post of a followed user', + categoryIds, + }) + await factory.create('Post', { + author: randomAuthor, + title: 'This is some random post', + categoryIds, + }) +}) + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + const client = createTestClient(server) + query = client.query }) afterEach(async () => { @@ -52,33 +76,44 @@ afterEach(async () => { describe('Filter posts by author is followed by sb.', () => { describe('given an authenticated user', () => { - let authenticatedClient - beforeEach(async () => { - const headers = await login(currentUserParams) - authenticatedClient = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) describe('no filter bubble', () => { it('returns all posts', async () => { - const query = '{ Post(filter: { }) { title } }' + const postQuery = gql` + { + Post(filter: {}) { + title + } + } + ` const expected = { - Post: [ - { title: 'This is some random post' }, - { title: 'This is the post of a followed user' }, - ], + data: { + Post: [ + { title: 'This is some random post' }, + { title: 'This is the post of a followed user' }, + ], + }, } - await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + await expect(query({ query: postQuery })).resolves.toMatchObject(expected) }) }) describe('filtering for posts of followed users only', () => { it('returns only posts authored by followed users', async () => { - const query = '{ Post( filter: { author: { followedBy_some: { id: "u1" } } }) { title } }' + const postQuery = gql` + { + Post(filter: { author: { followedBy_some: { id: "u1" } } }) { + title + } + } + ` const expected = { - Post: [{ title: 'This is the post of a followed user' }], + data: { Post: [{ title: 'This is the post of a followed user' }] }, } - await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + await expect(query({ query: postQuery })).resolves.toMatchObject(expected) }) }) }) From 3f5ac61b73b161c4bef6aea7a31fd7d931c53eb9 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 31 Aug 2019 00:47:40 +0200 Subject: [PATCH 05/31] Fix users and report resolver specs This time I did not refactor the whole tests suites because of time and complexity of the PR review. --- backend/src/schema/resolvers/reports.spec.js | 19 +++++++------------ backend/src/schema/resolvers/users.spec.js | 13 +++++++------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 7287a79f4..512d8d956 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -12,6 +12,7 @@ describe('report', () => { let returnedObject let variables let createPostVariables + let user const categoryIds = ['cat9'] beforeEach(async () => { @@ -20,10 +21,10 @@ describe('report', () => { id: 'whatever', } headers = {} - await factory.create('User', { - id: 'u1', + user = await factory.create('User', { email: 'test@example.org', password: '1234', + id: 'u1', }) await factory.create('User', { id: 'u2', @@ -127,11 +128,8 @@ describe('report', () => { describe('reported resource is a post', () => { beforeEach(async () => { - await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) await factory.create('Post', { + author: user, id: 'p23', title: 'Matt and Robert having a pair-programming', categoryIds, @@ -182,12 +180,9 @@ describe('report', () => { content: 'please comment on me', categoryIds, } - const asAuthenticatedUser = await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) - await asAuthenticatedUser.create('Post', createPostVariables) - await asAuthenticatedUser.create('Comment', { + await factory.create('Post', { ...createPostVariables, author: user }) + await factory.create('Comment', { + author: user, postId: 'p1', id: 'c34', content: 'Robert getting tired.', diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 454b457e6..bf7a7ec2d 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -7,6 +7,7 @@ let client const factory = Factory() const instance = neode() const categoryIds = ['cat9'] +let user afterEach(async () => { await factory.cleanDatabase() @@ -132,7 +133,6 @@ describe('users', () => { describe('DeleteUser', () => { let deleteUserVariables - let asAuthor const deleteUserMutation = gql` mutation($id: ID!, $resource: [Deletable]) { DeleteUser(id: $id, resource: $resource) { @@ -149,7 +149,7 @@ describe('users', () => { } ` beforeEach(async () => { - await factory.create('User', { + user = await factory.create('User', { email: 'test@example.org', password: '1234', id: 'u343', @@ -193,8 +193,7 @@ describe('users', () => { describe('attempting to delete my own account', () => { let expectedResponse beforeEach(async () => { - asAuthor = Factory() - await asAuthor.authenticateAs({ + await factory.authenticateAs({ email: 'test@example.org', password: '1234', }) @@ -203,12 +202,14 @@ describe('users', () => { name: 'Democracy & Politics', icon: 'university', }) - await asAuthor.create('Post', { + await factory.create('Post', { + author: user, id: 'p139', content: 'Post by user u343', categoryIds, }) - await asAuthor.create('Comment', { + await factory.create('Comment', { + author: user, id: 'c155', postId: 'p139', content: 'Comment by user u343', From 2033b1791c50a8d892d899a7ccb70ec97c361e0b Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 31 Aug 2019 01:03:29 +0200 Subject: [PATCH 06/31] Fix posts resolver spec --- backend/src/schema/resolvers/posts.spec.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 62507af0e..9bf99c351 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -243,10 +243,9 @@ describe('UpdatePost', () => { } ` beforeEach(async () => { - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { + const user = await factory.create('User', authorParams) + await factory.create('Post', { + author: user, id: postId, title: oldTitle, content: oldContent, @@ -400,11 +399,10 @@ describe('DeletePost', () => { } beforeEach(async () => { - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { + const user = await factory.create('User', authorParams) + await factory.create('Post', { id: postId, + author: user, content: 'To be deleted', categoryIds, }) From 1b5d91bfd277fa4bb4d4bac07e9febe035d8b6dd Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 00:45:09 +0200 Subject: [PATCH 07/31] Refactor post spec, implement soft delete, fix bug I think the validation for categories was too complex and the graphql query 'PostEmotionsByCurrentUser' was erroneously allowed for unauthenticated users. --- .../src/middleware/permissionsMiddleware.js | 2 +- .../validation/validationMiddleware.js | 26 +- .../schema/resolvers/notifications.spec.js | 4 +- backend/src/schema/resolvers/posts.js | 71 +- backend/src/schema/resolvers/posts.spec.js | 796 +++++++++--------- 5 files changed, 463 insertions(+), 436 deletions(-) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 061603b6b..745387e41 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -129,7 +129,7 @@ const permissions = shield( isLoggedIn: allow, Badge: allow, PostsEmotionsCountByEmotion: allow, - PostsEmotionsByCurrentUser: allow, + PostsEmotionsByCurrentUser: isAuthenticated, blockedUsers: isAuthenticated, notifications: isAuthenticated, }, diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index 134c85c0c..0ecb6c115 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -52,29 +52,9 @@ const validatePost = async (resolve, root, args, context, info) => { } const validateUpdatePost = async (resolve, root, args, context, info) => { - const { id, categoryIds } = args - const session = context.driver.session() - const categoryQueryRes = await session.run( - ` - MATCH (post:Post {id: $id})-[:CATEGORIZED]->(category:Category) - RETURN category`, - { id }, - ) - session.close() - const [category] = categoryQueryRes.records.map(record => { - return record.get('category') - }) - - if (category) { - if (categoryIds && categoryIds.length > 3) { - throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) - } - } else { - if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) { - throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) - } - } - return resolve(root, args, context, info) + const { categoryIds } = args + if (typeof categoryIds === 'undefined') return resolve(root, args, context, info) + return validatePost(resolve, root, args, context, info) } export default { diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index b321d449f..b2ff1c378 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -109,8 +109,8 @@ describe('given some notifications', () => { ` describe('unauthenticated', () => { it('throws authorization error', async () => { - const result = await query({ query: notificationQuery }) - expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + const { errors } = await query({ query: notificationQuery }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index f9e4f0718..a9f02b76b 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -73,6 +73,35 @@ export default { }, }, Mutation: { + CreatePost: async (object, params, context, resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) + params.id = params.id || uuid() + + const createPostCypher = `CREATE (post:Post {params}) + WITH post + MATCH (author:User {id: $userId}) + MERGE (post)<-[:WROTE]-(author) + WITH post + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + RETURN post` + + const createPostVariables = { userId: context.user.id, categoryIds, params } + + const session = context.driver.session() + const transactionRes = await session.run(createPostCypher, createPostVariables) + + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + + session.close() + + return post.properties + }, UpdatePost: async (object, params, context, resolveInfo) => { const { categoryIds } = params delete params.categoryIds @@ -112,34 +141,21 @@ export default { return post.properties }, - CreatePost: async (object, params, context, resolveInfo) => { - const { categoryIds } = params - delete params.categoryIds - params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - params.id = params.id || uuid() - - const createPostCypher = `CREATE (post:Post {params}) - WITH post - MATCH (author:User {id: $userId}) - MERGE (post)<-[:WROTE]-(author) - WITH post - UNWIND $categoryIds AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (post)-[:CATEGORIZED]->(category) - RETURN post` - - const createPostVariables = { userId: context.user.id, categoryIds, params } - + DeletePost: async (object, args, context, resolveInfo) => { const session = context.driver.session() - const transactionRes = await session.run(createPostCypher, createPostVariables) - - const [post] = transactionRes.records.map(record => { - return record.get('post') - }) - - session.close() - - return post.properties + const transactionRes = await session.run( + ` + MATCH (post:Post {id: $postId}) + SET post.deleted = TRUE + SET post.image = 'DELETED' + SET post.content = 'DELETED' + SET post.contentExcerpt = 'DELETED' + RETURN post + `, + { postId: args.id }, + ) + const [post] = transactionRes.records.map(record => record.get('post').properties) + return post }, AddPostEmotions: async (object, params, context, resolveInfo) => { const session = context.driver.session() @@ -184,6 +200,7 @@ export default { }, Post: { ...Resolver('Post', { + undefinedToNull: ['activityId', 'objectId', 'image', 'language'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', categories: '-[:CATEGORIZED]->(related:Category)', diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 9bf99c351..8e1612f1d 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -1,383 +1,406 @@ -import { GraphQLClient } from 'graphql-request' import { createTestClient } from 'apollo-server-testing' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode, getDriver } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' import createServer from '../../server' const driver = getDriver() const factory = Factory() -const instance = neode() +const neode = getNeode() -let client -let userParams -let authorParams +let query +let mutate +let authenticatedUser +let user -const postId = 'p3589' -const postTitle = 'I am a title' -const postContent = 'Some content' -const oldTitle = 'Old title' -const oldContent = 'Old content' -const newTitle = 'New title' -const newContent = 'New content' -const postSaveError = 'You cannot save a post without at least one category or more than three' const categoryIds = ['cat9', 'cat4', 'cat15'] -let createPostVariables - -const postQueryWithCategories = gql` - query($id: ID) { - Post(id: $id) { - categories { - id - } - } - } -` -const postQueryFilteredByCategory = gql` - query Post($filter: _PostFilter) { - Post(filter: $filter) { - title - id - categories { - id - } - } - } -` -const postCategoriesFilterParam = { categories_some: { id_in: categoryIds } } -const postQueryFilteredByCategoryVariables = { - filter: postCategoriesFilterParam, -} +let variables const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { + CreatePost( + id: $id + title: $title + content: $content + language: $language + categoryIds: $categoryIds + ) { id title content slug disabled deleted + language + author { + name + } } } ` + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + beforeEach(async () => { - userParams = { + variables = {} + user = await factory.create('User', { id: 'u198', name: 'TestUser', email: 'test@example.org', password: '1234', - } - authorParams = { - id: 'u25', - email: 'author@example.org', - password: '1234', - } - await factory.create('User', userParams) + }) await Promise.all([ - instance.create('Category', { + neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', }), - instance.create('Category', { + neode.create('Category', { id: 'cat4', name: 'Environment & Nature', icon: 'tree', }), - instance.create('Category', { + neode.create('Category', { id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart', }), - instance.create('Category', { + neode.create('Category', { id: 'cat27', name: 'Animal Protection', icon: 'paw', }), ]) - createPostVariables = { - id: postId, - title: postTitle, - content: postContent, - categoryIds, - } + authenticatedUser = null }) afterEach(async () => { await factory.cleanDatabase() }) +describe('Post', () => { + const postQuery = gql` + query Post($filter: _PostFilter) { + Post(filter: $filter) { + id + categories { + id + } + } + } + ` + + describe('can be filtered', () => { + it('by categories', async () => { + await Promise.all([ + factory.create('Post', { id: 'p31', categoryIds: ['cat4'] }), + factory.create('Post', { id: 'p32', categoryIds: ['cat15'] }), + factory.create('Post', { id: 'p33', categoryIds: ['cat9'] }), + ]) + const expected = { + data: { + Post: [ + { + id: 'p33', + categories: [{ id: 'cat9' }], + }, + ], + }, + } + variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) + }) + }) +}) + describe('CreatePost', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'p3589', + title: 'I am a title', + content: 'Some content', + categoryIds, + } + }) + describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: createPostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('creates a post', async () => { - const expected = { - CreatePost: { - title: postTitle, - content: postContent, - }, - } - await expect(client.request(createPostMutation, createPostVariables)).resolves.toMatchObject( + const expected = { data: { CreatePost: { title: 'I am a title', content: 'Some content' } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, ) }) it('assigns the authenticated user as author', async () => { - await client.request(createPostMutation, createPostVariables) - const { User } = await client.request( - gql` - { - User(name: "TestUser") { - contributions { - title - } - } - } - `, - { headers }, + const expected = { + data: { + CreatePost: { + title: 'I am a title', + author: { + name: 'TestUser', + }, + }, + }, + } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, ) - expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) }) - describe('disabled and deleted', () => { - it('initially false', async () => { - const expected = { CreatePost: { disabled: false, deleted: false } } - await expect( - client.request(createPostMutation, createPostVariables), - ).resolves.toMatchObject(expected) - }) + it('`disabled` and `deleted` default to `false`', async () => { + const expected = { data: { CreatePost: { disabled: false, deleted: false } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) describe('language', () => { + beforeEach(() => { + variables = { ...variables, language: 'es' } + }) + it('allows a user to set the language of the post', async () => { - const createPostWithLanguageMutation = gql` - mutation($title: String!, $content: String!, $language: String, $categoryIds: [ID]) { - CreatePost( - title: $title - content: $content - language: $language - categoryIds: $categoryIds - ) { - language - } - } - ` - const createPostWithLanguageVariables = { - title: postTitle, - content: postContent, - language: 'en', - categoryIds, - } - const expected = { CreatePost: { language: 'en' } } - await expect( - client.request(createPostWithLanguageMutation, createPostWithLanguageVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + const expected = { data: { CreatePost: { language: 'es' } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) }) describe('categories', () => { - it('throws an error if categoryIds is not an array', async () => { - createPostVariables.categoryIds = null - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) + describe('null', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: null } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) - it('requires at least one category for successful creation', async () => { - createPostVariables.categoryIds = [] - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) + describe('empty', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: [] } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) - it('allows a maximum of three category for successful update', async () => { - createPostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4'] - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) - }) - - it('allows a user to filter for posts by category', async () => { - await client.request(createPostMutation, createPostVariables) - const categoryIdsArray = [{ id: 'cat4' }, { id: 'cat15' }, { id: 'cat9' }] - const expected = { - Post: [ - { - title: postTitle, - id: postId, - categories: expect.arrayContaining(categoryIdsArray), - }, - ], - } - await expect( - client.request(postQueryFilteredByCategory, postQueryFilteredByCategoryVariables), - ).resolves.toEqual(expected) + describe('more than 3 items', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) }) }) }) describe('UpdatePost', () => { - let updatePostVariables + let author const updatePostMutation = gql` mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id content + categories { + id + } } } ` beforeEach(async () => { - const user = await factory.create('User', authorParams) + author = await factory.create('User', { slug: 'the-author' }) await factory.create('Post', { - author: user, - id: postId, - title: oldTitle, - content: oldContent, + author, + id: 'p9876', + title: 'Old title', + content: 'Old content', categoryIds, }) - updatePostVariables = { - id: postId, - title: newTitle, - content: newContent, + variables = { + ...variables, + id: 'p9876', + title: 'New title', + content: 'New content', } }) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updatePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorization error', async () => { - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updatePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { - let headers beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await author.toJson() }) it('updates a post', async () => { - updatePostVariables.categoryIds = ['cat27'] - const expected = { UpdatePost: { id: postId, content: newContent } } - await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + const expected = { data: { UpdatePost: { id: 'p9876', content: 'New content' } } } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( expected, ) }) - describe('categories', () => { - it('allows a user to update other attributes without passing in categoryIds explicitly', async () => { - const expected = { UpdatePost: { id: postId, content: newContent } } - await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + describe('no new category ids provided for update', () => { + it('resolves and keeps current categories', async () => { + const expected = { + data: { + UpdatePost: { + id: 'p9876', + categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), + }, + }, + } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('given category ids', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat27'] } + }) + + it('updates categories of a post', async () => { + const expected = { + data: { + UpdatePost: { + id: 'p9876', + categories: expect.arrayContaining([{ id: 'cat27' }]), + }, + }, + } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( expected, ) }) - it('allows a user to update the categories of a post', async () => { - updatePostVariables.categoryIds = ['cat27'] - await client.request(updatePostMutation, updatePostVariables) - const expected = [{ id: 'cat27' }] - const postQueryWithCategoriesVariables = { - id: postId, - } - await expect( - client.request(postQueryWithCategories, postQueryWithCategoriesVariables), - ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) - }) + describe('more than 3 categories', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] } + }) - it('allows a maximum of three category for a successful update', async () => { - updatePostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4'] - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - postSaveError, - ) + it('allows a maximum of three category for a successful update', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) describe('post created without categories somehow', () => { - let ownerNode, owner, postMutationAction + let owner + beforeEach(async () => { - const postSomehowCreated = await instance.create('Post', { + const postSomehowCreated = await neode.create('Post', { id: 'how-was-this-created', - title: postTitle, - content: postContent, }) - ownerNode = await instance.create('User', { + owner = await neode.create('User', { id: 'author-of-post-without-category', name: 'Hacker', slug: 'hacker', email: 'hacker@example.org', password: '1234', }) - owner = await ownerNode.toJson() - await postSomehowCreated.relateTo(ownerNode, 'author') - postMutationAction = async (user, mutation, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { mutate } = createTestClient(server) - - return mutate({ - mutation, - variables, - }) - } - updatePostVariables.id = 'how-was-this-created' + await postSomehowCreated.relateTo(owner, 'author') + authenticatedUser = await owner.toJson() + variables = { ...variables, id: 'how-was-this-created' } }) it('throws an error if categoryIds is not an array', async () => { - const mustAddCategoryToPost = await postMutationAction( - owner, - updatePostMutation, - updatePostVariables, + const { + errors: [error], + } = await mutate({ + mutation: createPostMutation, + variables: { + ...variables, + categoryIds: null, + }, + }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', ) - expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) }) it('requires at least one category for successful update', async () => { - updatePostVariables.categoryIds = [] - const mustAddCategoryToPost = await postMutationAction( - owner, - updatePostMutation, - updatePostVariables, + const { + errors: [error], + } = await mutate({ + mutation: createPostMutation, + variables: { + ...variables, + categoryIds: [], + }, + }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', ) - expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) }) }) }) @@ -385,72 +408,78 @@ describe('UpdatePost', () => { }) describe('DeletePost', () => { - const mutation = gql` + let author + const deletePostMutation = gql` mutation($id: ID!) { DeletePost(id: $id) { id + deleted content + contentExcerpt + image } } ` - const variables = { - id: postId, - } - beforeEach(async () => { - const user = await factory.create('User', authorParams) + author = await factory.create('User') await factory.create('Post', { - id: postId, - author: user, + id: 'p4711', + author, + title: 'I will be deleted', content: 'To be deleted', + image: 'path/to/some/image', categoryIds, }) + variables = { ...variables, id: 'p4711' } }) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + const { errors } = await mutate({ mutation: deletePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorization error', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + const { errors } = await mutate({ mutation: deletePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { - let headers beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await author.toJson() }) - it('deletes a post', async () => { - const expected = { DeletePost: { id: postId, content: 'To be deleted' } } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + it('marks the post as deleted and blacks out attributes', async () => { + const expected = { + data: { + DeletePost: { + id: 'p4711', + deleted: true, + content: 'DELETED', + contentExcerpt: 'DELETED', + image: 'DELETED', + }, + }, + } + await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( + expected, + ) }) + + it.todo('marks all comments as deleted') }) }) describe('emotions', () => { - let addPostEmotionsVariables, - someUser, - ownerNode, - owner, - postMutationAction, - user, - postQueryAction, - postToEmote, - postToEmoteNode + let author, postToEmote const PostsEmotionsCountQuery = gql` query($id: ID!) { Post(id: $id) { @@ -470,104 +499,75 @@ describe('emotions', () => { } } ` - const addPostEmotionsMutation = gql` - mutation($to: _PostInput!, $data: _EMOTEDInput!) { - AddPostEmotions(to: $to, data: $data) { - from { - id - } - to { - id - } - emotion - } - } - ` + beforeEach(async () => { - userParams.id = 'u1987' - authorParams.id = 'u257' - createPostVariables.id = 'p1376' - const someUserNode = await instance.create('User', userParams) - someUser = await someUserNode.toJson() - ownerNode = await instance.create('User', authorParams) - owner = await ownerNode.toJson() - postToEmoteNode = await instance.create('Post', createPostVariables) - postToEmote = await postToEmoteNode.toJson() - await postToEmoteNode.relateTo(ownerNode, 'author') + author = await neode.create('User', { id: 'u257' }) + postToEmote = await factory.create('Post', { + author, + id: 'p1376', + categoryIds, + }) - postMutationAction = async (user, mutation, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { mutate } = createTestClient(server) - - return mutate({ - mutation, - variables, - }) - } - postQueryAction = async (postQuery, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { query } = createTestClient(server) - return query({ query: postQuery, variables }) - } - addPostEmotionsVariables = { - to: { id: postToEmote.id }, + variables = { + ...variables, + to: { id: 'p1376' }, data: { emotion: 'happy' }, } }) describe('AddPostEmotions', () => { + const addPostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + AddPostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` let postsEmotionsQueryVariables + beforeEach(async () => { - postsEmotionsQueryVariables = { id: postToEmote.id } + postsEmotionsQueryVariables = { id: 'p1376' } }) describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + it('throws authorization error', async () => { - user = null - const addPostEmotions = await postMutationAction( - user, - addPostEmotionsMutation, - addPostEmotionsVariables, - ) + const addPostEmotions = await mutate({ + mutation: addPostEmotionsMutation, + variables, + }) expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated and not the author', () => { - beforeEach(() => { - user = someUser + beforeEach(async () => { + authenticatedUser = await user.toJson() }) it('adds an emotion to the post', async () => { const expected = { data: { AddPostEmotions: { - from: { id: user.id }, - to: addPostEmotionsVariables.to, + from: { id: 'u198' }, + to: { id: 'p1376' }, emotion: 'happy', }, }, } - await expect( - postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + await expect(mutate({ mutation: addPostEmotionsMutation, variables })).resolves.toEqual( + expect.objectContaining(expected), + ) }) it('limits the addition of the same emotion to 1', async () => { @@ -580,48 +580,53 @@ describe('emotions', () => { ], }, } - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await mutate({ mutation: addPostEmotionsMutation, variables }) + await mutate({ mutation: addPostEmotionsMutation, variables }) await expect( - postQueryAction(PostsEmotionsCountQuery, postsEmotionsQueryVariables), + query({ query: PostsEmotionsCountQuery, variables: postsEmotionsQueryVariables }), ).resolves.toEqual(expect.objectContaining(expected)) }) it('allows a user to add more than one emotion', async () => { - const expectedEmotions = [ - { emotion: 'happy', User: { id: user.id } }, - { emotion: 'surprised', User: { id: user.id } }, - ] - const expectedResponse = { - data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + const expected = { + data: { + Post: [ + { + emotions: expect.arrayContaining([ + { emotion: 'happy', User: { id: 'u198' } }, + { emotion: 'surprised', User: { id: 'u198' } }, + ]), + }, + ], + }, } - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) - addPostEmotionsVariables.data.emotion = 'surprised' - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await mutate({ mutation: addPostEmotionsMutation, variables }) + variables = { ...variables, data: { emotion: 'surprised' } } + await mutate({ mutation: addPostEmotionsMutation, variables }) await expect( - postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), - ).resolves.toEqual(expect.objectContaining(expectedResponse)) + query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }), + ).resolves.toEqual(expect.objectContaining(expected)) }) }) describe('authenticated as author', () => { - beforeEach(() => { - user = owner + beforeEach(async () => { + authenticatedUser = await author.toJson() }) it('adds an emotion to the post', async () => { const expected = { data: { AddPostEmotions: { - from: { id: owner.id }, - to: addPostEmotionsVariables.to, + from: { id: 'u257' }, + to: { id: 'p1376' }, emotion: 'happy', }, }, } - await expect( - postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + await expect(mutate({ mutation: addPostEmotionsMutation, variables })).resolves.toEqual( + expect.objectContaining(expected), + ) }) }) }) @@ -642,37 +647,41 @@ describe('emotions', () => { } ` beforeEach(async () => { - await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await author.relateTo(postToEmote, 'emoted', { emotion: 'happy' }) + await user.relateTo(postToEmote, 'emoted', { emotion: 'cry' }) - postsEmotionsQueryVariables = { id: postToEmote.id } + postsEmotionsQueryVariables = { id: 'p1376' } removePostEmotionsVariables = { - to: { id: postToEmote.id }, + to: { id: 'p1376' }, data: { emotion: 'cry' }, } }) describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + it('throws authorization error', async () => { - user = null - const removePostEmotions = await postMutationAction( - user, - removePostEmotionsMutation, - removePostEmotionsVariables, - ) + const removePostEmotions = await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { describe('but not the emoter', () => { + beforeEach(async () => { + authenticatedUser = await author.toJson() + }) + it('returns null if the emotion could not be found', async () => { - user = someUser - const removePostEmotions = await postMutationAction( - user, - removePostEmotionsMutation, - removePostEmotionsVariables, - ) + const removePostEmotions = await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) expect(removePostEmotions).toEqual( expect.objectContaining({ data: { RemovePostEmotions: null } }), ) @@ -680,30 +689,39 @@ describe('emotions', () => { }) describe('as the emoter', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + it('removes an emotion from a post', async () => { - user = owner const expected = { data: { RemovePostEmotions: { - to: { id: postToEmote.id }, - from: { id: user.id }, + to: { id: 'p1376' }, + from: { id: 'u198' }, emotion: 'cry', }, }, } await expect( - postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables), + mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }), ).resolves.toEqual(expect.objectContaining(expected)) }) it('removes only the requested emotion, not all emotions', async () => { - const expectedEmotions = [{ emotion: 'happy', User: { id: authorParams.id } }] + const expectedEmotions = [{ emotion: 'happy', User: { id: 'u257' } }] const expectedResponse = { data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, } - await postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables) + await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) await expect( - postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }), ).resolves.toEqual(expect.objectContaining(expectedResponse)) }) }) @@ -726,30 +744,42 @@ describe('emotions', () => { } ` beforeEach(async () => { - await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + await user.relateTo(postToEmote, 'emoted', { emotion: 'cry' }) PostsEmotionsCountByEmotionVariables = { - postId: postToEmote.id, + postId: 'p1376', data: { emotion: 'cry' }, } - PostsEmotionsByCurrentUserVariables = { postId: postToEmote.id } + PostsEmotionsByCurrentUserVariables = { postId: 'p1376' } }) describe('PostsEmotionsCountByEmotion', () => { it("returns a post's emotions count", async () => { const expectedResponse = { data: { PostsEmotionsCountByEmotion: 1 } } await expect( - postQueryAction(PostsEmotionsCountByEmotionQuery, PostsEmotionsCountByEmotionVariables), + query({ + query: PostsEmotionsCountByEmotionQuery, + variables: PostsEmotionsCountByEmotionVariables, + }), ).resolves.toEqual(expect.objectContaining(expectedResponse)) }) }) - describe('PostsEmotionsCountByEmotion', () => { - it("returns a currentUser's emotions on a post", async () => { - const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } - await expect( - postQueryAction(PostsEmotionsByCurrentUserQuery, PostsEmotionsByCurrentUserVariables), - ).resolves.toEqual(expect.objectContaining(expectedResponse)) + describe('PostsEmotionsByCurrentUser', () => { + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it("returns a currentUser's emotions on a post", async () => { + const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } + await expect( + query({ + query: PostsEmotionsByCurrentUserQuery, + variables: PostsEmotionsByCurrentUserVariables, + }), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) }) }) }) From 660e3434aa3af1189df4a0b8f67502484bdedb76 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 11:11:11 +0200 Subject: [PATCH 08/31] Implement delete of comments when post is deleted --- backend/src/schema/resolvers/posts.js | 2 ++ backend/src/schema/resolvers/posts.spec.js | 41 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index a9f02b76b..e9f430d07 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -146,10 +146,12 @@ export default { const transactionRes = await session.run( ` MATCH (post:Post {id: $postId}) + OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) SET post.deleted = TRUE SET post.image = 'DELETED' SET post.content = 'DELETED' SET post.contentExcerpt = 'DELETED' + SET comment.deleted = TRUE RETURN post `, { postId: args.id }, diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 8e1612f1d..107a90727 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -417,6 +417,11 @@ describe('DeletePost', () => { content contentExcerpt image + comments { + deleted + content + contentExcerpt + } } } ` @@ -466,6 +471,7 @@ describe('DeletePost', () => { content: 'DELETED', contentExcerpt: 'DELETED', image: 'DELETED', + comments: [], }, }, } @@ -474,7 +480,40 @@ describe('DeletePost', () => { ) }) - it.todo('marks all comments as deleted') + describe('if there are comments on the post', () => { + beforeEach(async () => { + await factory.create('Comment', { + postId: 'p4711', + content: 'to be deleted comment content', + contentExcerpt: 'to be deleted comment content', + }) + }) + + it('marks the comments as deleted', async () => { + const expected = { + data: { + DeletePost: { + id: 'p4711', + deleted: true, + content: 'DELETED', + contentExcerpt: 'DELETED', + image: 'DELETED', + comments: [ + { + deleted: true, + // Should we black out the comment content in the database, too? + content: 'to be deleted comment content', + contentExcerpt: 'to be deleted comment content', + }, + ], + }, + }, + } + await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) }) }) From 76acebbb738c5510ac73f089e89e9760ad315b85 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 12:34:21 +0200 Subject: [PATCH 09/31] Notifications query filters for deleted resources This should finally fix #1414 --- backend/src/models/Comment.js | 14 ++ backend/src/models/Post.js | 14 ++ backend/src/schema/resolvers/notifications.js | 2 +- .../schema/resolvers/notifications.spec.js | 163 ++++++++++++------ backend/src/seed/factories/categories.js | 23 +-- 5 files changed, 148 insertions(+), 68 deletions(-) diff --git a/backend/src/models/Comment.js b/backend/src/models/Comment.js index 7b130cc79..face77882 100644 --- a/backend/src/models/Comment.js +++ b/backend/src/models/Comment.js @@ -31,4 +31,18 @@ module.exports = { target: 'User', direction: 'in', }, + notified: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'User', + direction: 'out', + properties: { + read: { type: 'boolean', default: false }, + reason: { + type: 'string', + valid: ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'], + }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + }, + }, } diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index 7dd62287d..5ac8378c2 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -23,6 +23,20 @@ module.exports = { target: 'User', direction: 'in', }, + notified: { + type: 'relationship', + relationship: 'NOTIFIED', + target: 'User', + direction: 'out', + properties: { + read: { type: 'boolean', default: false }, + reason: { + type: 'string', + valid: ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'], + }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + }, + }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, updatedAt: { type: 'string', diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index 65c92b4be..0219df02c 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -44,7 +44,7 @@ export default { try { const cypher = ` - MATCH (resource)-[notification:NOTIFIED]->(user:User {id:$id}) + MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) ${whereClause} RETURN resource, notification, user ${orderByClause} diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index b2ff1c378..280544852 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -1,20 +1,14 @@ import Factory from '../../seed/factories' import { gql } from '../../jest/helpers' -import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' +import { getDriver } from '../../bootstrap/neo4j' import { createTestClient } from 'apollo-server-testing' import createServer from '../.././server' const factory = Factory() -const neode = getNeode() const driver = getDriver() -const userParams = { - id: 'you', - email: 'test@example.org', - password: '1234', -} - let authenticatedUser let user +let author let variables let query let mutate @@ -43,51 +37,81 @@ afterEach(async () => { describe('given some notifications', () => { beforeEach(async () => { - user = await factory.create('User', userParams) - await factory.create('User', { id: 'neighbor' }) - await Promise.all(setupNotifications.map(s => neode.cypher(s))) + const categoryIds = ['cat1'] + author = await factory.create('User', { id: 'author' }) + user = await factory.create('User', { id: 'you' }) + const [neighbor] = await Promise.all([ + factory.create('User', { id: 'neighbor' }), + factory.create('Category', { id: 'cat1' }), + ]) + const [post1, post2, post3] = await Promise.all([ + factory.create('Post', { author, id: 'p1', categoryIds, content: 'Not for you' }), + factory.create('Post', { + author, + id: 'p2', + categoryIds, + content: 'Already seen post mention', + }), + factory.create('Post', { + author, + id: 'p3', + categoryIds, + content: 'You have been mentioned in a post', + }), + ]) + const [comment1, comment2, comment3] = await Promise.all([ + factory.create('Comment', { + author, + postId: 'p3', + id: 'c1', + content: 'You have seen this comment mentioning already', + }), + factory.create('Comment', { + author, + postId: 'p3', + id: 'c2', + content: 'You have been mentioned in a comment', + }), + factory.create('Comment', { + author, + postId: 'p3', + id: 'c3', + content: 'Somebody else was mentioned in a comment', + }), + ]) + await Promise.all([ + post1.relateTo(neighbor, 'notified', { + createdAt: '2019-08-29T17:33:48.651Z', + read: false, + reason: 'mentioned_in_post', + }), + post2.relateTo(user, 'notified', { + createdAt: '2019-08-30T17:33:48.651Z', + read: true, + reason: 'mentioned_in_post', + }), + post3.relateTo(user, 'notified', { + createdAt: '2019-08-31T17:33:48.651Z', + read: false, + reason: 'mentioned_in_post', + }), + comment1.relateTo(user, 'notified', { + createdAt: '2019-08-30T15:33:48.651Z', + read: true, + reason: 'mentioned_in_comment', + }), + comment2.relateTo(user, 'notified', { + createdAt: '2019-08-30T19:33:48.651Z', + read: false, + reason: 'mentioned_in_comment', + }), + comment3.relateTo(neighbor, 'notified', { + createdAt: '2019-09-01T17:33:48.651Z', + read: false, + reason: 'mentioned_in_comment', + }), + ]) }) - const setupNotifications = [ - `MATCH(user:User {id: 'neighbor'}) - MERGE (:Post {id: 'p1', content: 'Not for you'}) - -[:NOTIFIED {createdAt: "2019-08-29T17:33:48.651Z", read: false, reason: "mentioned_in_post"}] - ->(user); - `, - `MATCH(user:User {id: 'you'}) - MERGE (:Post {id: 'p2', content: 'Already seen post mentioning'}) - -[:NOTIFIED {createdAt: "2019-08-30T17:33:48.651Z", read: true, reason: "mentioned_in_post"}] - ->(user); - `, - `MATCH(user:User {id: 'you'}) - MERGE (:Post {id: 'p3', content: 'You have been mentioned in a post'}) - -[:NOTIFIED {createdAt: "2019-08-31T17:33:48.651Z", read: false, reason: "mentioned_in_post"}] - ->(user); - `, - `MATCH(user:User {id: 'you'}) - MATCH(post:Post {id: 'p3'}) - CREATE (comment:Comment {id: 'c1', content: 'You have seen this comment mentioning already'}) - MERGE (comment)-[:COMMENTS]->(post) - MERGE (comment) - -[:NOTIFIED {createdAt: "2019-08-30T15:33:48.651Z", read: true, reason: "mentioned_in_comment"}] - ->(user); - `, - `MATCH(user:User {id: 'you'}) - MATCH(post:Post {id: 'p3'}) - CREATE (comment:Comment {id: 'c2', content: 'You have been mentioned in a comment'}) - MERGE (comment)-[:COMMENTS]->(post) - MERGE (comment) - -[:NOTIFIED {createdAt: "2019-08-30T19:33:48.651Z", read: false, reason: "mentioned_in_comment"}] - ->(user); - `, - `MATCH(user:User {id: 'neighbor'}) - MATCH(post:Post {id: 'p3'}) - CREATE (comment:Comment {id: 'c3', content: 'Somebody else was mentioned in a comment'}) - MERGE (comment)-[:COMMENTS]->(post) - MERGE (comment) - -[:NOTIFIED {createdAt: "2019-09-01T17:33:48.651Z", read: false, reason: "mentioned_in_comment"}] - ->(user); - `, - ] describe('notifications', () => { const notificationQuery = gql` @@ -121,7 +145,7 @@ describe('given some notifications', () => { describe('no filters', () => { it('returns all notifications of current user', async () => { - const expected = expect.objectContaining({ + const expected = { data: { notifications: [ { @@ -135,7 +159,7 @@ describe('given some notifications', () => { { from: { __typename: 'Post', - content: 'Already seen post mentioning', + content: 'Already seen post mention', }, read: true, createdAt: '2019-08-30T17:33:48.651Z', @@ -158,8 +182,10 @@ describe('given some notifications', () => { }, ], }, - }) - await expect(query({ query: notificationQuery, variables })).resolves.toEqual(expected) + } + await expect(query({ query: notificationQuery, variables })).resolves.toMatchObject( + expected, + ) }) }) @@ -191,6 +217,31 @@ describe('given some notifications', () => { query({ query: notificationQuery, variables: { ...variables, read: false } }), ).resolves.toEqual(expected) }) + + describe('if a resource gets deleted', () => { + beforeEach(async () => { + authenticatedUser = await author.toJson() + const deletePostMutation = gql` + mutation($id: ID!) { + DeletePost(id: $id) { + id + deleted + } + } + ` + await expect( + mutate({ mutation: deletePostMutation, variables: { id: 'p3' } }), + ).resolves.toMatchObject({ data: { DeletePost: { id: 'p3', deleted: true } } }) + authenticatedUser = await user.toJson() + }) + + it('reduces notifications list', async () => { + const expected = expect.objectContaining({ data: { notifications: [] } }) + await expect( + query({ query: notificationQuery, variables: { ...variables, read: false } }), + ).resolves.toEqual(expected) + }) + }) }) }) }) diff --git a/backend/src/seed/factories/categories.js b/backend/src/seed/factories/categories.js index 341f1b1fd..d3f5fed21 100644 --- a/backend/src/seed/factories/categories.js +++ b/backend/src/seed/factories/categories.js @@ -1,17 +1,18 @@ import uuid from 'uuid/v4' -export default function(params) { - const { id = uuid(), name, slug, icon } = params - +export default function create() { return { - mutation: ` - mutation($id: ID, $name: String!, $slug: String, $icon: String!) { - CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) { - id - name + factory: async ({ args, neodeInstance }) => { + const defaults = { + id: uuid(), + icon: 'img/badges/fundraisingbox_de_airship.svg', + name: 'Some category name', } - } - `, - variables: { id, name, slug, icon }, + args = { + ...defaults, + ...args, + } + return neodeInstance.create('Category', args) + }, } } From 4d5769fbc6d350558acafd22fc597ef150174fa1 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 16:03:15 +0200 Subject: [PATCH 10/31] Fix cypress tests and factories --- backend/src/seed/factories/index.js | 1 + backend/src/seed/factories/posts.js | 15 +++-- cypress/integration/common/steps.js | 58 ++++++++----------- .../moderation/ReportContent.feature | 7 ++- .../blocked-users/Blocking.feature | 4 +- docker-compose.override.yml | 1 + 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 4cc143e68..4c55f2dc7 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -79,6 +79,7 @@ export default function Factory(options = {}) { this.lastResponse = await factory({ args, neodeInstance, + factoryInstance: this, }) return this.lastResponse } else { diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 8344e6c89..334a95489 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -4,10 +4,9 @@ import uuid from 'uuid/v4' export default function create() { return { - factory: async ({ args, neodeInstance }) => { + factory: async ({ args, neodeInstance, factoryInstance }) => { const defaults = { id: uuid(), - slug: '', title: faker.lorem.sentence(), content: [ faker.lorem.sentence(), @@ -21,11 +20,13 @@ export default function create() { deleted: false, categoryIds: [], } - defaults.slug = slugify(defaults.title, { lower: true }) args = { ...defaults, ...args, } + args.slug = args.slug || slugify(args.title, { lower: true }) + args.contentExcerpt = args.contentExcerpt || args.content + const { categoryIds } = args if (!categoryIds.length) throw new Error('CategoryIds are empty!') const categories = await Promise.all( @@ -33,8 +34,14 @@ export default function create() { return neodeInstance.find('Category', c) }), ) - const author = args.author || (await neodeInstance.create('User', args)) + + let { author, authorId } = args delete args.author + delete args.authorId + if (author && authorId) throw new Error('You provided both author and authorId') + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User', args)) + const post = await neodeInstance.create('Post', args) await post.relateTo(author, 'author') await Promise.all(categories.map(c => c.relateTo(post, 'post'))) diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 386035ea2..558feed69 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -1,6 +1,5 @@ import { Given, When, Then } from "cypress-cucumber-preprocessor/steps"; import helpers from "../../support/helpers"; -import slugify from "slug"; /* global cy */ @@ -11,6 +10,7 @@ let loginCredentials = { password: "1234" }; const narratorParams = { + id: 'id-of-peter-pan', name: "Peter Pan", slug: "peter-pan", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg", @@ -158,40 +158,28 @@ When("I press {string}", label => { cy.contains(label).click(); }); +Given("we have this user in our database:", table => { + const [firstRow] = table.hashes() + cy.factory().create('User', firstRow) +}) + Given("we have the following posts in our database:", table => { - table.hashes().forEach(({ Author, ...postAttributes }, i) => { - Author = Author || `author-${i}`; - const userAttributes = { - name: Author, - email: `${slugify(Author, { lower: true })}@example.org`, - password: "1234" - }; - postAttributes.deleted = Boolean(postAttributes.deleted); - const disabled = Boolean(postAttributes.disabled); - postAttributes.categoryIds = [`cat${i}${new Date()}`]; - postAttributes; - cy.factory() - .create("User", userAttributes) - .authenticateAs(userAttributes) - .create("Category", { - id: `cat${i}${new Date()}`, - name: "Just For Fun", - slug: `just-for-fun-${i}`, - icon: "smile" - }) - .create("Post", postAttributes); - if (disabled) { - const moderatorParams = { - email: "moderator@example.org", - role: "moderator", - password: "1234" - }; - cy.factory() - .create("User", moderatorParams) - .authenticateAs(moderatorParams) - .mutate("mutation($id: ID!) { disable(id: $id) }", postAttributes); + cy.factory().create('Category', { + id: `cat-456`, + name: "Just For Fun", + slug: `just-for-fun`, + icon: "smile" + }) + + table.hashes().forEach(({ ...postAttributes }, i) => { + postAttributes = { + ...postAttributes, + deleted: Boolean(postAttributes.deleted), + disabled: Boolean(postAttributes.disabled), + categoryIds: ['cat-456'] } - }); + cy.factory().create("Post", postAttributes); + }) }); Then("I see a success message:", message => { @@ -210,11 +198,11 @@ When( ); Given("I previously created a post", () => { + lastPost.authorId = narratorParams.id lastPost.title = "previously created post"; lastPost.content = "with some content"; - lastPost.categoryIds = "cat0"; + lastPost.categoryIds = ["cat0"]; cy.factory() - .authenticateAs(loginCredentials) .create("Post", lastPost); }); diff --git a/cypress/integration/moderation/ReportContent.feature b/cypress/integration/moderation/ReportContent.feature index 62fb4f421..a5f7f2f50 100644 --- a/cypress/integration/moderation/ReportContent.feature +++ b/cypress/integration/moderation/ReportContent.feature @@ -8,9 +8,12 @@ Feature: Report and Moderate So I can look into it and decide what to do Background: + Given we have this user in our database: + | id | name | + | u67 | David Irving| Given we have the following posts in our database: - | Author | id | title | content | - | David Irving | p1 | The Truth about the Holocaust | It never existed! | + | authorId | id | title | content | + | u67 | p1 | The Truth about the Holocaust | It never existed! | Scenario Outline: Report a post from various pages diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature index ed784b803..6ff81f4dc 100644 --- a/cypress/integration/user_profile/blocked-users/Blocking.feature +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -27,8 +27,8 @@ Feature: Block a User Scenario: Posts of blocked users are filtered from search results Given we have the following posts in our database: - | Author | id | title | content | - | Some unblocked user | im-not-blocked | Post that should be seen | cause I'm not blocked | + | id | title | content | + | im-not-blocked | Post that should be seen | cause I'm not blocked | Given "Spammy Spammer" wrote a post "Spam Spam Spam" When I search for "Spam" Then I should see the following posts in the select dropdown: diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 41a88970f..32ed3ab92 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -54,6 +54,7 @@ services: - SMTP_HOST=mailserver - SMTP_PORT=25 - SMTP_IGNORE_TLS=true + - "DEBUG=${DEBUG}" neo4j: environment: - NEO4J_AUTH=none From 784e1fd911bd791883e4520ff696119a78f970c3 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 16:56:56 +0200 Subject: [PATCH 11/31] Fix one more cypress test, implement a factory --- backend/src/models/Tag.js | 17 +++++++ backend/src/models/index.js | 1 + backend/src/seed/factories/posts.js | 13 +++++- backend/src/seed/factories/tags.js | 20 ++++----- cypress/integration/common/steps.js | 45 +++++-------------- .../blocked-users/Blocking.feature | 1 - 6 files changed, 47 insertions(+), 50 deletions(-) create mode 100644 backend/src/models/Tag.js diff --git a/backend/src/models/Tag.js b/backend/src/models/Tag.js new file mode 100644 index 000000000..90b5f8772 --- /dev/null +++ b/backend/src/models/Tag.js @@ -0,0 +1,17 @@ +module.exports = { + id: { type: 'string', primary: true }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + post: { + type: 'relationship', + relationship: 'TAGGED', + target: 'Post', + direction: 'in', + }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 4f0b40cee..a5323678b 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -9,4 +9,5 @@ export default { Post: require('./Post.js'), Comment: require('./Comment.js'), Category: require('./Category.js'), + Tag: require('./Tag.js'), } diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 334a95489..cb3c163d8 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -35,16 +35,25 @@ export default function create() { }), ) + const { tagIds = [] } = args + delete args.tags + const tags = await Promise.all( + tagIds.map(t => { + return neodeInstance.find('Tag', t) + }), + ) + let { author, authorId } = args delete args.author delete args.authorId if (author && authorId) throw new Error('You provided both author and authorId') - if (authorId) author = await neodeInstance.find('User', authorId) - author = author || (await factoryInstance.create('User', args)) + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User')) const post = await neodeInstance.create('Post', args) await post.relateTo(author, 'author') await Promise.all(categories.map(c => c.relateTo(post, 'post'))) + await Promise.all(tags.map(t => t.relateTo(post, 'post'))) return post }, } diff --git a/backend/src/seed/factories/tags.js b/backend/src/seed/factories/tags.js index 4a135e051..9005d1406 100644 --- a/backend/src/seed/factories/tags.js +++ b/backend/src/seed/factories/tags.js @@ -1,16 +1,12 @@ -import uuid from 'uuid/v4' - -export default function(params) { - const { id = uuid(), name = '#human-connection' } = params - +export default function create() { return { - mutation: ` - mutation($id: ID!) { - CreateTag(id: $id) { - id - } + factory: async ({ args, neodeInstance }) => { + const defaults = { name: '#human-connection' } + args = { + ...defaults, + ...args, } - `, - variables: { id, name }, + return neodeInstance.create('Tag', args) + }, } } diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 558feed69..f7ab18707 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -28,40 +28,20 @@ Given("we have a selection of categories", () => { Given("we have a selection of tags and categories as well as posts", () => { cy.createCategories("cat12") .factory() - .authenticateAs(loginCredentials) .create("Tag", { id: "Ecology" }) .create("Tag", { id: "Nature" }) .create("Tag", { id: "Democracy" }); - const someAuthor = { - id: "authorId", - email: "author@example.org", - password: "1234" - }; - const yetAnotherAuthor = { - id: "yetAnotherAuthor", - email: "yet-another-author@example.org", - password: "1234" - }; + cy.factory() - .create("User", someAuthor) - .authenticateAs(someAuthor) - .create("Post", { id: "p0", categoryIds: ["cat12"] }) - .create("Post", { id: "p1", categoryIds: ["cat121"] }); + .create("User", { id: 'a1' }) + .create("Post", {authorId: 'a1', tagIds: [ "Ecology", "Nature", "Democracy" ], categoryIds: ["cat12"] }) + .create("Post", {authorId: 'a1', tagIds: [ "Nature", "Democracy" ], categoryIds: ["cat121"] }); + cy.factory() - .create("User", yetAnotherAuthor) - .authenticateAs(yetAnotherAuthor) - .create("Post", { id: "p2", categoryIds: ["cat12"] }); + .create("User", { id: 'a2'}) + .create("Post", { authorId: 'a2', tagIds: ['Nature', 'Democracy'], categoryIds: ["cat12"] }); cy.factory() - .authenticateAs(loginCredentials) - .create("Post", { id: "p3", categoryIds: ["cat122"] }) - .relate("Post", "Tags", { from: "p0", to: "Ecology" }) - .relate("Post", "Tags", { from: "p0", to: "Nature" }) - .relate("Post", "Tags", { from: "p0", to: "Democracy" }) - .relate("Post", "Tags", { from: "p1", to: "Nature" }) - .relate("Post", "Tags", { from: "p1", to: "Democracy" }) - .relate("Post", "Tags", { from: "p2", to: "Nature" }) - .relate("Post", "Tags", { from: "p2", to: "Democracy" }) - .relate("Post", "Tags", { from: "p3", to: "Democracy" }); + .create("Post", { authorId: narratorParams.id, tagIds: ['Democracy'], categoryIds: ["cat122"] }) }); Given("we have the following user accounts:", table => { @@ -410,11 +390,7 @@ Given("I follow the user {string}", name => { Given('"Spammy Spammer" wrote a post {string}', title => { cy.createCategories("cat21") .factory() - .authenticateAs({ - email: "spammy-spammer@example.org", - password: "1234" - }) - .create("Post", { title, categoryIds: ["cat21"] }); + .create("Post", { authorId: 'annoying-user', title, categoryIds: ["cat21"] }); }); Then("the list of posts of this user is empty", () => { @@ -433,8 +409,7 @@ Then("nobody is following the user profile anymore", () => { Given("I wrote a post {string}", title => { cy.createCategories(`cat213`, title) .factory() - .authenticateAs(loginCredentials) - .create("Post", { title, categoryIds: ["cat213"] }); + .create("Post", { authorId: narratorParams.id, title, categoryIds: ["cat213"] }); }); When("I block the user {string}", name => { diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature index 6ff81f4dc..9b27f82a3 100644 --- a/cypress/integration/user_profile/blocked-users/Blocking.feature +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -7,7 +7,6 @@ Feature: Block a User Given I have a user account And there is an annoying user called "Spammy Spammer" And I am logged in - And we have a selection of categories Scenario: Block a user Given I am on the profile page of the annoying user From afe47e84c0fcfcd90e75bb3af684e1476ade929f Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 17:19:15 +0200 Subject: [PATCH 12/31] Remove the organizations for now We haven't implemented them at all, so I would prefer not to have obscure data in the database. --- backend/src/seed/factories/index.js | 2 -- backend/src/seed/factories/organizations.js | 21 ------------------- backend/src/seed/seed-db.js | 23 --------------------- 3 files changed, 46 deletions(-) delete mode 100644 backend/src/seed/factories/organizations.js diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 4c55f2dc7..176f7ec26 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -2,7 +2,6 @@ import { GraphQLClient, request } from 'graphql-request' import { getDriver, neode } from '../../bootstrap/neo4j' import createBadge from './badges.js' import createUser from './users.js' -import createOrganization from './organizations.js' import createPost from './posts.js' import createComment from './comments.js' import createCategory from './categories.js' @@ -24,7 +23,6 @@ const authenticatedHeaders = async ({ email, password }, host) => { const factories = { Badge: createBadge, User: createUser, - Organization: createOrganization, Post: createPost, Comment: createComment, Category: createCategory, diff --git a/backend/src/seed/factories/organizations.js b/backend/src/seed/factories/organizations.js deleted file mode 100644 index 536de1597..000000000 --- a/backend/src/seed/factories/organizations.js +++ /dev/null @@ -1,21 +0,0 @@ -import faker from 'faker' -import uuid from 'uuid/v4' - -export default function create(params) { - const { - id = uuid(), - name = faker.company.companyName(), - description = faker.company.catchPhrase(), - } = params - - return { - mutation: ` - mutation($id: ID!, $name: String!, $description: String!) { - CreateOrganization(id: $id, name: $name, description: $description) { - name - } - } - `, - variables: { id, name, description }, - } -} diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 5a5c7716b..acd50cae3 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -692,29 +692,6 @@ import Factory from './factories' }), ]) - await Promise.all([ - f.create('Organization', { - id: 'o1', - name: 'Democracy Deutschland', - description: 'Description for democracy-deutschland.', - }), - f.create('Organization', { - id: 'o2', - name: 'Human-Connection', - description: 'Description for human-connection.', - }), - f.create('Organization', { - id: 'o3', - name: 'Pro Veg', - description: 'Description for pro-veg.', - }), - f.create('Organization', { - id: 'o4', - name: 'Greenpeace', - description: 'Description for greenpeace.', - }), - ]) - await Promise.all([ f.relate('Organization', 'CreatedBy', { from: 'u1', From e5be418076da66b9d0a3297c68ae62ef09d5af12 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 18:14:37 +0200 Subject: [PATCH 13/31] Refactor seeds - no need for a factories server! Fix #1427 --- backend/package.json | 5 +- backend/src/models/User.js | 6 + backend/src/seed/seed-db.js | 752 +++++++++++++++--------------------- 3 files changed, 326 insertions(+), 437 deletions(-) diff --git a/backend/package.json b/backend/package.json index 00f7be4bb..8d43b6d67 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,9 +19,8 @@ "test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --", "test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", - "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", - "db:reset": "cross-env babel-node src/seed/reset-db.js", - "db:seed": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions run-p --race dev db:script:seed" + "db:reset": "babel-node src/seed/reset-db.js", + "db:seed": "babel-node src/seed/seed-db.js" }, "author": "Human Connection gGmbH", "license": "MIT", diff --git a/backend/src/models/User.js b/backend/src/models/User.js index fa578f8ad..b10144315 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -83,4 +83,10 @@ module.exports = { target: 'Notification', direction: 'in', }, + shouted: { + type: 'relationship', + relationship: 'SHOUTED', + target: 'Post', + direction: 'out', + }, } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index acd50cae3..153b65805 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -1,9 +1,29 @@ import faker from 'faker' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../server' import Factory from './factories' +import { neode as getNeode, getDriver } from '../bootstrap/neo4j' +import { gql } from '../jest/helpers' /* eslint-disable no-multi-spaces */ ;(async function() { + let authenticatedUser = null + const driver = getDriver() + const factory = Factory() + const neode = getNeode() + try { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + const { mutate } = createTestClient(server) + const f = Factory() const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([ f.create('Badge', { @@ -36,9 +56,9 @@ import Factory from './factories' peterLustig, bobDerBaumeister, jennyRostock, - tick, // eslint-disable-line no-unused-vars - trick, // eslint-disable-line no-unused-vars - track, // eslint-disable-line no-unused-vars + huey, + dewey, + louie, dagobert, ] = await Promise.all([ f.create('User', { @@ -64,22 +84,22 @@ import Factory from './factories' }), f.create('User', { id: 'u4', - name: 'Huey (Tick)', - slug: 'huey-tick', + name: 'Huey', + slug: 'huey', role: 'user', email: 'huey@example.org', }), f.create('User', { id: 'u5', - name: 'Dewey (Trick)', - slug: 'dewey-trick', + name: 'Dewey', + slug: 'dewey', role: 'user', email: 'dewey@example.org', }), f.create('User', { id: 'u6', - name: 'Louie (Track)', - slug: 'louie-track', + name: 'Louie', + slug: 'louie', role: 'user', email: 'louie@example.org', }), @@ -92,33 +112,6 @@ import Factory from './factories' }), ]) - const [asAdmin, asModerator, asUser, asTick, asTrick, asTrack] = await Promise.all([ - Factory().authenticateAs({ - email: 'admin@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'moderator@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'user@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'huey@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'dewey@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'louie@example.org', - password: '1234', - }), - ]) - await Promise.all([ peterLustig.relateTo(racoon, 'rewarded'), peterLustig.relateTo(rhino, 'rewarded'), @@ -133,16 +126,16 @@ import Factory from './factories' bobDerBaumeister.relateTo(jennyRostock, 'friends'), peterLustig.relateTo(jennyRostock, 'following'), - peterLustig.relateTo(tick, 'following'), - bobDerBaumeister.relateTo(tick, 'following'), - jennyRostock.relateTo(tick, 'following'), - tick.relateTo(track, 'following'), - trick.relateTo(tick, 'following'), - track.relateTo(jennyRostock, 'following'), + peterLustig.relateTo(huey, 'following'), + bobDerBaumeister.relateTo(huey, 'following'), + jennyRostock.relateTo(huey, 'following'), + huey.relateTo(dewey, 'following'), + dewey.relateTo(huey, 'following'), + louie.relateTo(jennyRostock, 'following'), - dagobert.relateTo(tick, 'blocked'), - dagobert.relateTo(trick, 'blocked'), - dagobert.relateTo(track, 'blocked'), + dagobert.relateTo(huey, 'blocked'), + dagobert.relateTo(dewey, 'blocked'), + dagobert.relateTo(louie, 'blocked'), ]) await Promise.all([ @@ -244,25 +237,90 @@ import Factory from './factories' }), ]) - await Promise.all([ + const [environment, nature, democracy, freedom] = await Promise.all([ f.create('Tag', { - id: 'Umwelt', - name: 'Umwelt', + id: 'Environment', }), f.create('Tag', { - id: 'Naturschutz', - name: 'Naturschutz', + id: 'Nature', }), f.create('Tag', { - id: 'Demokratie', - name: 'Demokratie', + id: 'Democracy', }), f.create('Tag', { - id: 'Freiheit', - name: 'Freiheit', + id: 'Freedom', }), ]) + const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([ + factory.create('Post', { + author: peterLustig, + id: 'p0', + image: faker.image.unsplash.food(), + categoryIds: ['cat16'], + }), + factory.create('Post', { + author: bobDerBaumeister, + id: 'p1', + image: faker.image.unsplash.technology(), + categoryIds: ['cat1'], + }), + factory.create('Post', { + author: huey, + id: 'p3', + categoryIds: ['cat3'], + }), + factory.create('Post', { + author: dewey, + id: 'p4', + categoryIds: ['cat4'], + }), + factory.create('Post', { + author: louie, + id: 'p5', + categoryIds: ['cat5'], + }), + factory.create('Post', { + authorId: 'u1', + id: 'p6', + image: faker.image.unsplash.buildings(), + categoryIds: ['cat6'], + }), + factory.create('Post', { + author: huey, + id: 'p9', + categoryIds: ['cat9'], + }), + factory.create('Post', { + author: dewey, + id: 'p10', + categoryIds: ['cat10'], + }), + factory.create('Post', { + author: louie, + id: 'p11', + image: faker.image.unsplash.people(), + categoryIds: ['cat11'], + }), + factory.create('Post', { + author: bobDerBaumeister, + id: 'p13', + categoryIds: ['cat13'], + }), + factory.create('Post', { + author: jennyRostock, + id: 'p14', + image: faker.image.unsplash.objects(), + categoryIds: ['cat14'], + }), + factory.create('Post', { + author: huey, + id: 'p15', + categoryIds: ['cat15'], + }), + ]) + + authenticatedUser = await louie.toJson() const mention1 = 'Hey @jenny-rostock, what\'s up?' const mention2 = @@ -271,445 +329,271 @@ import Factory from './factories' 'See #NaturphilosophieYoga can really help you!' const hashtagAndMention1 = 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' + const createPostMutation = gql` + mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + id + } + } + ` await Promise.all([ - asAdmin.create('Post', { - id: 'p0', - image: faker.image.unsplash.food(), - categoryIds: ['cat16'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p2', + title: `Nature Philosophy Yoga`, + content: hashtag1, + categoryIds: ['cat2'], + }, }), - asModerator.create('Post', { - id: 'p1', - image: faker.image.unsplash.technology(), - categoryIds: ['cat1'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p7', + title: 'This is post #7', + content: `${mention1} ${faker.lorem.paragraph()}`, + categoryIds: ['cat7'], + }, }), - asUser.create('Post', { - id: 'p2', - title: `Nature Philosophy Yoga`, - content: `${hashtag1}`, - categoryIds: ['cat2'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p8', + image: faker.image.unsplash.nature(), + title: `Quantum Flow Theory explains Quantum Gravity`, + content: hashtagAndMention1, + categoryIds: ['cat8'], + }, }), - asTick.create('Post', { - id: 'p3', - categoryIds: ['cat3'], - }), - asTrick.create('Post', { - id: 'p4', - categoryIds: ['cat4'], - }), - asTrack.create('Post', { - id: 'p5', - categoryIds: ['cat5'], - }), - asAdmin.create('Post', { - id: 'p6', - image: faker.image.unsplash.buildings(), - categoryIds: ['cat6'], - }), - asModerator.create('Post', { - id: 'p7', - content: `${mention1} ${faker.lorem.paragraph()}`, - categoryIds: ['cat7'], - }), - asUser.create('Post', { - id: 'p8', - image: faker.image.unsplash.nature(), - title: `Quantum Flow Theory explains Quantum Gravity`, - content: `${hashtagAndMention1}`, - categoryIds: ['cat8'], - }), - asTick.create('Post', { - id: 'p9', - categoryIds: ['cat9'], - }), - asTrick.create('Post', { - id: 'p10', - categoryIds: ['cat10'], - }), - asTrack.create('Post', { - id: 'p11', - image: faker.image.unsplash.people(), - categoryIds: ['cat11'], - }), - asAdmin.create('Post', { - id: 'p12', - content: `${mention2} ${faker.lorem.paragraph()}`, - categoryIds: ['cat12'], - }), - asModerator.create('Post', { - id: 'p13', - categoryIds: ['cat13'], - }), - asUser.create('Post', { - id: 'p14', - image: faker.image.unsplash.objects(), - categoryIds: ['cat14'], - }), - asTick.create('Post', { - id: 'p15', - categoryIds: ['cat15'], - }), - ]) - - await Promise.all([ - f.relate('Post', 'Tags', { - from: 'p0', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p1', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p2', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p3', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p4', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p5', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p6', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p7', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p8', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p9', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p10', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p11', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p12', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p13', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p14', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p15', - to: 'Demokratie', - }), - f.emote({ - from: 'u1', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u2', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u3', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u4', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u5', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u7', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u2', - to: 'p14', - data: 'cry', - }), - f.emote({ - from: 'u3', - to: 'p13', - data: 'angry', - }), - f.emote({ - from: 'u4', - to: 'p12', - data: 'funny', - }), - f.emote({ - from: 'u5', - to: 'p11', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p10', - data: 'cry', - }), - f.emote({ - from: 'u5', - to: 'p9', - data: 'happy', - }), - f.emote({ - from: 'u4', - to: 'p8', - data: 'angry', - }), - f.emote({ - from: 'u3', - to: 'p7', - data: 'funny', - }), - f.emote({ - from: 'u2', - to: 'p6', - data: 'surprised', - }), - f.emote({ - from: 'u1', - to: 'p5', - data: 'cry', - }), - f.emote({ - from: 'u2', - to: 'p4', - data: 'happy', - }), - f.emote({ - from: 'u3', - to: 'p3', - data: 'angry', - }), - f.emote({ - from: 'u4', - to: 'p2', - data: 'funny', - }), - f.emote({ - from: 'u5', - to: 'p1', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p0', - data: 'cry', - }), - ]) - - await Promise.all([ - asAdmin.shout({ - id: 'p2', - type: 'Post', - }), - asAdmin.shout({ - id: 'p6', - type: 'Post', - }), - asModerator.shout({ - id: 'p0', - type: 'Post', - }), - asModerator.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p7', - type: 'Post', - }), - asTick.shout({ - id: 'p8', - type: 'Post', - }), - asTick.shout({ - id: 'p9', - type: 'Post', - }), - asTrack.shout({ - id: 'p10', - type: 'Post', - }), - ]) - await Promise.all([ - asAdmin.shout({ - id: 'p2', - type: 'Post', - }), - asAdmin.shout({ - id: 'p6', - type: 'Post', - }), - asModerator.shout({ - id: 'p0', - type: 'Post', - }), - asModerator.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p7', - type: 'Post', - }), - asTick.shout({ - id: 'p8', - type: 'Post', - }), - asTick.shout({ - id: 'p9', - type: 'Post', - }), - asTrack.shout({ - id: 'p10', - type: 'Post', + mutate({ + mutation: createPostMutation, + variables: { + id: 'p12', + title: 'This is post #12', + content: `${mention2} ${faker.lorem.paragraph()}`, + categoryIds: ['cat12'], + }, }), ]) + const [p2, p7, p8, p12] = await Promise.all( + ['p2', 'p7', 'p8', 'p12'].map(id => neode.find('Post', id)), + ) + authenticatedUser = null + authenticatedUser = await dewey.toJson() const mentionInComment1 = 'I heard @jenny-rostock, practice it since 3 years now.' const mentionInComment2 = 'Did @peter-lustig told you?' + const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + } + } + ` + await Promise.all([ + mutate({ + mutation: createCommentMutation, + variables: { + id: 'c4', + postId: 'p2', + content: mentionInComment1, + }, + }), + mutate({ + mutation: createCommentMutation, + variables: { + id: 'c4-1', + postId: 'p2', + content: mentionInComment2, + }, + }), + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'p14', + content: faker.lorem.paragraph(), + }, + }), // should send a notification + ]) + authenticatedUser = null await Promise.all([ - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c1', postId: 'p1', }), - asTick.create('Comment', { + factory.create('Comment', { + author: huey, id: 'c2', postId: 'p1', }), - asTrack.create('Comment', { + factory.create('Comment', { + author: louie, id: 'c3', postId: 'p3', }), - asTrick.create('Comment', { - id: 'c4', - postId: 'p2', - content: `${mentionInComment1}`, - }), - asUser.create('Comment', { - id: 'c4-1', - postId: 'p2', - content: `${mentionInComment2}`, - }), - asModerator.create('Comment', { + factory.create('Comment', { + author: bobDerBaumeister, id: 'c5', postId: 'p3', }), - asAdmin.create('Comment', { + factory.create('Comment', { + author: peterLustig, id: 'c6', postId: 'p4', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c7', postId: 'p2', }), - asTick.create('Comment', { + factory.create('Comment', { + author: huey, id: 'c8', postId: 'p15', }), - asTrick.create('Comment', { + factory.create('Comment', { + author: dewey, id: 'c9', postId: 'p15', }), - asTrack.create('Comment', { + factory.create('Comment', { + author: louie, id: 'c10', postId: 'p15', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c11', postId: 'p15', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c12', postId: 'p15', }), ]) - const disableMutation = 'mutation($id: ID!) { disable(id: $id) }' await Promise.all([ - asModerator.mutate(disableMutation, { - id: 'p11', - }), - asModerator.mutate(disableMutation, { - id: 'c5', - }), + democracy.relateTo(p3, 'post'), + democracy.relateTo(p11, 'post'), + democracy.relateTo(p15, 'post'), + democracy.relateTo(p7, 'post'), + environment.relateTo(p1, 'post'), + environment.relateTo(p5, 'post'), + environment.relateTo(p9, 'post'), + environment.relateTo(p13, 'post'), + freedom.relateTo(p0, 'post'), + freedom.relateTo(p4, 'post'), + freedom.relateTo(p8, 'post'), + freedom.relateTo(p12, 'post'), + nature.relateTo(p2, 'post'), + nature.relateTo(p6, 'post'), + nature.relateTo(p10, 'post'), + nature.relateTo(p14, 'post'), + peterLustig.relateTo(p15, 'emoted', { emotion: 'surprised' }), + bobDerBaumeister.relateTo(p15, 'emoted', { emotion: 'surprised' }), + jennyRostock.relateTo(p15, 'emoted', { emotion: 'surprised' }), + huey.relateTo(p15, 'emoted', { emotion: 'surprised' }), + dewey.relateTo(p15, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p15, 'emoted', { emotion: 'surprised' }), + dagobert.relateTo(p15, 'emoted', { emotion: 'surprised' }), + bobDerBaumeister.relateTo(p14, 'emoted', { emotion: 'cry' }), + jennyRostock.relateTo(p13, 'emoted', { emotion: 'angry' }), + huey.relateTo(p12, 'emoted', { emotion: 'funny' }), + dewey.relateTo(p11, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p10, 'emoted', { emotion: 'cry' }), + dewey.relateTo(p9, 'emoted', { emotion: 'happy' }), + huey.relateTo(p8, 'emoted', { emotion: 'angry' }), + jennyRostock.relateTo(p7, 'emoted', { emotion: 'funny' }), + bobDerBaumeister.relateTo(p6, 'emoted', { emotion: 'surprised' }), + peterLustig.relateTo(p5, 'emoted', { emotion: 'cry' }), + bobDerBaumeister.relateTo(p4, 'emoted', { emotion: 'happy' }), + jennyRostock.relateTo(p3, 'emoted', { emotion: 'angry' }), + huey.relateTo(p2, 'emoted', { emotion: 'funny' }), + dewey.relateTo(p1, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p0, 'emoted', { emotion: 'cry' }), ]) await Promise.all([ - asTick.create('Report', { - description: "I don't like this comment", - id: 'c1', - }), - asTrick.create('Report', { - description: "I don't like this post", - id: 'p1', - }), - asTrack.create('Report', { - description: "I don't like this user", - id: 'u1', - }), + peterLustig.relateTo(p1, 'shouted'), + peterLustig.relateTo(p6, 'shouted'), + bobDerBaumeister.relateTo(p0, 'shouted'), + bobDerBaumeister.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p7, 'shouted'), + huey.relateTo(p8, 'shouted'), + huey.relateTo(p9, 'shouted'), + dewey.relateTo(p10, 'shouted'), + peterLustig.relateTo(p2, 'shouted'), + peterLustig.relateTo(p6, 'shouted'), + bobDerBaumeister.relateTo(p0, 'shouted'), + bobDerBaumeister.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p7, 'shouted'), + huey.relateTo(p8, 'shouted'), + huey.relateTo(p9, 'shouted'), + louie.relateTo(p10, 'shouted'), ]) + const disableMutation = gql` + mutation($id: ID!) { + disable(id: $id) + } + ` + authenticatedUser = await bobDerBaumeister.toJson() await Promise.all([ - f.relate('Organization', 'CreatedBy', { - from: 'u1', - to: 'o1', + mutate({ + mutation: disableMutation, + variables: { + id: 'p11', + }, }), - f.relate('Organization', 'CreatedBy', { - from: 'u1', - to: 'o2', - }), - f.relate('Organization', 'OwnedBy', { - from: 'u2', - to: 'o2', - }), - f.relate('Organization', 'OwnedBy', { - from: 'u2', - to: 'o3', + mutate({ + mutation: disableMutation, + variables: { + id: 'c5', + }, }), ]) + authenticatedUser = null + + const reportMutation = gql` + mutation($id: ID!, $description: String!) { + report(description: $description, id: $id) { + id + } + } + ` + authenticatedUser = await huey.toJson() + await Promise.all([ + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this comment", + id: 'c1', + }, + }), + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this post", + id: 'p1', + }, + }), + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this user", + id: 'u1', + }, + }), + ]) + authenticatedUser = null await Promise.all( [...Array(30).keys()].map(i => { From 580c048cfaa6aeec38fc4c1391e5d01207a7fcbb Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 18:56:10 +0200 Subject: [PATCH 14/31] Remove obsolete report factory --- backend/src/seed/factories/index.js | 2 -- backend/src/seed/factories/reports.js | 19 ------------------- 2 files changed, 21 deletions(-) delete mode 100644 backend/src/seed/factories/reports.js diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 176f7ec26..1689be5e9 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -6,7 +6,6 @@ import createPost from './posts.js' import createComment from './comments.js' import createCategory from './categories.js' import createTag from './tags.js' -import createReport from './reports.js' export const seedServerHost = 'http://127.0.0.1:4001' @@ -27,7 +26,6 @@ const factories = { Comment: createComment, Category: createCategory, Tag: createTag, - Report: createReport, } export const cleanDatabase = async (options = {}) => { diff --git a/backend/src/seed/factories/reports.js b/backend/src/seed/factories/reports.js deleted file mode 100644 index 5bb6f6ba2..000000000 --- a/backend/src/seed/factories/reports.js +++ /dev/null @@ -1,19 +0,0 @@ -import faker from 'faker' - -export default function create(params) { - const { description = faker.lorem.sentence(), id } = params - - return { - mutation: ` - mutation($id: ID!, $description: String!) { - report(description: $description, id: $id) { - id - } - } - `, - variables: { - id, - description, - }, - } -} From c4ba2c4aeb721ec5d88001b054e10d879fe41026 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Mon, 2 Sep 2019 21:08:56 +0200 Subject: [PATCH 15/31] SoftDeleteMiddleware obfuscates deleted resources --- .../src/middleware/softDeleteMiddleware.js | 10 +- .../middleware/softDeleteMiddleware.spec.js | 4 +- backend/src/schema/resolvers/comments.js | 4 +- backend/src/schema/resolvers/comments.spec.js | 4 +- backend/src/schema/resolvers/posts.js | 6 +- backend/src/schema/resolvers/posts.spec.js | 16 +- backend/src/schema/resolvers/users.js | 40 +- backend/src/schema/resolvers/users.spec.js | 561 ++++++++++++------ backend/src/seed/factories/comments.js | 21 +- backend/src/seed/factories/posts.js | 14 +- 10 files changed, 438 insertions(+), 242 deletions(-) diff --git a/backend/src/middleware/softDeleteMiddleware.js b/backend/src/middleware/softDeleteMiddleware.js index cc5aa06c5..b07770fd9 100644 --- a/backend/src/middleware/softDeleteMiddleware.js +++ b/backend/src/middleware/softDeleteMiddleware.js @@ -13,8 +13,8 @@ const setDefaultFilters = (resolve, root, args, context, info) => { return resolve(root, args, context, info) } -const obfuscateDisabled = async (resolve, root, args, context, info) => { - if (!isModerator(context) && root.disabled) { +const obfuscate = async (resolve, root, args, context, info) => { + if (root.deleted || (!isModerator(context) && root.disabled)) { root.content = 'UNAVAILABLE' root.contentExcerpt = 'UNAVAILABLE' root.title = 'UNAVAILABLE' @@ -40,7 +40,7 @@ export default { } return resolve(root, args, context, info) }, - Post: obfuscateDisabled, - User: obfuscateDisabled, - Comment: obfuscateDisabled, + Post: obfuscate, + User: obfuscate, + Comment: obfuscate, } diff --git a/backend/src/middleware/softDeleteMiddleware.spec.js b/backend/src/middleware/softDeleteMiddleware.spec.js index 673c3026f..73446fe1e 100644 --- a/backend/src/middleware/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDeleteMiddleware.spec.js @@ -362,8 +362,8 @@ describe('softDeleteMiddleware', () => { authenticatedUser = await moderator.toJson() }) - it('shows deleted posts', async () => { - const expected = { data: { Post: [{ title: 'Deleted post' }] } } + it('does not show deleted posts', async () => { + const expected = { data: { Post: [{ title: 'UNAVAILABLE' }] } } await expect(action()).resolves.toMatchObject(expected) }) }) diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index dd55e8f44..1f6803e09 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -53,8 +53,8 @@ export default { ` MATCH (comment:Comment {id: $commentId}) SET comment.deleted = TRUE - SET comment.content = 'DELETED' - SET comment.contentExcerpt = 'DELETED' + SET comment.content = 'UNAVAILABLE' + SET comment.contentExcerpt = 'UNAVAILABLE' RETURN comment `, { commentId: args.id }, diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index b3cf1fda2..dcb2d31f8 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -271,8 +271,8 @@ describe('DeleteComment', () => { DeleteComment: { id: 'c456', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', }, } expect(data).toMatchObject(expected) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index e9f430d07..1b97617cc 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -148,9 +148,9 @@ export default { MATCH (post:Post {id: $postId}) OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) SET post.deleted = TRUE - SET post.image = 'DELETED' - SET post.content = 'DELETED' - SET post.contentExcerpt = 'DELETED' + SET post.image = 'UNAVAILABLE' + SET post.content = 'UNAVAILABLE' + SET post.contentExcerpt = 'UNAVAILABLE' SET comment.deleted = TRUE RETURN post `, diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 107a90727..cd89a4055 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -468,9 +468,9 @@ describe('DeletePost', () => { DeletePost: { id: 'p4711', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', - image: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: 'UNAVAILABLE', comments: [], }, }, @@ -495,15 +495,15 @@ describe('DeletePost', () => { DeletePost: { id: 'p4711', deleted: true, - content: 'DELETED', - contentExcerpt: 'DELETED', - image: 'DELETED', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: 'UNAVAILABLE', comments: [ { deleted: true, // Should we black out the comment content in the database, too? - content: 'to be deleted comment content', - contentExcerpt: 'to be deleted comment content', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', }, ], }, diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 2ac1d6043..f0a179028 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -102,23 +102,41 @@ export default { const { resource } = params const session = context.driver.session() - if (resource && resource.length) { - await Promise.all( - resource.map(async node => { - await session.run( - ` + let user + try { + if (resource && resource.length) { + await Promise.all( + resource.map(async node => { + await session.run( + ` MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) SET resource.deleted = true + SET resource.content = 'UNAVAILABLE' + SET resource.contentExcerpt = 'UNAVAILABLE' + SET comment.deleted = true RETURN author`, - { - userId: context.user.id, - }, - ) - }), + { + userId: context.user.id, + }, + ) + }), + ) + } + const transactionResult = await session.run( + ` + MATCH (user:User {id: $userId}) + SET user.deleted = true + SET user.name = 'UNAVAILABLE' + SET user.about = 'UNAVAILABLE' + RETURN user`, + { userId: context.user.id }, ) + user = transactionResult.records.map(r => r.get('user').properties)[0] + } finally { session.close() } - return neo4jgraphql(object, params, context, resolveInfo, false) + return user }, }, User: { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index bf7a7ec2d..82174a2bf 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -1,203 +1,242 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' -let client const factory = Factory() -const instance = neode() const categoryIds = ['cat9'] let user +let query +let mutate +let authenticatedUser +let variables + +const driver = getDriver() +const neode = getNeode() + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + afterEach(async () => { await factory.cleanDatabase() }) -describe('users', () => { - describe('User', () => { - describe('query by email address', () => { - beforeEach(async () => { - await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) - }) - - const query = `query($email: String) { User(email: $email) { name } }` - const variables = { email: 'any-email-address@example.org' } - beforeEach(() => { - client = new GraphQLClient(host) - }) - - it('is forbidden', async () => { - await expect(client.request(query, variables)).rejects.toThrow('Not Authorised') - }) - - describe('as admin', () => { - beforeEach(async () => { - const userParams = { - role: 'admin', - email: 'admin@example.org', - password: '1234', - } - const factory = Factory() - await factory.create('User', userParams) - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) - - it('is permitted', async () => { - await expect(client.request(query, variables)).resolves.toEqual({ - User: [{ name: 'Johnny' }], - }) - }) - }) +describe('User', () => { + describe('query by email address', () => { + beforeEach(async () => { + await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) }) - }) - describe('UpdateUser', () => { - const userParams = { - email: 'user@example.org', - password: '1234', - id: 'u47', - name: 'John Doe', - } - const variables = { - id: 'u47', - name: 'John Doughnut', - } - - const mutation = ` - mutation($id: ID!, $name: String) { - UpdateUser(id: $id, name: $name) { - id + const userQuery = gql` + query($email: String) { + User(email: $email) { name } } ` + const variables = { email: 'any-email-address@example.org' } - beforeEach(async () => { - await factory.create('User', userParams) + it('is forbidden', async () => { + const { errors } = await query({ query: userQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) - describe('as another user', () => { + describe('as admin', () => { beforeEach(async () => { - const someoneElseParams = { - email: 'someone-else@example.org', + const userParams = { + role: 'admin', + email: 'admin@example.org', password: '1234', - name: 'James Doe', } - - await factory.create('User', someoneElseParams) - const headers = await login(someoneElseParams) - client = new GraphQLClient(host, { headers }) + const admin = await factory.create('User', userParams) + authenticatedUser = await admin.toJson() }) - it('is not allowed to change other user accounts', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + it('is permitted', async () => { + await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ + data: { User: [{ name: 'Johnny' }] }, + }) }) }) + }) +}) - describe('as the same user', () => { - beforeEach(async () => { - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) +describe('UpdateUser', () => { + const userParams = { + email: 'user@example.org', + password: '1234', + id: 'u47', + name: 'John Doe', + } + const variables = { + id: 'u47', + name: 'John Doughnut', + } - it('name within specifications', async () => { - const expected = { + const updateUserMutation = gql` + mutation($id: ID!, $name: String) { + UpdateUser(id: $id, name: $name) { + id + name + } + } + ` + + beforeEach(async () => { + user = await factory.create('User', userParams) + }) + + describe('as another user', () => { + beforeEach(async () => { + const someoneElseParams = { + email: 'someone-else@example.org', + password: '1234', + name: 'James Doe', + } + + const someoneElse = await factory.create('User', someoneElseParams) + authenticatedUser = await someoneElse.toJson() + }) + + it('is not allowed to change other user accounts', async () => { + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('as the same user', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('name within specifications', async () => { + const expected = { + data: { UpdateUser: { id: 'u47', name: 'John Doughnut', }, - } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) - }) + }, + } + await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( + expected, + ) + }) - it('with `null` as name', async () => { - const variables = { - id: 'u47', - name: null, - } - const expected = '"name" must be a string' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) + it('with `null` as name', async () => { + const variables = { + id: 'u47', + name: null, + } + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty( + 'message', + 'child "name" fails because ["name" contains an invalid value, "name" must be a string]', + ) + }) - it('with too short name', async () => { - const variables = { - id: 'u47', - name: ' ', + it('with too short name', async () => { + const variables = { + id: 'u47', + name: ' ', + } + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty( + 'message', + 'child "name" fails because ["name" length must be at least 3 characters long]', + ) + }) + }) +}) + +describe('DeleteUser', () => { + const deleteUserMutation = gql` + mutation($id: ID!, $resource: [Deletable]) { + DeleteUser(id: $id, resource: $resource) { + id + name + about + deleted + contributions { + id + content + contentExcerpt + deleted + comments { + id + content + contentExcerpt + deleted + } } - const expected = '"name" length must be at least 3 characters long' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) + comments { + id + content + contentExcerpt + deleted + } + } + } + ` + beforeEach(async () => { + variables = { id: ' u343', resource: [] } + + user = await factory.create('User', { + name: 'My name should be deleted', + about: 'along with my about', + id: 'u343', + }) + await factory.create('User', { + email: 'friends-account@example.org', + password: '1234', + id: 'u565', }) }) - describe('DeleteUser', () => { - let deleteUserVariables - const deleteUserMutation = gql` - mutation($id: ID!, $resource: [Deletable]) { - DeleteUser(id: $id, resource: $resource) { - id - contributions { - id - deleted - } - comments { - id - deleted - } - } - } - ` + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { beforeEach(async () => { - user = await factory.create('User', { - email: 'test@example.org', - password: '1234', - id: 'u343', - }) - await factory.create('User', { - email: 'friends-account@example.org', - password: '1234', - id: 'u565', - }) - deleteUserVariables = { id: 'u343', resource: [] } + authenticatedUser = await user.toJson() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) + describe("attempting to delete another user's account", () => { + beforeEach(() => { + variables = { ...variables, id: 'u565' } + }) + + it('throws an authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { headers }) + describe('attempting to delete my own account', () => { + beforeEach(() => { + variables = { ...variables, id: 'u343' } }) - describe("attempting to delete another user's account", () => { - it('throws an authorization error', async () => { - deleteUserVariables = { id: 'u565' } - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) - }) - }) - - describe('attempting to delete my own account', () => { - let expectedResponse + describe('given posts and comments', () => { beforeEach(async () => { - await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { + await factory.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', @@ -211,64 +250,192 @@ describe('users', () => { await factory.create('Comment', { author: user, id: 'c155', - postId: 'p139', content: 'Comment by user u343', }) - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: false }], + await factory.create('Comment', { + postId: 'p139', + id: 'c156', + content: "A comment by someone else on user u343's post", + }) + }) + + it("deletes my account, but doesn't delete posts or comments by default", async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, }, } - }) - it("deletes my account, but doesn't delete posts or comments by default", async () => { - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject( expectedResponse, ) }) - describe("deletes a user's", () => { - it('posts on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: false }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + describe('deletion of all post requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post'] } }) - it('comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: true }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + describe("marks user's posts as deleted", () => { + it('posts on request', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + }) + + describe('deletion of all comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Comment'] } }) - it('posts and comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: true }], + it('marks comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, }, } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + + describe('deletion of all post and comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post', 'Comment'] } + }) + + it('marks posts and comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) }) }) }) diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index 87f46f358..33d9464ae 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -3,7 +3,7 @@ import uuid from 'uuid/v4' export default function create() { return { - factory: async ({ args, neodeInstance }) => { + factory: async ({ args, neodeInstance, factoryInstance }) => { const defaults = { id: uuid(), content: [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), @@ -12,11 +12,22 @@ export default function create() { ...defaults, ...args, } - const { postId } = args - if (!postId) throw new Error('PostId is missing!') - const post = await neodeInstance.find('Post', postId) + args.contentExcerpt = args.contentExcerpt || args.content + + let { post, postId } = args + delete args.post delete args.postId - const author = args.author || (await neodeInstance.create('User', args)) + if (post && post) throw new Error('You provided both post and postId') + if (postId) post = await neodeInstance.find('Post', postId) + post = post || (await factoryInstance.create('Post')) + + let { author, authorId } = args + delete args.author + delete args.authorId + if (author && authorId) throw new Error('You provided both author and authorId') + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User')) + delete args.author const comment = await neodeInstance.create('Comment', args) await comment.relateTo(post, 'post') diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index cb3c163d8..e81251c53 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -27,13 +27,13 @@ export default function create() { args.slug = args.slug || slugify(args.title, { lower: true }) args.contentExcerpt = args.contentExcerpt || args.content - const { categoryIds } = args - if (!categoryIds.length) throw new Error('CategoryIds are empty!') - const categories = await Promise.all( - categoryIds.map(c => { - return neodeInstance.find('Category', c) - }), - ) + let { categories, categoryIds } = args + delete args.categories + delete args.categoryIds + if (categories && categoryIds) throw new Error('You provided both category and categoryIds') + if (categoryIds) + categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id))) + categories = categories || (await Promise.all([factoryInstance.create('Category')])) const { tagIds = [] } = args delete args.tags From 491a6260319c419d2cf54faad7cf0756c90dcb70 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 3 Sep 2019 00:00:50 +0200 Subject: [PATCH 16/31] Replace deleteComment with a more KISS solution --- backend/src/schema/resolvers/users.js | 1 + webapp/components/Comment.vue | 21 ++----- webapp/components/CommentList/CommentList.vue | 11 +++- webapp/components/PostCard/index.vue | 6 +- webapp/graphql/CommentMutations.js | 26 ++++++++ webapp/graphql/PostQuery.js | 48 ++++++++++++++ webapp/pages/index.vue | 6 +- webapp/pages/post/_id/_slug/more-info.vue | 62 ++++--------------- webapp/pages/profile/_id/_slug.vue | 10 +-- 9 files changed, 112 insertions(+), 79 deletions(-) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index f0a179028..90bd15822 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -179,6 +179,7 @@ export default { hasOne: { invitedBy: '<-[:INVITED]-(related:User)', disabledBy: '<-[:DISABLED]-(related:User)', + location: '-[:IS_IN]->(related:Location)', }, hasMany: { followedBy: '<-[:FOLLOWS]-(related:User)', diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 74b3f893c..e4df37693 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -68,7 +68,6 @@ import ContentMenu from '~/components/ContentMenu' import ContentViewer from '~/components/Editor/ContentViewer' import HcEditCommentForm from '~/components/EditCommentForm/EditCommentForm' import CommentMutations from '~/graphql/CommentMutations' -import PostQuery from '~/graphql/PostQuery' export default { data: function() { @@ -143,26 +142,14 @@ export default { }, async deleteCommentCallback() { try { - await this.$apollo.mutate({ + const { + data: { DeleteComment }, + } = await this.$apollo.mutate({ mutation: CommentMutations(this.$i18n).DeleteComment, variables: { id: this.comment.id }, - update: async store => { - const data = await store.readQuery({ - query: PostQuery(this.$i18n), - variables: { id: this.post.id }, - }) - - const index = data.Post[0].comments.findIndex( - deletedComment => deletedComment.id === this.comment.id, - ) - if (index !== -1) { - data.Post[0].comments.splice(index, 1) - } - await store.writeQuery({ query: PostQuery(this.$i18n), data }) - }, }) this.$toast.success(this.$t(`delete.comment.success`)) - this.$emit('deleteComment') + this.$emit('deleteComment', DeleteComment) } catch (err) { this.$toast.error(err.message) } diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index 2ae670bf4..710607b94 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -18,11 +18,11 @@
@@ -40,5 +40,12 @@ export default { props: { post: { type: Object, default: () => {} }, }, + methods: { + deleteComment(deleted) { + this.post.comments = this.post.comments.map(comment => { + return comment.id === deleted.id ? deleted : comment + }) + }, + }, } diff --git a/webapp/components/PostCard/index.vue b/webapp/components/PostCard/index.vue index 7c79fe9eb..43f846d89 100644 --- a/webapp/components/PostCard/index.vue +++ b/webapp/components/PostCard/index.vue @@ -118,9 +118,11 @@ export default { methods: { async deletePostCallback() { try { - await this.$apollo.mutate(deletePostMutation(this.post.id)) + const { + data: { DeletePost }, + } = await this.$apollo.mutate(deletePostMutation(this.post.id)) this.$toast.success(this.$t('delete.contribution.success')) - this.$emit('removePostFromList') + this.$emit('removePostFromList', DeletePost) } catch (err) { this.$toast.error(err.message) } diff --git a/webapp/graphql/CommentMutations.js b/webapp/graphql/CommentMutations.js index b772923d4..dd8d1d3a0 100644 --- a/webapp/graphql/CommentMutations.js +++ b/webapp/graphql/CommentMutations.js @@ -1,6 +1,7 @@ import gql from 'graphql-tag' export default i18n => { + const lang = i18n.locale().toUpperCase() return { CreateComment: gql` mutation($postId: ID!, $content: String!) { @@ -55,6 +56,31 @@ export default i18n => { mutation($id: ID!) { DeleteComment(id: $id) { id + contentExcerpt + content + createdAt + disabled + deleted + author { + id + slug + name + avatar + disabled + deleted + shoutedCount + contributionsCount + commentedCount + followedByCount + followedByCurrentUser + location { + name: name${lang} + } + badges { + id + icon + } + } } } `, diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js index d82232fd8..a5415c6cf 100644 --- a/webapp/graphql/PostQuery.js +++ b/webapp/graphql/PostQuery.js @@ -128,3 +128,51 @@ export const PostsEmotionsByCurrentUser = () => { } ` } + +export const relatedContributions = i18n => { + const lang = i18n.locale().toUpperCase() + return gql`query Post($slug: String!) { + Post(slug: $slug) { + id + title + tags { + id + } + categories { + id + name + icon + } + relatedContributions(first: 2) { + id + title + slug + contentExcerpt + shoutedCount + categories { + id + name + icon + } + author { + id + name + slug + avatar + contributionsCount + followedByCount + followedByCurrentUser + commentedCount + location { + name: name${lang} + } + badges { + id + icon + } + } + } + shoutedCount + } + }` +} diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 1783007db..a89e1ce76 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -20,7 +20,7 @@ @@ -164,9 +164,9 @@ export default { showMoreContributions() { this.offset += this.pageSize }, - deletePost(_index, postId) { + deletePost(deletedPost) { this.posts = this.posts.filter(post => { - return post.id !== postId + return post.id !== deletedPost.id }) }, }, diff --git a/webapp/pages/post/_id/_slug/more-info.vue b/webapp/pages/post/_id/_slug/more-info.vue index ab711e101..b05312a0a 100644 --- a/webapp/pages/post/_id/_slug/more-info.vue +++ b/webapp/pages/post/_id/_slug/more-info.vue @@ -36,11 +36,11 @@ @@ -50,9 +50,9 @@