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 @@ - + @@ -13,6 +13,7 @@ + {{ form.title.length }}/{{ formSchema.title.max }} + {{ form.contentLength }}/{{ contentMax }} {{ $t('actions.cancel') }} {{ $t('actions.save') }} @@ -92,6 +96,7 @@ export default { form: { title: '', content: '', + contentLength: 0, teaserImage: null, image: null, language: null, @@ -100,13 +105,16 @@ export default { }, formSchema: { title: { required: true, min: 3, max: 64 }, - content: { required: true, min: 3 }, + content: [{ required: true }], }, id: null, loading: false, - disabled: false, + disabledByContent: true, slug: null, users: [], + contentMin: 3, + contentMax: 2000, + hashtags: [], } }, @@ -119,8 +127,9 @@ export default { } this.id = contribution.id this.slug = contribution.slug - this.form.content = contribution.content this.form.title = contribution.title + this.form.content = contribution.content + this.manageContent(this.form.content) this.form.image = contribution.image this.form.categoryIds = this.categoryIds(contribution.categories) }, @@ -169,7 +178,7 @@ export default { .then(res => { this.loading = false this.$toast.success(this.$t('contribution.success')) - this.disabled = true + this.disabledByContent = true const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] this.$router.push({ @@ -180,12 +189,21 @@ export default { .catch(err => { this.$toast.error(err.message) this.loading = false - this.disabled = false + this.disabledByContent = false }) }, updateEditorContent(value) { - // this.form.content = value + // TODO: Do smth????? what is happening this.$refs.contributionForm.update('content', value) + this.manageContent(value) + }, + manageContent(content) { + // filter HTML out of content value + const str = content.replace(/<\/?[^>]+(>|$)/gm, '') + // Set counter length of text + this.form.contentLength = str.length + // Enable save button if requirements are met + this.disabledByContent = !(this.contentMin <= str.length && str.length <= this.contentMax) }, availableLocales() { orderBy(locales, 'name').map(locale => { @@ -242,6 +260,11 @@ export default { 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 @@ - - - - - - - {{ link.url }} - - - | - - - - - - - - - - - - - - - - {{ $t('settings.social-media.submit') }} - - + + + + + + + + + + + {{ link.url }} + + | + + + + + + + + + - - + + + + + + {{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }} + + + {{ $t('actions.cancel') }} + + + + + + diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 0a34a876e..dfa3491c2 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" @@ -11379,10 +11384,10 @@ vue@^2.6.10, vue@^2.6.6: resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== -vuex-i18n@~1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.11.0.tgz#e6cdc95080c445ab2c211cc6b64a907d217639d3" - integrity sha512-+Eme0C7FS3VFLIWpAwisohC3KcRDw+YcXFANssUZZq16P2C4z8V2VGbEtFHFw0DzkvZcdM2CAkUj6rdMl9wYmg== +vuex-i18n@~1.13.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/vuex-i18n/-/vuex-i18n-1.13.0.tgz#161c22548e1d3038523f5af49e5730e17a0ca4f2" + integrity sha512-r9ZS8NFr8OAIvtlSnqrQLFXxh07QHhkdeGem8RyWFBKej9/yxPAneBEQaLCUM0BOKPpAzBX8t2cjQmtukWbWLg== vuex@^3.1.1: version "3.1.1"