diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index d24bbcc9f..a803adb64 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -163,6 +163,7 @@ const permissions = shield( DeletePost: isAuthor, report: isAuthenticated, CreateSocialMedia: isAuthenticated, + UpdateSocialMedia: isAuthenticated, DeleteSocialMedia: isAuthenticated, // AddBadgeRewarded: isAdmin, // RemoveBadgeRewarded: isAdmin, diff --git a/backend/src/schema/resolvers/socialMedia.js b/backend/src/schema/resolvers/socialMedia.js index 0bc03ea74..d28bc3fe1 100644 --- a/backend/src/schema/resolvers/socialMedia.js +++ b/backend/src/schema/resolvers/socialMedia.js @@ -3,14 +3,11 @@ import { neo4jgraphql } from 'neo4j-graphql-js' export default { Mutation: { CreateSocialMedia: async (object, params, context, resolveInfo) => { - /** - * TODO?: Creates double Nodes! - */ const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) const session = context.driver.session() await session.run( `MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId}) - MERGE (socialMedia)<-[:OWNED]-(owner) + MERGE (socialMedia)<-[:OWNED]-(owner) RETURN owner`, { userId: context.user.id, @@ -26,5 +23,21 @@ export default { return socialMedia }, + UpdateSocialMedia: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + await session.run( + `MATCH (owner: User { id: $userId })-[:OWNED]->(socialMedia: SocialMedia { id: $socialMediaId }) + SET socialMedia.url = $socialMediaUrl + RETURN owner`, + { + userId: context.user.id, + socialMediaId: params.id, + socialMediaUrl: params.url, + }, + ) + session.close() + + return params + }, }, } diff --git a/backend/src/schema/resolvers/socialMedia.spec.js b/backend/src/schema/resolvers/socialMedia.spec.js index 7ec35a08f..af17dbb43 100644 --- a/backend/src/schema/resolvers/socialMedia.spec.js +++ b/backend/src/schema/resolvers/socialMedia.spec.js @@ -5,9 +5,26 @@ import { host, login, gql } from '../../jest/helpers' const factory = Factory() describe('SocialMedia', () => { - let client - let headers - const mutationC = gql` + let client, headers, variables, mutation + + const ownerParams = { + email: 'owner@example.com', + password: '1234', + id: '1234', + name: 'Pippi Langstrumpf', + } + + const userParams = { + email: 'someuser@example.com', + password: 'abcd', + id: 'abcd', + name: 'Kalle Blomqvist', + } + + const url = 'https://twitter.com/pippi-langstrumpf' + const newUrl = 'https://twitter.com/bullerby' + + const createSocialMediaMutation = gql` mutation($url: String!) { CreateSocialMedia(url: $url) { id @@ -15,7 +32,15 @@ describe('SocialMedia', () => { } } ` - const mutationD = gql` + const updateSocialMediaMutation = gql` + mutation($id: ID!, $url: String!) { + UpdateSocialMedia(id: $id, url: $url) { + id + url + } + } + ` + const deleteSocialMediaMutation = gql` mutation($id: ID!) { DeleteSocialMedia(id: $id) { id @@ -24,92 +49,139 @@ describe('SocialMedia', () => { } ` beforeEach(async () => { - await factory.create('User', { - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', - id: 'acb2d923-f3af-479e-9f00-61b12e864666', - name: 'Matilde Hermiston', - slug: 'matilde-hermiston', - role: 'user', - email: 'test@example.org', - password: '1234', - }) + await factory.create('User', userParams) + await factory.create('User', ownerParams) }) afterEach(async () => { await factory.cleanDatabase() }) - describe('unauthenticated', () => { - it('throws authorization error', async () => { - client = new GraphQLClient(host) - const variables = { - url: 'http://nsosp.org', - } - await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised') + describe('create social media', () => { + beforeEach(() => { + variables = { url } + mutation = createSocialMediaMutation + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + headers = await login(userParams) + client = new GraphQLClient(host, { headers }) + }) + + it('creates social media with correct URL', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual( + expect.objectContaining({ + CreateSocialMedia: { + id: expect.any(String), + url: url, + }, + }), + ) + }) + + it('rejects empty string', async () => { + variables = { url: '' } + + await expect(client.request(mutation, variables)).rejects.toThrow( + '"url" is not allowed to be empty', + ) + }) + + it('rejects invalid URLs', async () => { + variables = { url: 'not-a-url' } + + await expect(client.request(createSocialMediaMutation, variables)).rejects.toThrow( + '"url" must be a valid uri', + ) + }) }) }) - describe('authenticated', () => { + describe('update social media', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) - }) + headers = await login(ownerParams) + client = new GraphQLClient(host, { headers }) - it('creates social media with correct URL', async () => { - const variables = { - url: 'http://nsosp.org', - } - await expect(client.request(mutationC, variables)).resolves.toEqual( - expect.objectContaining({ - CreateSocialMedia: { - id: expect.any(String), - url: 'http://nsosp.org', - }, - }), - ) - }) - - it('deletes social media', async () => { - const creationVariables = { - url: 'http://nsosp.org', - } - const { CreateSocialMedia } = await client.request(mutationC, creationVariables) + const { CreateSocialMedia } = await client.request(createSocialMediaMutation, { url }) const { id } = CreateSocialMedia - const deletionVariables = { - id, - } - const expected = { - DeleteSocialMedia: { - id: id, - url: 'http://nsosp.org', - }, - } - await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected) + variables = { url: newUrl, id } + mutation = updateSocialMediaMutation }) - it('rejects empty string', async () => { - const variables = { - url: '', - } - await expect(client.request(mutationC, variables)).rejects.toThrow( - '"url" is not allowed to be empty', - ) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) }) - it('validates URLs', async () => { - const variables = { - url: 'not-a-url', - } + describe('authenticated as other user', () => { + // TODO: make sure it throws an authorization error + }) - await expect(client.request(mutationC, variables)).rejects.toThrow( - '"url" must be a valid uri', - ) + describe('authenticated as owner', () => { + it('updates social media', async () => { + const expected = { UpdateSocialMedia: { ...variables } } + + await expect(client.request(mutation, variables)).resolves.toEqual( + expect.objectContaining(expected), + ) + }) + + describe('given a non-existent id', () => { + // TODO: make sure it throws an error + }) + }) + }) + + describe('delete social media', () => { + beforeEach(async () => { + headers = await login(ownerParams) + client = new GraphQLClient(host, { headers }) + + const { CreateSocialMedia } = await client.request(createSocialMediaMutation, { url }) + const { id } = CreateSocialMedia + + variables = { id } + mutation = deleteSocialMediaMutation + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated as other user', () => { + // TODO: make sure it throws an authorization error + }) + + describe('authenticated as owner', () => { + beforeEach(async () => { + headers = await login(ownerParams) + client = new GraphQLClient(host, { headers }) + }) + + it('deletes social media', async () => { + const expected = { + DeleteSocialMedia: { + id: variables.id, + url: url, + }, + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) }) }) }) diff --git a/cypress/integration/common/settings.js b/cypress/integration/common/settings.js index 664ffcff8..b32924f6a 100644 --- a/cypress/integration/common/settings.js +++ b/cypress/integration/common/settings.js @@ -79,7 +79,7 @@ Then('I should be on the {string} page', page => { }) When('I add a social media link', () => { - cy.get("input[name='social-media']") + cy.get('input#addSocialMedia') .type('https://freeradical.zone/peter-pan') .get('button') .contains('Add link') @@ -98,7 +98,7 @@ Then('the new social media link shows up on the page', () => { Given('I have added a social media link', () => { cy.openPage('/settings/my-social-media') - .get("input[name='social-media']") + .get('input#addSocialMedia') .type('https://freeradical.zone/peter-pan') .get('button') .contains('Add link') @@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => { cy.get('.iziToast-message') .should('contain', 'Deleted social media') }) + +When('I start editing a social media link', () => { + cy.get("a[name='edit']") + .click() +}) + +Then('I can cancel editing', () => { + cy.get('button#cancel') + .click() + .get('input#editSocialMedia') + .should('have.length', 0) +}) + +When('I edit and save the link', () => { + cy.get('input#editSocialMedia') + .clear() + .type('https://freeradical.zone/tinkerbell') + .get('button') + .contains('Save') + .click() +}) + +Then('the new url is displayed', () => { + cy.get("a[href='https://freeradical.zone/tinkerbell']") + .should('have.length', 1) +}) + +Then('the old url is not displayed', () => { + cy.get("a[href='https://freeradical.zone/peter-pan']") + .should('have.length', 0) +}) diff --git a/cypress/integration/user_profile/SocialMedia.feature b/cypress/integration/user_profile/SocialMedia.feature index d21167c6b..e6090a0a4 100644 --- a/cypress/integration/user_profile/SocialMedia.feature +++ b/cypress/integration/user_profile/SocialMedia.feature @@ -15,7 +15,7 @@ Feature: List Social Media Accounts Then it gets saved successfully And the new social media link shows up on the page - Scenario: Other user's viewing my Social Media + Scenario: Other users viewing my Social Media Given I have added a social media link When people visit my profile page Then they should be able to see my social media links @@ -27,3 +27,16 @@ Feature: List Social Media Accounts Given I have added a social media link When I delete a social media link Then it gets deleted successfully + + Scenario: Editing Social Media + Given I am on the "settings" page + And I click on the "Social media" link + Then I should be on the "/settings/my-social-media" page + Given I have added a social media link + When I start editing a social media link + Then I can cancel editing + When I start editing a social media link + And I edit and save the link + Then it gets saved successfully + And the new url is displayed + But the old url is not displayed diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 467a9a2ee..58dfa187f 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -168,7 +168,8 @@ }, "social-media": { "name": "Soziale Medien", - "placeholder": "Füge eine Social-Media URL hinzu", + "placeholder": "Deine Social-Media URL", + "requireUnique": "Dieser Link existiert bereits", "submit": "Link hinzufügen", "successAdd": "Social-Media hinzugefügt. Profil aktualisiert!", "successDelete": "Social-Media gelöscht. Profil aktualisiert!" @@ -286,6 +287,7 @@ "reportContent": "Melden", "validations": { "email": "muss eine gültige E-Mail Adresse sein", + "url": "muss eine gültige URL sein", "verification-code": "muss genau 6 Buchstaben lang sein" } }, diff --git a/webapp/locales/en.json b/webapp/locales/en.json index f73382b92..3c76f6010 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -169,7 +169,8 @@ }, "social-media": { "name": "Social media", - "placeholder": "Add social media url", + "placeholder": "Your social media url", + "requireUnique": "You added this url already", "submit": "Add link", "successAdd": "Added social media. Updated user profile!", "successDelete": "Deleted social media. Updated user profile!" @@ -288,6 +289,7 @@ "reportContent": "Report", "validations": { "email": "must be a valid email address", + "url": "must be a valid URL", "verification-code": "must be 6 characters long" } }, diff --git a/webapp/package.json b/webapp/package.json index 15b5f62e2..96b26b6b7 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -101,6 +101,7 @@ "eslint-plugin-promise": "~4.2.1", "eslint-plugin-standard": "~4.0.0", "eslint-plugin-vue": "~5.2.3", + "flush-promises": "^1.0.2", "fuse.js": "^3.4.5", "jest": "~24.8.0", "node-sass": "~4.12.0", diff --git a/webapp/pages/settings/my-social-media.spec.js b/webapp/pages/settings/my-social-media.spec.js index 55ba27bb8..046edf152 100644 --- a/webapp/pages/settings/my-social-media.spec.js +++ b/webapp/pages/settings/my-social-media.spec.js @@ -1,4 +1,5 @@ import { mount, createLocalVue } from '@vue/test-utils' +import flushPromises from 'flush-promises' import MySocialMedia from './my-social-media.vue' import Vuex from 'vuex' import Styleguide from '@human-connection/styleguide' @@ -12,23 +13,17 @@ localVue.use(Filters) describe('my-social-media.vue', () => { let wrapper - let store let mocks let getters - let input - let submitBtn const socialMediaUrl = 'https://freeradical.zone/@mattwr18' + const newSocialMediaUrl = 'https://twitter.com/mattwr18' + const faviconUrl = 'https://freeradical.zone/favicon.ico' beforeEach(() => { mocks = { $t: jest.fn(), $apollo: { - mutate: jest - .fn() - .mockRejectedValue({ message: 'Ouch!' }) - .mockResolvedValueOnce({ - data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } }, - }), + mutate: jest.fn(), }, $toast: { error: jest.fn(), @@ -43,79 +38,161 @@ describe('my-social-media.vue', () => { }) describe('mount', () => { + let form, input, submitButton const Wrapper = () => { - store = new Vuex.Store({ + const store = new Vuex.Store({ getters, }) return mount(MySocialMedia, { store, mocks, localVue }) } - it('renders', () => { - wrapper = Wrapper() - expect(wrapper.contains('div')).toBe(true) - }) - - describe('given currentUser has a social media account linked', () => { + describe('adding social media link', () => { beforeEach(() => { - getters = { - 'auth/user': () => { - return { - socialMedia: [{ id: 's1', url: socialMediaUrl }], - } - }, - } - }) - - it("displays a link to the currentUser's social media", () => { wrapper = Wrapper() - const socialMediaLink = wrapper.find('a').attributes().href - expect(socialMediaLink).toBe(socialMediaUrl) + form = wrapper.find('form') + input = wrapper.find('input#addSocialMedia') + submitButton = wrapper.find('button') }) - beforeEach(() => { - mocks = { - $t: jest.fn(), - $apollo: { - mutate: jest - .fn() - .mockRejectedValue({ message: 'Ouch!' }) - .mockResolvedValueOnce({ - data: { DeleteSocialMeda: { id: 's1', url: socialMediaUrl } }, - }), - }, - $toast: { - error: jest.fn(), - success: jest.fn(), - }, - } - getters = { - 'auth/user': () => { - return { - socialMedia: [{ id: 's1', url: socialMediaUrl }], - } - }, - } + it('requires the link to be a valid url', () => { + input.setValue('some value') + form.trigger('submit') + + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() }) - it('displays a trash sympol after a social media and allows the user to delete it', () => { - wrapper = Wrapper() - const deleteSelector = wrapper.find({ name: 'delete' }) - expect(deleteSelector).toEqual({ selector: 'Component' }) - const icon = wrapper.find({ name: 'trash' }) - icon.trigger('click') - expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) + it('displays an error message when not saved successfully', async () => { + mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' }) + input.setValue(newSocialMediaUrl) + form.trigger('submit') + + await flushPromises() + + expect(mocks.$toast.error).toHaveBeenCalledTimes(1) + }) + + describe('success', () => { + beforeEach(() => { + mocks.$apollo.mutate.mockResolvedValue({ + data: { CreateSocialMedia: { id: 's2', url: newSocialMediaUrl } }, + }) + input.setValue(newSocialMediaUrl) + form.trigger('submit') + }) + + it('sends the new url to the backend', () => { + const expected = expect.objectContaining({ + variables: { url: newSocialMediaUrl }, + }) + + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('displays a success message', async () => { + await flushPromises() + + expect(mocks.$toast.success).toHaveBeenCalledTimes(1) + }) + + it('clears the form', async () => { + await flushPromises() + + expect(input.value).toBe(undefined) + expect(submitButton.vm.$attrs.disabled).toBe(true) + }) }) }) - describe('currentUser does not have a social media account linked', () => { - it('allows a user to add a social media link', () => { + describe('given existing social media links', () => { + beforeEach(() => { + getters = { + 'auth/user': () => ({ + socialMedia: [{ id: 's1', url: socialMediaUrl }], + }), + } + wrapper = Wrapper() - input = wrapper.find({ name: 'social-media' }) - input.element.value = socialMediaUrl - input.trigger('input') - submitBtn = wrapper.find('.ds-button') - submitBtn.trigger('click') - expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) + form = wrapper.find('form') + }) + + describe('for each link it', () => { + it('displays the favicon', () => { + expect(wrapper.find(`img[src="${faviconUrl}"]`).exists()).toBe(true) + }) + + it('displays the url', () => { + expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true) + }) + + it('displays the edit button', () => { + expect(wrapper.find('a[name="edit"]').exists()).toBe(true) + }) + + it('displays the delete button', () => { + expect(wrapper.find('a[name="delete"]').exists()).toBe(true) + }) + }) + + it('does not accept a duplicate url', () => { + input = wrapper.find('input#addSocialMedia') + + input.setValue(socialMediaUrl) + form.trigger('submit') + + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + describe('editing social media link', () => { + beforeEach(() => { + const editButton = wrapper.find('a[name="edit"]') + editButton.trigger('click') + input = wrapper.find('input#editSocialMedia') + }) + + it('disables adding new links while editing', () => { + const addInput = wrapper.find('input#addSocialMedia') + + expect(addInput.exists()).toBe(false) + }) + + it('sends the new url to the backend', () => { + const expected = expect.objectContaining({ + variables: { id: 's1', url: newSocialMediaUrl }, + }) + input.setValue(newSocialMediaUrl) + form.trigger('submit') + + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('allows the user to cancel editing', () => { + const cancelButton = wrapper.find('button#cancel') + cancelButton.trigger('click') + + expect(wrapper.find('input#editSocialMedia').exists()).toBe(false) + }) + }) + + describe('deleting social media link', () => { + beforeEach(() => { + const deleteButton = wrapper.find('a[name="delete"]') + deleteButton.trigger('click') + }) + + it('sends the link id to the backend', () => { + const expected = expect.objectContaining({ + variables: { id: 's1' }, + }) + + expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('displays a success message', async () => { + await flushPromises() + + expect(mocks.$toast.success).toHaveBeenCalledTimes(1) + }) }) }) }) diff --git a/webapp/pages/settings/my-social-media.vue b/webapp/pages/settings/my-social-media.vue index 948e77407..cf8537aa5 100644 --- a/webapp/pages/settings/my-social-media.vue +++ b/webapp/pages/settings/my-social-media.vue @@ -1,49 +1,90 @@ + diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 0a34a876e..308b710b3 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4911,6 +4911,11 @@ flatten@^1.0.2: resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" integrity sha1-2uRqnXj74lKSJYzB54CkHZXAN4I= +flush-promises@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced" + integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA== + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"