diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b1a08a14d..ea4ba3dd2 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -16,11 +16,15 @@ const isAdmin = rule()(async (parent, args, { user }, info) => { return user && user.role === 'admin' }) -const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info) => { +const isMyOwn = rule({ + cache: 'no_cache', +})(async (parent, args, context, info) => { return context.user.id === parent.id }) -const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => { +const belongsToMe = rule({ + cache: 'no_cache', +})(async (_, args, context) => { const { driver, user: { id: userId }, @@ -32,7 +36,10 @@ const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => { MATCH (u:User {id: $userId})<-[:NOTIFIED]-(n:Notification {id: $notificationId}) RETURN n `, - { userId, notificationId }, + { + userId, + notificationId, + }, ) const [notification] = result.records.map(record => { return record.get('n') @@ -41,12 +48,16 @@ const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => { return Boolean(notification) }) -const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => { +const onlyEnabledContent = rule({ + cache: 'strict', +})(async (parent, args, ctx, info) => { const { disabled, deleted } = args return !(disabled || deleted) }) -const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver }) => { +const isAuthor = rule({ + cache: 'no_cache', +})(async (parent, args, { user, driver }) => { if (!user) return false const session = driver.session() const { id: postId } = args @@ -55,7 +66,9 @@ const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver MATCH (post:Post {id: $postId})<-[:WROTE]-(author) RETURN author `, - { postId }, + { + postId, + }, ) const [author] = result.records.map(record => { return record.get('author') @@ -100,6 +113,7 @@ const permissions = shield({ enable: isModerator, disable: isModerator, CreateComment: isAuthenticated, + DeleteComment: isAuthenticated, // CreateUser: allow, }, User: { diff --git a/backend/src/resolvers/comments.js b/backend/src/resolvers/comments.js index 949b77041..7f3b11da4 100644 --- a/backend/src/resolvers/comments.js +++ b/backend/src/resolvers/comments.js @@ -53,6 +53,11 @@ export default { ) session.close() + return comment + }, + DeleteComment: async (object, params, context, resolveInfo) => { + const comment = await neo4jgraphql(object, params, context, resolveInfo, false) + return comment }, }, diff --git a/backend/src/resolvers/comments.spec.js b/backend/src/resolvers/comments.spec.js index 451c559f1..e3bf00528 100644 --- a/backend/src/resolvers/comments.spec.js +++ b/backend/src/resolvers/comments.spec.js @@ -1,3 +1,4 @@ +import gql from 'graphql-tag' import Factory from '../seed/factories' import { GraphQLClient } from 'graphql-request' import { host, login } from '../jest/helpers' @@ -5,6 +6,7 @@ import { host, login } from '../jest/helpers' const factory = Factory() let client let createCommentVariables +let deleteCommentVariables let createPostVariables let createCommentVariablesSansPostId let createCommentVariablesWithNonExistentPost @@ -21,22 +23,22 @@ afterEach(async () => { }) describe('CreateComment', () => { - const createCommentMutation = ` - mutation($postId: ID, $content: String!) { - CreateComment(postId: $postId, content: $content) { - id - content + const createCommentMutation = gql` + mutation($postId: ID, $content: String!) { + CreateComment(postId: $postId, content: $content) { + id + content + } } - } ` - const createPostMutation = ` - mutation($id: ID!, $title: String!, $content: String!) { - CreatePost(id: $id, title: $title, content: $content) { - id + const createPostMutation = gql` + mutation($id: ID!, $title: String!, $content: String!) { + CreatePost(id: $id, title: $title, content: $content) { + id + } } - } ` - const commentQueryForPostId = ` + const commentQueryForPostId = gql` query($content: String) { Comment(content: $content) { postId @@ -59,8 +61,13 @@ describe('CreateComment', () => { describe('authenticated', () => { let headers beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) createCommentVariables = { postId: 'p1', content: "I'm authorised to comment", @@ -88,15 +95,25 @@ describe('CreateComment', () => { it('assigns the authenticated user as author', async () => { await client.request(createCommentMutation, createCommentVariables) - const { User } = await client.request(`{ + const { User } = await client.request(gql` + { User(email: "test@example.org") { comments { content } } - }`) + } + `) - expect(User).toEqual([{ comments: [{ content: "I'm authorised to comment" }] }]) + expect(User).toEqual([ + { + comments: [ + { + content: "I'm authorised to comment", + }, + ], + }, + ]) }) it('throw an error if an empty string is sent from the editor as content', async () => { @@ -186,7 +203,92 @@ describe('CreateComment', () => { commentQueryForPostId, commentQueryVariablesByContent, ) - expect(Comment).toEqual([{ postId: null }]) + expect(Comment).toEqual([ + { + postId: null, + }, + ]) }) }) }) + +describe('DeleteComment', () => { + const createCommentMutation = gql` + mutation($postId: ID, $content: String!) { + CreateComment(postId: $postId, content: $content) { + id + content + } + } + ` + const deleteCommentMutation = gql` + mutation($id: ID!) { + DeleteComment(id: $id) { + id + } + } + ` + const createPostMutation = gql` + mutation($id: ID!, $title: String!, $content: String!) { + CreatePost(id: $id, title: $title, content: $content) { + id + } + } + ` + describe('unauthenticated', () => { + it('throws authorization error', async () => { + deleteCommentVariables = { + id: 'c1', + } + client = new GraphQLClient(host) + await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( + 'Not Authorised', + ) + }) + }) + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) + createCommentVariables = { + id: 'c1', + postId: 'p1', + content: "I'm authorised to comment", + } + deleteCommentVariables = { + id: 'c1', + } + createPostVariables = { + id: 'p1', + title: 'post to comment on', + content: 'please comment on me', + } + await client.request(createPostMutation, createPostVariables) + }) + + it('deletes the authors comment', async () => { + const { CreateComment } = await client.request(createCommentMutation, createCommentVariables) + + deleteCommentVariables = { + id: CreateComment.id, + } + const expected = { + DeleteComment: { + id: CreateComment.id, + }, + } + await expect( + client.request(deleteCommentMutation, deleteCommentVariables), + ).resolves.toMatchObject(expected) + }) + + it.todo('throws an error if it tries to delete a comment not from this author') + }) +}) diff --git a/backend/src/resolvers/socialMedia.spec.js b/backend/src/resolvers/socialMedia.spec.js index 5143587b1..4e298e783 100644 --- a/backend/src/resolvers/socialMedia.spec.js +++ b/backend/src/resolvers/socialMedia.spec.js @@ -1,13 +1,14 @@ +import gql from 'graphql-tag' import Factory from '../seed/factories' import { GraphQLClient } from 'graphql-request' import { host, login } from '../jest/helpers' const factory = Factory() -describe('CreateSocialMedia', () => { +describe('SocialMedia', () => { let client let headers - const mutationC = ` + const mutationC = gql` mutation($url: String!) { CreateSocialMedia(url: $url) { id @@ -15,7 +16,7 @@ describe('CreateSocialMedia', () => { } } ` - const mutationD = ` + const mutationD = gql` mutation($id: ID!) { DeleteSocialMedia(id: $id) { id @@ -42,19 +43,28 @@ describe('CreateSocialMedia', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - const variables = { url: 'http://nsosp.org' } + const variables = { + url: 'http://nsosp.org', + } await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised') }) }) describe('authenticated', () => { beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) }) it('creates social media with correct URL', async () => { - const variables = { url: 'http://nsosp.org' } + const variables = { + url: 'http://nsosp.org', + } await expect(client.request(mutationC, variables)).resolves.toEqual( expect.objectContaining({ CreateSocialMedia: { @@ -66,11 +76,15 @@ describe('CreateSocialMedia', () => { }) it('deletes social media', async () => { - const creationVariables = { url: 'http://nsosp.org' } + const creationVariables = { + url: 'http://nsosp.org', + } const { CreateSocialMedia } = await client.request(mutationC, creationVariables) const { id } = CreateSocialMedia - const deletionVariables = { id } + const deletionVariables = { + id, + } const expected = { DeleteSocialMedia: { id: id, @@ -81,12 +95,16 @@ describe('CreateSocialMedia', () => { }) it('rejects empty string', async () => { - const variables = { url: '' } + const variables = { + url: '', + } await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL') }) it('validates URLs', async () => { - const variables = { url: 'not-a-url' } + const variables = { + url: 'not-a-url', + } await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL') }) }) diff --git a/webapp/components/Comment.spec.js b/webapp/components/Comment.spec.js index ebb9b8bf8..e899a05e1 100644 --- a/webapp/components/Comment.spec.js +++ b/webapp/components/Comment.spec.js @@ -14,11 +14,20 @@ describe('Comment.vue', () => { let propsData let mocks let getters + let wrapper + let Wrapper beforeEach(() => { propsData = {} mocks = { $t: jest.fn(), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $apollo: { + mutate: jest.fn().mockResolvedValue(), + }, } getters = { 'auth/user': () => { @@ -29,11 +38,16 @@ describe('Comment.vue', () => { }) describe('shallowMount', () => { - const Wrapper = () => { + Wrapper = () => { const store = new Vuex.Store({ getters, }) - return shallowMount(Comment, { store, propsData, mocks, localVue }) + return shallowMount(Comment, { + store, + propsData, + mocks, + localVue, + }) } describe('given a comment', () => { @@ -45,7 +59,7 @@ describe('Comment.vue', () => { }) it('renders content', () => { - const wrapper = Wrapper() + wrapper = Wrapper() expect(wrapper.text()).toMatch('Hello I am a comment content') }) @@ -55,17 +69,17 @@ describe('Comment.vue', () => { }) it('renders no comment data', () => { - const wrapper = Wrapper() + wrapper = Wrapper() expect(wrapper.text()).not.toMatch('comment content') }) it('has no "disabled-content" css class', () => { - const wrapper = Wrapper() + wrapper = Wrapper() expect(wrapper.classes()).not.toContain('disabled-content') }) it('translates a placeholder', () => { - /* const wrapper = */ Wrapper() + wrapper = Wrapper() const calls = mocks.$t.mock.calls const expected = [['comment.content.unavailable-placeholder']] expect(calls).toEqual(expect.arrayContaining(expected)) @@ -77,16 +91,46 @@ describe('Comment.vue', () => { }) it('renders comment data', () => { - const wrapper = Wrapper() + wrapper = Wrapper() expect(wrapper.text()).toMatch('comment content') }) it('has a "disabled-content" css class', () => { - const wrapper = Wrapper() + wrapper = Wrapper() expect(wrapper.classes()).toContain('disabled-content') }) }) }) + + beforeEach(jest.useFakeTimers) + + describe('test callbacks', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + describe('deletion of Comment from List by invoking "deleteCommentCallback()"', () => { + beforeEach(() => { + wrapper.vm.deleteCommentCallback() + }) + + describe('after timeout', () => { + beforeEach(jest.runAllTimers) + + it('emits "deleteComment"', () => { + expect(wrapper.emitted().deleteComment.length).toBe(1) + }) + + it('does call mutation', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) + }) + + it('mutation is successful', () => { + expect(mocks.$toast.success).toHaveBeenCalledTimes(1) + }) + }) + }) + }) }) }) }) diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 751a11f02..862b95545 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -14,6 +14,7 @@ placement="bottom-end" resource-type="comment" :resource="comment" + :callbacks="{ confirm: deleteCommentCallback, cancel: null }" style="float-right" :is-owner="isAuthor(author.id)" /> @@ -27,6 +28,7 @@ diff --git a/webapp/components/ContentMenu.vue b/webapp/components/ContentMenu.vue index 6e5bca4c0..69289cb6d 100644 --- a/webapp/components/ContentMenu.vue +++ b/webapp/components/ContentMenu.vue @@ -28,6 +28,7 @@ import Dropdown from '~/components/Dropdown' export default { + name: 'ContentMenu', components: { Dropdown, }, @@ -42,6 +43,7 @@ export default { return value.match(/(contribution|comment|organization|user)/) }, }, + callbacks: { type: Object, required: true }, }, computed: { routes() { @@ -49,7 +51,7 @@ export default { if (this.isOwner && this.resourceType === 'contribution') { routes.push({ - name: this.$t(`contribution.edit`), + name: this.$t(`post.menu.edit`), path: this.$router.resolve({ name: 'post-edit-id', params: { @@ -59,21 +61,29 @@ export default { icon: 'edit', }) routes.push({ - name: this.$t(`post.delete.title`), + name: this.$t(`post.menu.delete`), callback: () => { this.openModal('delete') }, icon: 'trash', }) } + if (this.isOwner && this.resourceType === 'comment') { + // routes.push({ + // name: this.$t(`comment.menu.edit`), + // callback: () => { + // /* eslint-disable-next-line no-console */ + // console.log('EDIT COMMENT') + // }, + // icon: 'edit' + // }) routes.push({ - name: this.$t(`comment.edit`), + name: this.$t(`comment.menu.delete`), callback: () => { - /* eslint-disable-next-line no-console */ - console.log('EDIT COMMENT') + this.openModal('delete') }, - icon: 'edit', + icon: 'trash', }) } @@ -125,6 +135,7 @@ export default { data: { type: this.resourceType, resource: this.resource, + callbacks: this.callbacks, }, }) }, diff --git a/webapp/components/Modal.spec.js b/webapp/components/Modal.spec.js index 52d13c4a0..0e2158e96 100644 --- a/webapp/components/Modal.spec.js +++ b/webapp/components/Modal.spec.js @@ -1,5 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils' import Modal from './Modal.vue' +import DeleteModal from './Modal/DeleteModal.vue' import DisableModal from './Modal/DisableModal.vue' import ReportModal from './Modal/ReportModal.vue' import Vuex from 'vuex' @@ -29,7 +30,11 @@ describe('Modal.vue', () => { 'modal/SET_OPEN': mutations.SET_OPEN, }, }) - return mountMethod(Modal, { store, mocks, localVue }) + return mountMethod(Modal, { + store, + mocks, + localVue, + }) } } @@ -55,6 +60,7 @@ describe('Modal.vue', () => { it('initially empty', () => { wrapper = Wrapper() + expect(wrapper.contains(DeleteModal)).toBe(false) expect(wrapper.contains(DisableModal)).toBe(false) expect(wrapper.contains(ReportModal)).toBe(false) }) @@ -69,6 +75,10 @@ describe('Modal.vue', () => { id: 'c456', title: 'some title', }, + callbacks: { + confirm: null, + cancel: null, + }, }, } wrapper = Wrapper() @@ -83,6 +93,10 @@ describe('Modal.vue', () => { type: 'contribution', name: 'some title', id: 'c456', + callbacks: { + confirm: null, + cancel: null, + }, }) }) @@ -97,23 +111,49 @@ describe('Modal.vue', () => { it('passes author name to disable modal', () => { state.data = { type: 'comment', - resource: { id: 'c456', author: { name: 'Author name' } }, + resource: { + id: 'c456', + author: { + name: 'Author name', + }, + }, + callbacks: { + confirm: null, + cancel: null, + }, } wrapper = Wrapper() expect(wrapper.find(DisableModal).props()).toEqual({ type: 'comment', name: 'Author name', id: 'c456', + callbacks: { + confirm: null, + cancel: null, + }, }) }) it('does not crash if author is undefined', () => { - state.data = { type: 'comment', resource: { id: 'c456' } } + state.data = { + type: 'comment', + resource: { + id: 'c456', + }, + callbacks: { + confirm: null, + cancel: null, + }, + } wrapper = Wrapper() expect(wrapper.find(DisableModal).props()).toEqual({ type: 'comment', name: '', id: 'c456', + callbacks: { + confirm: null, + cancel: null, + }, }) }) }) diff --git a/webapp/components/Modal.vue b/webapp/components/Modal.vue index 88b89d407..efe1b8ab6 100644 --- a/webapp/components/Modal.vue +++ b/webapp/components/Modal.vue @@ -1,10 +1,12 @@