diff --git a/backend/package.json b/backend/package.json index c30a9633a..3cc4936c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,7 +48,7 @@ "apollo-client": "~2.6.3", "apollo-link-context": "~1.0.18", "apollo-link-http": "~1.5.15", - "apollo-server": "~2.7.2", + "apollo-server": "~2.8.0", "apollo-server-express": "^2.7.2", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.3", @@ -62,7 +62,7 @@ "graphql": "~14.4.2", "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", - "graphql-middleware": "~3.0.2", + "graphql-middleware": "~3.0.3", "graphql-shield": "~6.0.4", "graphql-tag": "~2.10.1", "helmet": "~3.20.0", @@ -106,7 +106,7 @@ "@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/preset-env": "~7.5.5", "@babel/register": "~7.5.5", - "apollo-server-testing": "~2.7.2", + "apollo-server-testing": "~2.8.0", "babel-core": "~7.0.0-0", "babel-eslint": "~10.0.2", "babel-jest": "~24.8.0", @@ -127,4 +127,4 @@ "prettier": "~1.18.2", "supertest": "~4.0.2" } -} +} \ No newline at end of file diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 29a171f29..9e8f5dacb 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -1,4 +1,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' +import { neode } from '../bootstrap/neo4j' + +const instance = neode() /* * TODO: implement @@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' const isAuthenticated = rule({ cache: 'contextual', })(async (_parent, _args, ctx, _info) => { - return ctx.user !== null + return ctx.user != null }) const isModerator = rule()(async (parent, args, { user }, info) => { @@ -36,6 +39,14 @@ const isMyOwn = rule({ return context.user.id === parent.id }) +const isMySocialMedia = rule({ + cache: 'no_cache', +})(async (_, args, { user }) => { + let socialMedia = await instance.find('SocialMedia', args.id) + socialMedia = await socialMedia.toJson() + return socialMedia.ownedBy.node.id === user.id +}) + const belongsToMe = rule({ cache: 'no_cache', })(async (_, args, context) => { @@ -170,7 +181,8 @@ const permissions = shield( DeletePost: isAuthor, report: isAuthenticated, CreateSocialMedia: isAuthenticated, - DeleteSocialMedia: isAuthenticated, + UpdateSocialMedia: isMySocialMedia, + DeleteSocialMedia: isMySocialMedia, // AddBadgeRewarded: isAdmin, // RemoveBadgeRewarded: isAdmin, reward: isAdmin, diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index 319a9a6c0..2d354ad2b 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -1,23 +1,8 @@ import { UserInputError } from 'apollo-server' -import Joi from '@hapi/joi' const COMMENT_MIN_LENGTH = 1 const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' -const validate = schema => { - return async (resolve, root, args, context, info) => { - const validation = schema.validate(args) - if (validation.error) throw new UserInputError(validation.error) - return resolve(root, args, context, info) - } -} - -const socialMediaSchema = Joi.object().keys({ - url: Joi.string() - .uri() - .required(), -}) - const validateCommentCreation = async (resolve, root, args, context, info) => { const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const { postId } = args @@ -57,7 +42,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => { export default { Mutation: { - CreateSocialMedia: validate(socialMediaSchema), CreateComment: validateCommentCreation, UpdateComment: validateUpdateComment, }, diff --git a/backend/src/models/SocialMedia.js b/backend/src/models/SocialMedia.js new file mode 100644 index 000000000..d41391ec1 --- /dev/null +++ b/backend/src/models/SocialMedia.js @@ -0,0 +1,15 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + url: { type: 'string', uri: true, required: true }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + ownedBy: { + type: 'relationship', + relationship: 'OWNED_BY', + target: 'User', + direction: 'in', + eager: true, + cascade: 'detach', + }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 09d1dbbeb..b468dedf2 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -5,4 +5,5 @@ export default { User: require('./User.js'), InvitationCode: require('./InvitationCode.js'), EmailAddress: require('./EmailAddress.js'), + SocialMedia: require('./SocialMedia.js'), } diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index 0f724d9b5..2b24178e1 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -19,6 +19,7 @@ export default applyScalars( 'Notfication', 'Statistics', 'LoggedInUser', + 'SocialMedia', ], // add 'User' here as soon as possible }, @@ -30,6 +31,7 @@ export default applyScalars( 'Notfication', 'Statistics', 'LoggedInUser', + 'SocialMedia', ], // add 'User' here as soon as possible }, diff --git a/backend/src/schema/resolvers/embeds.js b/backend/src/schema/resolvers/embeds.js index ba27f77b2..016352950 100644 --- a/backend/src/schema/resolvers/embeds.js +++ b/backend/src/schema/resolvers/embeds.js @@ -1,5 +1,5 @@ import scrape from './embeds/scraper.js' -import { undefinedToNull } from '../helpers' +import { undefinedToNullResolver } from './helpers/Resolver' export default { Query: { @@ -8,7 +8,7 @@ export default { }, }, Embed: { - ...undefinedToNull([ + ...undefinedToNullResolver([ 'type', 'title', 'author', diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js new file mode 100644 index 000000000..655cf08a0 --- /dev/null +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -0,0 +1,75 @@ +import { neode } from '../../../bootstrap/neo4j' + +export const undefinedToNullResolver = list => { + const resolvers = {} + list.forEach(key => { + resolvers[key] = async (parent, params, context, resolveInfo) => { + return typeof parent[key] === 'undefined' ? null : parent[key] + } + }) + return resolvers +} + +export default function Resolver(type, options = {}) { + const instance = neode() + const { + idAttribute = 'id', + undefinedToNull = [], + count = {}, + hasOne = {}, + hasMany = {}, + } = options + const _hasResolver = (resolvers, { key, connection }, { returnType }) => { + return async (parent, params, context, resolveInfo) => { + if (typeof parent[key] !== 'undefined') return parent[key] + const id = parent[idAttribute] + const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related` + const result = await instance.cypher(statement, { id }) + let response = result.records.map(r => r.get('related').properties) + if (returnType === 'object') response = response[0] || null + return response + } + } + + const countResolver = obj => { + const resolvers = {} + for (const [key, connection] of Object.entries(obj)) { + resolvers[key] = async (parent, params, context, resolveInfo) => { + if (typeof parent[key] !== 'undefined') return parent[key] + const id = parent[idAttribute] + const statement = ` + MATCH(u:${type} {${idAttribute}: {id}})${connection} + WHERE NOT related.deleted = true AND NOT related.disabled = true + RETURN COUNT(DISTINCT(related)) as count + ` + const result = await instance.cypher(statement, { id }) + const [response] = result.records.map(r => r.get('count').toNumber()) + return response + } + } + return resolvers + } + + const hasManyResolver = obj => { + const resolvers = {} + for (const [key, connection] of Object.entries(obj)) { + resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' }) + } + return resolvers + } + + const hasOneResolver = obj => { + const resolvers = {} + for (const [key, connection] of Object.entries(obj)) { + resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' }) + } + return resolvers + } + const result = { + ...undefinedToNullResolver(undefinedToNull), + ...countResolver(count), + ...hasOneResolver(hasOne), + ...hasManyResolver(hasMany), + } + return result +} diff --git a/backend/src/schema/resolvers/socialMedia.js b/backend/src/schema/resolvers/socialMedia.js index 0bc03ea74..d67a41636 100644 --- a/backend/src/schema/resolvers/socialMedia.js +++ b/backend/src/schema/resolvers/socialMedia.js @@ -1,30 +1,38 @@ -import { neo4jgraphql } from 'neo4j-graphql-js' +import { neode } from '../../bootstrap/neo4j' +import Resolver from './helpers/Resolver' + +const instance = neode() 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) - RETURN owner`, - { - userId: context.user.id, - socialMediaId: socialMedia.id, - }, - ) - session.close() + const [user, socialMedia] = await Promise.all([ + instance.find('User', context.user.id), + instance.create('SocialMedia', params), + ]) + await socialMedia.relateTo(user, 'ownedBy') + const response = await socialMedia.toJson() - return socialMedia + return response }, - DeleteSocialMedia: async (object, params, context, resolveInfo) => { - const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) + UpdateSocialMedia: async (object, params, context, resolveInfo) => { + const socialMedia = await instance.find('SocialMedia', params.id) + await socialMedia.update({ url: params.url }) + const response = await socialMedia.toJson() - return socialMedia + return response + }, + DeleteSocialMedia: async (object, { id }, context, resolveInfo) => { + const socialMedia = await instance.find('SocialMedia', id) + if (!socialMedia) return null + await socialMedia.delete() + return socialMedia.toJson() }, }, + SocialMedia: Resolver('SocialMedia', { + idAttribute: 'url', + hasOne: { + ownedBy: '<-[:OWNED_BY]-(related:User)', + }, + }), } diff --git a/backend/src/schema/resolvers/socialMedia.spec.js b/backend/src/schema/resolvers/socialMedia.spec.js index 7ec35a08f..1bbcb8d5b 100644 --- a/backend/src/schema/resolvers/socialMedia.spec.js +++ b/backend/src/schema/resolvers/socialMedia.spec.js @@ -1,115 +1,274 @@ -import { GraphQLClient } from 'graphql-request' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../../server' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' +import { gql } from '../../jest/helpers' +import { neode, getDriver } from '../../bootstrap/neo4j' +const driver = getDriver() const factory = Factory() +const instance = neode() describe('SocialMedia', () => { - let client - let headers - const mutationC = gql` - mutation($url: String!) { - CreateSocialMedia(url: $url) { - id - url - } - } - ` - const mutationD = gql` - mutation($id: ID!) { - DeleteSocialMedia(id: $id) { - id - url - } - } - ` + let socialMediaAction, someUser, ownerNode, owner + + const ownerParams = { + email: 'pippi@example.com', + password: '1234', + name: 'Pippi Langstrumpf', + } + + const userParams = { + email: 'kalle@example.com', + password: 'abcd', + name: 'Kalle Blomqvist', + } + + const url = 'https://twitter.com/pippi-langstrumpf' + const newUrl = 'https://twitter.com/bullerby' + + const setUpSocialMedia = async () => { + const socialMediaNode = await instance.create('SocialMedia', { url }) + await socialMediaNode.relateTo(ownerNode, 'ownedBy') + return socialMediaNode.toJson() + } + 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', - }) + const someUserNode = await instance.create('User', userParams) + someUser = await someUserNode.toJson() + ownerNode = await instance.create('User', ownerParams) + owner = await ownerNode.toJson() + + socialMediaAction = async (user, mutation, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + driver, + } + }, + }) + const { mutate } = createTestClient(server) + + return mutate({ + mutation, + variables, + }) + } }) 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', () => { + let mutation, variables + + beforeEach(() => { + mutation = gql` + mutation($url: String!) { + CreateSocialMedia(url: $url) { + id + url + } + } + ` + variables = { url } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const user = null + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + let user + + beforeEach(() => { + user = owner + }) + + it('creates social media with the given url', async () => { + await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual( + expect.objectContaining({ + data: { + CreateSocialMedia: { + id: expect.any(String), + url, + }, + }, + }), + ) + }) + + it('rejects an empty string as url', async () => { + variables = { url: '' } + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0].message).toEqual( + expect.stringContaining('"url" is not allowed to be empty'), + ) + }) + + it('rejects invalid urls', async () => { + variables = { url: 'not-a-url' } + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0].message).toEqual( + expect.stringContaining('"url" must be a valid uri'), + ) + }) + }) + + describe('ownedBy', () => { + beforeEach(() => { + mutation = gql` + mutation($url: String!) { + CreateSocialMedia(url: $url) { + url + ownedBy { + name + } + } + } + ` + }) + + it('resolves', async () => { + const user = someUser + await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual( + expect.objectContaining({ + data: { + CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } }, + }, + }), + ) + }) }) }) - describe('authenticated', () => { + describe('update social media', () => { + let mutation, variables + beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, + const socialMedia = await setUpSocialMedia() + + mutation = gql` + mutation($id: ID!, $url: String!) { + UpdateSocialMedia(id: $id, url: $url) { + id + url + } + } + ` + variables = { url: newUrl, id: socialMedia.id } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const user = null + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) - 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', + describe('authenticated as other user', () => { + it('throws authorization error', async () => { + const user = someUser + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated as owner', () => { + let user + + beforeEach(() => { + user = owner + }) + + it('updates social media with the given id', async () => { + const expected = { + data: { + UpdateSocialMedia: { ...variables }, }, - }), - ) + } + + await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual( + expect.objectContaining(expected), + ) + }) + + it('does not update if the the given id does not exist', async () => { + variables.id = 'some-id' + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + }) + + describe('delete social media', () => { + let mutation, variables + + beforeEach(async () => { + const socialMedia = await setUpSocialMedia() + + mutation = gql` + mutation($id: ID!) { + DeleteSocialMedia(id: $id) { + id + url + } + } + ` + variables = { url: newUrl, id: socialMedia.id } }) - it('deletes social media', async () => { - const creationVariables = { - url: 'http://nsosp.org', - } - const { CreateSocialMedia } = await client.request(mutationC, creationVariables) - const { id } = CreateSocialMedia + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const user = null + const result = await socialMediaAction(user, mutation, variables) - const deletionVariables = { - id, - } - const expected = { - DeleteSocialMedia: { - id: id, - url: 'http://nsosp.org', - }, - } - await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) }) - it('rejects empty string', async () => { - const variables = { - url: '', - } - await expect(client.request(mutationC, variables)).rejects.toThrow( - '"url" is not allowed to be empty', - ) + describe('authenticated as other user', () => { + it('throws authorization error', async () => { + const user = someUser + const result = await socialMediaAction(user, mutation, variables) + + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) }) - it('validates URLs', async () => { - const variables = { - url: 'not-a-url', - } + describe('authenticated as owner', () => { + let user - await expect(client.request(mutationC, variables)).rejects.toThrow( - '"url" must be a valid uri', - ) + beforeEach(async () => { + user = owner + }) + + it('deletes social media with the given id', async () => { + const expected = { + data: { + DeleteSocialMedia: { + id: variables.id, + url, + }, + }, + } + + await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual( + expect.objectContaining(expected), + ) + }) }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 610f84ae1..c0826c6c8 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -2,57 +2,10 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import fileUpload from './fileUpload' import { neode } from '../../bootstrap/neo4j' import { UserInputError } from 'apollo-server' -import { undefinedToNull } from '../helpers' +import Resolver from './helpers/Resolver' const instance = neode() -const _has = (resolvers, { key, connection }, { returnType }) => { - return async (parent, params, context, resolveInfo) => { - if (typeof parent[key] !== 'undefined') return parent[key] - const { id } = parent - const statement = `MATCH(u:User {id: {id}})${connection} RETURN related` - const result = await instance.cypher(statement, { id }) - let response = result.records.map(r => r.get('related').properties) - if (returnType === 'object') response = response[0] || null - return response - } -} - -const count = obj => { - const resolvers = {} - for (const [key, connection] of Object.entries(obj)) { - resolvers[key] = async (parent, params, context, resolveInfo) => { - if (typeof parent[key] !== 'undefined') return parent[key] - const { id } = parent - const statement = ` - MATCH(u:User {id: {id}})${connection} - WHERE NOT related.deleted = true AND NOT related.disabled = true - RETURN COUNT(DISTINCT(related)) as count - ` - const result = await instance.cypher(statement, { id }) - const [response] = result.records.map(r => r.get('count').toNumber()) - return response - } - } - return resolvers -} - -export const hasMany = obj => { - const resolvers = {} - for (const [key, connection] of Object.entries(obj)) { - resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' }) - } - return resolvers -} - -export const hasOne = obj => { - const resolvers = {} - for (const [key, connection] of Object.entries(obj)) { - resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' }) - } - return resolvers -} - export default { Query: { User: async (object, args, context, resolveInfo) => { @@ -110,42 +63,44 @@ export default { let [{ email }] = result.records.map(r => r.get('e').properties) return email }, - ...undefinedToNull([ - 'actorId', - 'avatar', - 'coverImg', - 'deleted', - 'disabled', - 'locationName', - 'about', - ]), - ...count({ - contributionsCount: '-[:WROTE]->(related:Post)', - friendsCount: '<-[:FRIENDS]->(related:User)', - followingCount: '-[:FOLLOWS]->(related:User)', - followedByCount: '<-[:FOLLOWS]-(related:User)', - commentsCount: '-[:WROTE]->(r:Comment)', - commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)', - shoutedCount: '-[:SHOUTED]->(related:Post)', - badgesCount: '<-[:REWARDED]-(related:Badge)', - }), - ...hasOne({ - invitedBy: '<-[:INVITED]-(related:User)', - disabledBy: '<-[:DISABLED]-(related:User)', - }), - ...hasMany({ - followedBy: '<-[:FOLLOWS]-(related:User)', - following: '-[:FOLLOWS]->(related:User)', - friends: '-[:FRIENDS]-(related:User)', - blacklisted: '-[:BLACKLISTED]->(related:User)', - socialMedia: '-[:OWNED]->(related:SocialMedia)', - contributions: '-[:WROTE]->(related:Post)', - comments: '-[:WROTE]->(related:Comment)', - shouted: '-[:SHOUTED]->(related:Post)', - organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)', - organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)', - categories: '-[:CATEGORIZED]->(related:Category)', - badges: '<-[:REWARDED]-(related:Badge)', + ...Resolver('User', { + undefinedToNull: [ + 'actorId', + 'avatar', + 'coverImg', + 'deleted', + 'disabled', + 'locationName', + 'about', + ], + count: { + contributionsCount: '-[:WROTE]->(related:Post)', + friendsCount: '<-[:FRIENDS]->(related:User)', + followingCount: '-[:FOLLOWS]->(related:User)', + followedByCount: '<-[:FOLLOWS]-(related:User)', + commentsCount: '-[:WROTE]->(r:Comment)', + commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)', + shoutedCount: '-[:SHOUTED]->(related:Post)', + badgesCount: '<-[:REWARDED]-(related:Badge)', + }, + hasOne: { + invitedBy: '<-[:INVITED]-(related:User)', + disabledBy: '<-[:DISABLED]-(related:User)', + }, + hasMany: { + followedBy: '<-[:FOLLOWS]-(related:User)', + following: '-[:FOLLOWS]->(related:User)', + friends: '-[:FRIENDS]-(related:User)', + blacklisted: '-[:BLACKLISTED]->(related:User)', + socialMedia: '-[:OWNED_BY]->(related:SocialMedia', + contributions: '-[:WROTE]->(related:Post)', + comments: '-[:WROTE]->(related:Comment)', + shouted: '-[:SHOUTED]->(related:Post)', + organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)', + organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)', + categories: '-[:CATEGORIZED]->(related:Category)', + badges: '<-[:REWARDED]-(related:Badge)', + }, }), }, } diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 492dd3966..41fe8f4e6 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -131,8 +131,3 @@ type SharedInboxEndpoint { uri: String } -type SocialMedia { - id: ID! - url: String - ownedBy: [User]! @relation(name: "OWNED", direction: "IN") -} diff --git a/backend/src/schema/types/type/SocialMedia.gql b/backend/src/schema/types/type/SocialMedia.gql new file mode 100644 index 000000000..230938d95 --- /dev/null +++ b/backend/src/schema/types/type/SocialMedia.gql @@ -0,0 +1,11 @@ +type SocialMedia { + id: ID! + url: String + ownedBy: User! @relation(name: "OWNED_BY", direction: "IN") +} + +type Mutation { + CreateSocialMedia(id: ID, url: String!): SocialMedia + UpdateSocialMedia(id: ID!, url: String!): SocialMedia + DeleteSocialMedia(id: ID!): SocialMedia +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 81bf3e782..2534463d1 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -17,7 +17,7 @@ type User { location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String about: String - socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT") + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT") #createdAt: DateTime #updatedAt: DateTime diff --git a/backend/yarn.lock b/backend/yarn.lock index e799968b3..1059095d7 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1583,10 +1583,10 @@ apollo-server-caching@0.5.0: dependencies: lru-cache "^5.0.0" -apollo-server-core@2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.7.2.tgz#4acd9f4d0d235bef0e596e2a821326dfc07ae7b2" - integrity sha512-Dv6ZMMf8Y+ovkj1ioMtcYvjbcsSMqnZblbPPzOWo29vvKEjMXAL1OTSL1WBYxGA/WSBSCTnxAzipn71XZkYoCw== +apollo-server-core@2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.8.0.tgz#0bfba3d5eb557c6ffa68ad60e77f69e2634e211d" + integrity sha512-Bilaaaol8c4mpF+8DatsAm+leKd0lbz1jS7M+WIuu8GscAXFzzfT6311dNC7zx0wT5FUNNdHdvQOry/lyCn5GA== dependencies: "@apollographql/apollo-tools" "^0.4.0" "@apollographql/graphql-playground-html" "1.6.24" @@ -1601,7 +1601,7 @@ apollo-server-core@2.7.2: apollo-server-types "0.2.1" apollo-tracing "0.8.1" fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.8.2" + graphql-extensions "0.9.0" graphql-tag "^2.9.2" graphql-tools "^4.0.0" graphql-upload "^8.0.2" @@ -1622,10 +1622,10 @@ apollo-server-errors@2.3.1: resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.1.tgz#033cf331463ebb99a563f8354180b41ac6714eb6" integrity sha512-errZvnh0vUQChecT7M4A/h94dnBSRL213dNxpM5ueMypaLYgnp4hiCTWIEaooo9E4yMGd1qA6WaNbLDG2+bjcg== -apollo-server-express@2.7.2, apollo-server-express@^2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.7.2.tgz#a6b9514f42463c9514d2dda34e07ee240b73f764" - integrity sha512-XW+MTKyjJDrHqeLJt9Z3OzLTCRxp53XzVVhF0f/Bs9GCODPlTiBaoiMwY2mXQ7WqK6gkYAH1kRp7d/psPFKE5w== +apollo-server-express@2.8.0, apollo-server-express@^2.7.2: + version "2.8.0" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.8.0.tgz#3815eee2fccfc9cba6d232420fa7411cda062647" + integrity sha512-7dj4CVyOMz1HeVoF8nw3aKw7QV/5D6PACiweu6k9xPRHurYf0bj3ncYkAMPNnxIAwu1I8FzMn4/84BWoKJ7ZFg== dependencies: "@apollographql/graphql-playground-html" "1.6.24" "@types/accepts" "^1.3.5" @@ -1633,7 +1633,7 @@ apollo-server-express@2.7.2, apollo-server-express@^2.7.2: "@types/cors" "^2.8.4" "@types/express" "4.17.0" accepts "^1.3.5" - apollo-server-core "2.7.2" + apollo-server-core "2.8.0" apollo-server-types "0.2.1" body-parser "^1.18.3" cors "^2.8.4" @@ -1649,12 +1649,12 @@ apollo-server-plugin-base@0.6.1: dependencies: apollo-server-types "0.2.1" -apollo-server-testing@~2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.7.2.tgz#c0fb18fa7eef0c945c5b73887d19c704dac5957e" - integrity sha512-fjWJ6K5t3xilPrXg5rQtqFZN0JbNSthkNyJb4Qfpdj9WA5r0vZCeARAFcIrv7o3pSBstyy1UBvJuNG0Rw6HTzA== +apollo-server-testing@~2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.8.0.tgz#57c31575d51d13f09b5a14709c482b9d5986cf58" + integrity sha512-a+9OZcqNeeUkOGVDOfuSmrXsTu3LnG9NvfM/4H2XJBJWHzghiuU6xZV2yHetZSTLXsAvWw3To2j1g8+/A8Yqsg== dependencies: - apollo-server-core "2.7.2" + apollo-server-core "2.8.0" apollo-server-types@0.2.1: version "0.2.1" @@ -1665,13 +1665,13 @@ apollo-server-types@0.2.1: apollo-server-caching "0.5.0" apollo-server-env "2.4.1" -apollo-server@~2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.7.2.tgz#a3eeb6916f11802502ab40819e9f06a4c553c84a" - integrity sha512-0FkNi2ViLJoTglTuBTZ8OeUSK2/LOk4sMGmojDYUYkyVuM5lZX+GWVf3pDNvhrnC2po6TkntkNL4EJLXfKwNMA== +apollo-server@~2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.8.0.tgz#c57261f02f9f2865778ad8e0cdb3c6a80307beb5" + integrity sha512-WtHbP8/C7WkFBCA44V2uTiyuefgqlVSAb6di4XcCPLyopcg9XGKHYRPyp5uOOKlMDTfryNqV59DWHn5/oXkZmQ== dependencies: - apollo-server-core "2.7.2" - apollo-server-express "2.7.2" + apollo-server-core "2.8.0" + apollo-server-express "2.8.0" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" @@ -3994,6 +3994,15 @@ graphql-extensions@0.8.2: apollo-server-env "2.4.1" apollo-server-types "0.2.1" +graphql-extensions@0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.9.0.tgz#88fb3b161f84a92f4a9032b2941919113600635d" + integrity sha512-0GQjQ2t2Nkg9OIk3eS5jcvQLzFkJtVB73t4AnEl7bejPwwShtY37XzE7mOlfof1OqbvRKvKFoks+wSjus2Fhzw== + dependencies: + "@apollographql/apollo-tools" "^0.4.0" + apollo-server-env "2.4.1" + apollo-server-types "0.2.1" + graphql-import@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/graphql-import/-/graphql-import-0.7.1.tgz#4add8d91a5f752d764b0a4a7a461fcd93136f223" @@ -4007,12 +4016,12 @@ graphql-iso-date@~3.6.1: resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96" integrity sha512-AwFGIuYMJQXOEAgRlJlFL4H1ncFM8n8XmoVDTNypNOZyQ8LFDG2ppMFlsS862BSTCDcSUfHp8PD3/uJhv7t59Q== -graphql-middleware@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/graphql-middleware/-/graphql-middleware-3.0.2.tgz#c8cdb67615eec02aec237b455e679f5fc973ddc4" - integrity sha512-sRqu1sF+77z42z1OVM1QDHKQWnWY5K3nAgqWiZwx3U4tqNZprrDuXxSChPMliV343IrVkpYdejUYq9w24Ot3FA== +graphql-middleware@~3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/graphql-middleware/-/graphql-middleware-3.0.3.tgz#58cbce80892fb933d72794447f33f978fc743aa5" + integrity sha512-Os8Vt25MqqwIPJUCCcHznzs6EqarGmM0kkNPUiDnMEkX6vqjA+HugCWatinP+7+fqBqecFUsJmoL4ZypdqZZkg== dependencies: - graphql-tools "^4.0.4" + graphql-tools "^4.0.5" graphql-request@~1.8.2: version "1.8.2" @@ -4062,10 +4071,10 @@ graphql-toolkit@0.4.1: tslib "^1.9.3" valid-url "1.0.9" -graphql-tools@^4.0.0, graphql-tools@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.4.tgz#ca08a63454221fdde825fe45fbd315eb2a6d566b" - integrity sha512-chF12etTIGVVGy3fCTJ1ivJX2KB7OSG4c6UOJQuqOHCmBQwTyNgCDuejZKvpYxNZiEx7bwIjrodDgDe9RIkjlw== +graphql-tools@^4.0.0, graphql-tools@^4.0.4, graphql-tools@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.5.tgz#d2b41ee0a330bfef833e5cdae7e1f0b0d86b1754" + integrity sha512-kQCh3IZsMqquDx7zfIGWBau42xe46gmqabwYkpPlCLIjcEY1XK+auP7iGRD9/205BPyoQdY8hT96MPpgERdC9Q== dependencies: apollo-link "^1.2.3" apollo-utilities "^1.0.1" diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 1c1981581..5a3819a9d 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -1,73 +1,70 @@ -import { When, Then } from 'cypress-cucumber-preprocessor/steps' -When('I search for {string}', value => { - cy.get('#nav-search') +import { When, Then } from "cypress-cucumber-preprocessor/steps"; +When("I search for {string}", value => { + cy.get("#nav-search") .focus() - .type(value) -}) + .type(value); +}); -Then('I should have one post in the select dropdown', () => { - cy.get('.ds-select-dropdown').should($li => { - expect($li).to.have.length(1) - }) -}) +Then("I should have one post in the select dropdown", () => { + cy.get(".input .ds-select-dropdown").should($li => { + expect($li).to.have.length(1); + }); +}); -Then('I should see the following posts in the select dropdown:', table => { +Then("I should see the following posts in the select dropdown:", table => { table.hashes().forEach(({ title }) => { - cy.get('.ds-select-dropdown').should('contain', title) - }) -}) + cy.get(".ds-select-dropdown").should("contain", title); + }); +}); -When('I type {string} and press Enter', value => { - cy.get('#nav-search') +When("I type {string} and press Enter", value => { + cy.get("#nav-search") .focus() .type(value) - .type('{enter}', { force: true }) -}) + .type("{enter}", { force: true }); +}); -When('I type {string} and press escape', value => { - cy.get('#nav-search') +When("I type {string} and press escape", value => { + cy.get("#nav-search") .focus() .type(value) - .type('{esc}') -}) + .type("{esc}"); +}); -Then('the search field should clear', () => { - cy.get('#nav-search').should('have.text', '') -}) +Then("the search field should clear", () => { + cy.get("#nav-search").should("have.text", ""); +}); -When('I select an entry', () => { - cy.get('.ds-select-dropdown ul li') +When("I select an entry", () => { + cy.get(".input .ds-select-dropdown ul li") .first() - .trigger('click') -}) + .trigger("click"); +}); Then("I should be on the post's page", () => { - cy.location('pathname').should( - 'contain', - '/post/' - ) - cy.location('pathname').should( - 'eq', - '/post/p1/101-essays-that-will-change-the-way-you-think' - ) -}) + cy.location("pathname").should("contain", "/post/"); + cy.location("pathname").should( + "eq", + "/post/p1/101-essays-that-will-change-the-way-you-think" + ); +}); Then( - 'I should see posts with the searched-for term in the select dropdown', + "I should see posts with the searched-for term in the select dropdown", () => { - cy.get('.ds-select-dropdown').should( - 'contain', - '101 Essays that will change the way you think' - ) + cy.get(".ds-select-dropdown").should( + "contain", + "101 Essays that will change the way you think" + ); } -) +); Then( - 'I should not see posts without the searched-for term in the select dropdown', + "I should not see posts without the searched-for term in the select dropdown", () => { - cy.get('.ds-select-dropdown').should( - 'not.contain', - 'No searched for content' - ) + cy.get(".ds-select-dropdown").should( + "not.contain", + "No searched for content" + ); } -) +); 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/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 3d136ff4b..3f9384d27 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -14,6 +14,8 @@ localVue.use(Styleguide) localVue.use(Filters) config.stubs['no-ssr'] = '' +config.stubs['nuxt-link'] = '' +config.stubs['v-popover'] = '' describe('ContributionForm.vue', () => { let wrapper @@ -24,9 +26,22 @@ describe('ContributionForm.vue', () => { let mocks let propsData const postTitle = 'this is a title for a post' + const postTitleTooShort = 'xx' + let postTitleTooLong = '' + for (let i = 0; i < 65; i++) { + postTitleTooLong += 'x' + } const postContent = 'this is a post' + const postContentTooShort = 'xx' + let postContentTooLong = '' + for (let i = 0; i < 2001; i++) { + postContentTooLong += 'x' + } const imageUpload = { - file: { filename: 'avataar.svg', previewElement: '' }, + file: { + filename: 'avataar.svg', + previewElement: '', + }, url: 'someUrlToImage', } const image = '/uploads/1562010976466-avataaars' @@ -34,22 +49,17 @@ describe('ContributionForm.vue', () => { mocks = { $t: jest.fn(), $apollo: { - mutate: jest - .fn() - .mockResolvedValueOnce({ - data: { - CreatePost: { - title: postTitle, - slug: 'this-is-a-title-for-a-post', - content: postContent, - contentExcerpt: postContent, - language: 'en', - }, + mutate: jest.fn().mockResolvedValueOnce({ + data: { + CreatePost: { + title: postTitle, + slug: 'this-is-a-title-for-a-post', + content: postContent, + contentExcerpt: postContent, + language: 'en', }, - }) - .mockRejectedValue({ - message: 'Not Authorised!', - }), + }, + }), }, $toast: { error: jest.fn(), @@ -71,6 +81,13 @@ describe('ContributionForm.vue', () => { 'editor/placeholder': () => { return 'some cool placeholder' }, + 'auth/user': () => { + return { + id: '4711', + name: 'You yourself', + slug: 'you-yourself', + } + }, } const store = new Vuex.Store({ getters, @@ -106,16 +123,53 @@ describe('ContributionForm.vue', () => { }) describe('invalid form submission', () => { - it('title required for form submission', async () => { - postTitleInput = wrapper.find('.ds-input') - postTitleInput.setValue(postTitle) - await wrapper.find('form').trigger('submit') + it('title and content should not be empty ', async () => { + wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).not.toHaveBeenCalled() }) - it('content required for form submission', async () => { - wrapper.vm.updateEditorContent(postContent) - await wrapper.find('form').trigger('submit') + it('title should not be empty', async () => { + await wrapper.vm.updateEditorContent(postContent) + wrapper.find('.submit-button-for-test').trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + it('title should not be too long', async () => { + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitleTooLong) + await wrapper.vm.updateEditorContent(postContent) + wrapper.find('.submit-button-for-test').trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + it('title should not be too short', async () => { + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitleTooShort) + await wrapper.vm.updateEditorContent(postContent) + wrapper.find('.submit-button-for-test').trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + it('content should not be empty', async () => { + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitle) + await wrapper.find('.submit-button-for-test').trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + it('content should not be too short', async () => { + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitle) + await wrapper.vm.updateEditorContent(postContentTooShort) + wrapper.find('.submit-button-for-test').trigger('click') + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + + it('content should not be too long', async () => { + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitle) + await wrapper.vm.updateEditorContent(postContentTooLong) + wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).not.toHaveBeenCalled() }) }) @@ -136,15 +190,16 @@ describe('ContributionForm.vue', () => { } postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) - wrapper.vm.updateEditorContent(postContent) - await wrapper.find('form').trigger('submit') + await wrapper.vm.updateEditorContent(postContent) }) it('with title and content', () => { + wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) }) it("sends a fallback language based on a user's locale", () => { + wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) @@ -152,7 +207,7 @@ describe('ContributionForm.vue', () => { expectedParams.variables.language = 'de' deutschOption = wrapper.findAll('li').at(0) deutschOption.trigger('click') - await wrapper.find('form').trigger('submit') + wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) @@ -160,22 +215,26 @@ describe('ContributionForm.vue', () => { const categoryIds = ['cat12', 'cat15', 'cat37'] expectedParams.variables.categoryIds = categoryIds wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) - await wrapper.find('form').trigger('submit') + await wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) it('supports adding a teaser image', async () => { expectedParams.variables.imageUpload = imageUpload wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload) - await wrapper.find('form').trigger('submit') + await wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) it("pushes the user to the post's page", async () => { + wrapper.find('.submit-button-for-test').trigger('click') + await mocks.$apollo.mutate expect(mocks.$router.push).toHaveBeenCalledTimes(1) }) - it('shows a success toaster', () => { + it('shows a success toaster', async () => { + wrapper.find('.submit-button-for-test').trigger('click') + await mocks.$apollo.mutate expect(mocks.$toast.success).toHaveBeenCalledTimes(1) }) }) @@ -191,18 +250,19 @@ describe('ContributionForm.vue', () => { describe('handles errors', () => { beforeEach(async () => { jest.useFakeTimers() + mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ + message: 'Not Authorised!', + }) wrapper = Wrapper() postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) - wrapper.vm.updateEditorContent(postContent) - // second submission causes mutation to reject - await wrapper.find('form').trigger('submit') + await wrapper.vm.updateEditorContent(postContent) }) it('shows an error toaster when apollo mutation rejects', async () => { - await wrapper.find('form').trigger('submit') + await wrapper.find('.submit-button-for-test').trigger('click') await mocks.$apollo.mutate - expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') + await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') }) }) }) @@ -217,7 +277,12 @@ describe('ContributionForm.vue', () => { content: 'auf Deutsch geschrieben', language: 'de', image, - categories: [{ id: 'cat12', name: 'Democracy & Politics' }], + categories: [ + { + id: 'cat12', + name: 'Democracy & Politics', + }, + ], }, } wrapper = Wrapper() @@ -255,7 +320,7 @@ describe('ContributionForm.vue', () => { postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) wrapper.vm.updateEditorContent(postContent) - await wrapper.find('form').trigger('submit') + await wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) @@ -266,7 +331,7 @@ describe('ContributionForm.vue', () => { wrapper.vm.updateEditorContent(postContent) expectedParams.variables.categoryIds = categoryIds wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) - await wrapper.find('form').trigger('submit') + await wrapper.find('.submit-button-for-test').trigger('click') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) }) diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index 23a1c75de..dca23a882 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -1,5 +1,5 @@