diff --git a/backend/.env.template b/backend/.env.template index 594c7b28b..0227b93d4 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -4,7 +4,6 @@ NEO4J_PASSWORD=letmein GRAPHQL_PORT=4000 GRAPHQL_URI=http://localhost:4000 CLIENT_URI=http://localhost:3000 -MOCKS=false SMTP_HOST= SMTP_PORT= SMTP_IGNORE_TLS=true diff --git a/backend/Dockerfile b/backend/Dockerfile index 0df78f727..ffcc0559d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.7-alpine as base +FROM node:12.8-alpine as base LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" EXPOSE 4000 diff --git a/backend/package.json b/backend/package.json index 508040b6b..19904c22d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,8 +44,8 @@ "dependencies": { "@hapi/joi": "^15.1.0", "activitystrea.ms": "~2.1.3", - "apollo-cache-inmemory": "~1.6.2", - "apollo-client": "~2.6.3", + "apollo-cache-inmemory": "~1.6.3", + "apollo-client": "~2.6.4", "apollo-link-context": "~1.0.18", "apollo-link-http": "~1.5.15", "apollo-server": "~2.8.1", @@ -118,7 +118,7 @@ "eslint-config-prettier": "~6.0.0", "eslint-config-standard": "~13.0.1", "eslint-plugin-import": "~2.18.2", - "eslint-plugin-jest": "~22.15.0", + "eslint-plugin-jest": "~22.15.1", "eslint-plugin-node": "~9.1.0", "eslint-plugin-prettier": "~3.1.0", "eslint-plugin-promise": "~4.2.1", diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 320b636e9..daba745c8 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -32,7 +32,6 @@ export const serverConfigs = { GRAPHQL_PORT, CLIENT_URI, GRAPHQL_URI } export const developmentConfigs = { DEBUG: process.env.NODE_ENV !== 'production' && process.env.DEBUG === 'true', - MOCKS: process.env.MOCKS === 'true', DISABLED_MIDDLEWARES: (process.env.NODE_ENV !== 'production' && process.env.DISABLED_MIDDLEWARES) || '', } diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index d972603a6..78f833c23 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -157,6 +157,8 @@ const permissions = shield( User: or(noEmailFilter, isAdmin), isLoggedIn: allow, Badge: allow, + PostsEmotionsCountByEmotion: allow, + PostsEmotionsByCurrentUser: allow, }, Mutation: { '*': deny, @@ -178,7 +180,6 @@ const permissions = shield( // RemoveBadgeRewarded: isAdmin, reward: isAdmin, unreward: isAdmin, - // addFruitToBasket: isAuthenticated follow: isAuthenticated, unfollow: isAuthenticated, shout: isAuthenticated, @@ -192,6 +193,8 @@ const permissions = shield( DeleteUser: isDeletingOwnAccount, requestPasswordReset: allow, resetPassword: allow, + AddPostEmotions: isAuthenticated, + RemovePostEmotions: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/mocks/index.js b/backend/src/mocks/index.js deleted file mode 100644 index 7b453c8c6..000000000 --- a/backend/src/mocks/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import faker from 'faker' - -export default { - User: () => ({ - name: () => `${faker.name.firstName()} ${faker.name.lastName()}`, - email: () => `${faker.internet.email()}`, - }), - Post: () => ({ - title: () => faker.lorem.lines(1), - slug: () => faker.lorem.slug(3), - content: () => faker.lorem.paragraphs(5), - contentExcerpt: () => faker.lorem.paragraphs(1), - }), -} diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js new file mode 100644 index 000000000..7dd62287d --- /dev/null +++ b/backend/src/models/Post.js @@ -0,0 +1,34 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + activityId: { type: 'string', allow: [null] }, + objectId: { type: 'string', allow: [null] }, + author: { + type: 'relationship', + relationship: 'WROTE', + target: 'User', + direction: 'in', + }, + title: { type: 'string', disallow: [null], min: 3 }, + slug: { type: 'string', allow: [null] }, + content: { type: 'string', disallow: [null], min: 3 }, + contentExcerpt: { type: 'string', allow: [null] }, + image: { type: 'string', allow: [null] }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + disabledBy: { + type: 'relationship', + relationship: 'DISABLED', + target: 'User', + direction: 'in', + }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + language: { type: 'string', allow: [null] }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index bcd9fcf35..2c1575423 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -56,4 +56,19 @@ module.exports = { required: true, default: () => new Date().toISOString(), }, + emoted: { + type: 'relationships', + relationship: 'EMOTED', + target: 'Post', + direction: 'out', + properties: { + emotion: { + type: 'string', + valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], + invalid: [null], + }, + }, + eager: true, + cascade: true, + }, } diff --git a/backend/src/models/index.js b/backend/src/models/index.js index b468dedf2..b8dc451d7 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -6,4 +6,5 @@ export default { InvitationCode: require('./InvitationCode.js'), EmailAddress: require('./EmailAddress.js'), SocialMedia: require('./SocialMedia.js'), + Post: require('./Post.js'), } diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 0c8dfb7f0..336f816f5 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -44,6 +44,7 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() + let createPostCypher = `CREATE (post:Post {params}) WITH post MATCH (author:User {id: $userId}) @@ -70,5 +71,80 @@ export default { return post.properties }, + AddPostEmotions: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { to, data } = params + const { user } = context + const transactionRes = await session.run( + `MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id}) + MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo) + RETURN userFrom, postTo, emotedRelation`, + { user, to, data }, + ) + session.close() + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + ...record.get('emotedRelation').properties, + } + }) + return emoted + }, + RemovePostEmotions: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { to, data } = params + const { id: from } = context.user + const transactionRes = await session.run( + `MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) + DELETE emotedRelation + RETURN userFrom, postTo`, + { from, to, data }, + ) + session.close() + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + emotion: data.emotion, + } + }) + return emoted + }, + }, + Query: { + PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId, data } = params + const transactionRes = await session.run( + `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + RETURN COUNT(DISTINCT emoted) as emotionsCount + `, + { postId, data }, + ) + session.close() + + const [emotionsCount] = transactionRes.records.map(record => { + return record.get('emotionsCount').low + }) + + return emotionsCount + }, + PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId } = params + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + RETURN collect(emoted.emotion) as emotion`, + { userId: context.user.id, postId }, + ) + + session.close() + + const [emotions] = transactionRes.records.map(record => { + return record.get('emotion') + }) + return emotions + }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 6714c93ad..b48be16db 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -1,8 +1,14 @@ import { GraphQLClient } from 'graphql-request' +import { createTestClient } from 'apollo-server-testing' import Factory from '../../seed/factories' -import { host, login } from '../../jest/helpers' +import { host, login, gql } from '../../jest/helpers' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' +const driver = getDriver() const factory = Factory() +const instance = neode() + let client let userParams let authorParams @@ -14,7 +20,7 @@ const oldContent = 'Old content' const newTitle = 'New title' const newContent = 'New content' const createPostVariables = { title: postTitle, content: postContent } -const createPostWithCategoriesMutation = ` +const createPostWithCategoriesMutation = gql` mutation($title: String!, $content: String!, $categoryIds: [ID]) { CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { id @@ -27,7 +33,7 @@ const createPostWithCategoriesVariables = { content: postContent, categoryIds: ['cat9', 'cat4', 'cat15'], } -const postQueryWithCategories = ` +const postQueryWithCategories = gql` query($id: ID) { Post(id: $id) { categories { @@ -41,9 +47,9 @@ const createPostWithoutCategoriesVariables = { content: 'I should be able to filter it out', categoryIds: null, } -const postQueryFilteredByCategory = ` -query Post($filter: _PostFilter) { - Post(filter: $filter) { +const postQueryFilteredByCategory = gql` + query Post($filter: _PostFilter) { + Post(filter: $filter) { title id categories { @@ -56,13 +62,28 @@ const postCategoriesFilterParam = { categories_some: { id_in: ['cat4'] } } const postQueryFilteredByCategoryVariables = { filter: postCategoriesFilterParam, } + +const createPostMutation = gql` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + id + title + content + slug + disabled + deleted + } + } +` beforeEach(async () => { userParams = { + id: 'u198', name: 'TestUser', email: 'test@example.org', password: '1234', } authorParams = { + id: 'u25', email: 'author@example.org', password: '1234', } @@ -74,22 +95,12 @@ afterEach(async () => { }) describe('CreatePost', () => { - const mutation = ` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { - title - content - slug - disabled - deleted - } - } - ` - describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation, createPostVariables)).rejects.toThrow('Not Authorised') + await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -107,19 +118,23 @@ describe('CreatePost', () => { content: postContent, }, } - await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) + await expect(client.request(createPostMutation, createPostVariables)).resolves.toMatchObject( + expected, + ) }) it('assigns the authenticated user as author', async () => { - await client.request(mutation, createPostVariables) + await client.request(createPostMutation, createPostVariables) const { User } = await client.request( - `{ - User(name: "TestUser") { - contributions { - title + gql` + { + User(name: "TestUser") { + contributions { + title + } } } - }`, + `, { headers }, ) expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) @@ -128,13 +143,15 @@ describe('CreatePost', () => { describe('disabled and deleted', () => { it('initially false', async () => { const expected = { CreatePost: { disabled: false, deleted: false } } - await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) + await expect( + client.request(createPostMutation, createPostVariables), + ).resolves.toMatchObject(expected) }) }) describe('language', () => { it('allows a user to set the language of the post', async () => { - const createPostWithLanguageMutation = ` + const createPostWithLanguageMutation = gql` mutation($title: String!, $content: String!, $language: String) { CreatePost(title: $title, content: $content, language: $language) { language @@ -222,7 +239,7 @@ describe('UpdatePost', () => { title: oldTitle, content: oldContent, }) - updatePostMutation = ` + updatePostMutation = gql` mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id @@ -328,7 +345,7 @@ describe('UpdatePost', () => { }) describe('DeletePost', () => { - const mutation = ` + const mutation = gql` mutation($id: ID!) { DeletePost(id: $id) { id @@ -383,3 +400,315 @@ describe('DeletePost', () => { }) }) }) + +describe('emotions', () => { + let addPostEmotionsVariables, + someUser, + ownerNode, + owner, + postMutationAction, + user, + postQueryAction, + postToEmote, + postToEmoteNode + const PostsEmotionsCountQuery = ` + query($id: ID!) { + Post(id: $id) { + emotionsCount + } + } + ` + const PostsEmotionsQuery = gql` + query($id: ID!) { + Post(id: $id) { + emotions { + emotion + User { + id + } + } + } + } + ` + const addPostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + AddPostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` + beforeEach(async () => { + userParams.id = 'u1987' + authorParams.id = 'u257' + createPostVariables.id = 'p1376' + const someUserNode = await instance.create('User', userParams) + someUser = await someUserNode.toJson() + ownerNode = await instance.create('User', authorParams) + owner = await ownerNode.toJson() + postToEmoteNode = await instance.create('Post', createPostVariables) + postToEmote = await postToEmoteNode.toJson() + await postToEmoteNode.relateTo(ownerNode, 'author') + + postMutationAction = async (user, mutation, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + driver, + } + }, + }) + const { mutate } = createTestClient(server) + + return mutate({ + mutation, + variables, + }) + } + postQueryAction = async (postQuery, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + driver, + } + }, + }) + const { query } = createTestClient(server) + return query({ query: postQuery, variables }) + } + addPostEmotionsVariables = { + to: { id: postToEmote.id }, + data: { emotion: 'happy' }, + } + }) + + describe('AddPostEmotions', () => { + let postsEmotionsQueryVariables + beforeEach(async () => { + postsEmotionsQueryVariables = { id: postToEmote.id } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + user = null + const addPostEmotions = await postMutationAction( + user, + addPostEmotionsMutation, + addPostEmotionsVariables, + ) + + expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated and not the author', () => { + beforeEach(() => { + user = someUser + }) + + it('adds an emotion to the post', async () => { + const expected = { + data: { + AddPostEmotions: { + from: { id: user.id }, + to: addPostEmotionsVariables.to, + emotion: 'happy', + }, + }, + } + await expect( + postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('limits the addition of the same emotion to 1', async () => { + const expected = { + data: { + Post: [ + { + emotionsCount: 1, + }, + ], + }, + } + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsCountQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('allows a user to add more than one emotion', async () => { + const expectedEmotions = [ + { emotion: 'happy', User: { id: user.id } }, + { emotion: 'surprised', User: { id: user.id } }, + ] + const expectedResponse = { + data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + } + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + addPostEmotionsVariables.data.emotion = 'surprised' + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + + describe('authenticated as author', () => { + beforeEach(() => { + user = owner + }) + + it('adds an emotion to the post', async () => { + const expected = { + data: { + AddPostEmotions: { + from: { id: owner.id }, + to: addPostEmotionsVariables.to, + emotion: 'happy', + }, + }, + } + await expect( + postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + }) + }) + + describe('RemovePostEmotions', () => { + let removePostEmotionsVariables, postsEmotionsQueryVariables + const removePostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + RemovePostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` + beforeEach(async () => { + await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + + postsEmotionsQueryVariables = { id: postToEmote.id } + removePostEmotionsVariables = { + to: { id: postToEmote.id }, + data: { emotion: 'cry' }, + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + user = null + const removePostEmotions = await postMutationAction( + user, + removePostEmotionsMutation, + removePostEmotionsVariables, + ) + expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + describe('but not the emoter', () => { + it('returns null if the emotion could not be found', async () => { + user = someUser + const removePostEmotions = await postMutationAction( + user, + removePostEmotionsMutation, + removePostEmotionsVariables, + ) + expect(removePostEmotions).toEqual( + expect.objectContaining({ data: { RemovePostEmotions: null } }), + ) + }) + }) + + describe('as the emoter', () => { + it('removes an emotion from a post', async () => { + user = owner + const expected = { + data: { + RemovePostEmotions: { + to: { id: postToEmote.id }, + from: { id: user.id }, + emotion: 'cry', + }, + }, + } + await expect( + postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('removes only the requested emotion, not all emotions', async () => { + const expectedEmotions = [{ emotion: 'happy', User: { id: authorParams.id } }] + const expectedResponse = { + data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + } + await postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + }) + }) + + describe('posts emotions count', () => { + let PostsEmotionsCountByEmotionVariables + let PostsEmotionsByCurrentUserVariables + + const PostsEmotionsCountByEmotionQuery = gql` + query($postId: ID!, $data: _EMOTEDInput!) { + PostsEmotionsCountByEmotion(postId: $postId, data: $data) + } + ` + + const PostsEmotionsByCurrentUserQuery = gql` + query($postId: ID!) { + PostsEmotionsByCurrentUser(postId: $postId) + } + ` + beforeEach(async () => { + await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + + PostsEmotionsCountByEmotionVariables = { + postId: postToEmote.id, + data: { emotion: 'cry' }, + } + PostsEmotionsByCurrentUserVariables = { postId: postToEmote.id } + }) + + describe('PostsEmotionsCountByEmotion', () => { + it("returns a post's emotions count", async () => { + const expectedResponse = { data: { PostsEmotionsCountByEmotion: 1 } } + await expect( + postQueryAction(PostsEmotionsCountByEmotionQuery, PostsEmotionsCountByEmotionVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + + describe('PostsEmotionsCountByEmotion', () => { + it("returns a currentUser's emotions on a post", async () => { + const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } + await expect( + postQueryAction(PostsEmotionsByCurrentUserQuery, PostsEmotionsByCurrentUserVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/EMOTED.gql b/backend/src/schema/types/type/EMOTED.gql index 80d655b5c..d40eed0c4 100644 --- a/backend/src/schema/types/type/EMOTED.gql +++ b/backend/src/schema/types/type/EMOTED.gql @@ -7,4 +7,4 @@ type EMOTED @relation(name: "EMOTED") { #updatedAt: DateTime createdAt: String updatedAt: String -} \ No newline at end of file +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index d254a9a9c..519af14ae 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -50,6 +50,8 @@ type Post { ) emotions: [EMOTED] + emotionsCount: Int! + @cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)") } type Mutation { @@ -89,4 +91,11 @@ type Mutation { language: String categoryIds: [ID] ): Post + AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED + RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED +} + +type Query { + PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! + PostsEmotionsByCurrentUser(postId: ID!): [String] } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 0efb2a886..df3886a6c 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -128,6 +128,22 @@ export default function Factory(options = {}) { this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) return this }, + async emote({ to, data }) { + const mutation = ` + mutation { + AddPostEmotions( + to: { id: "${to}" }, + data: { emotion: ${data} } + ) { + from { id } + to { id } + emotion + } + } + ` + this.lastResponse = await this.graphQLClient.request(mutation) + return this + }, } result.authenticateAs.bind(result) result.create.bind(result) diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 30bf68b01..07e4f0625 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -503,6 +503,116 @@ import Factory from './factories' from: 'p15', to: 'Demokratie', }), + f.emote({ + from: 'u1', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u2', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u3', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u4', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u5', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u7', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u2', + to: 'p14', + data: 'cry', + }), + f.emote({ + from: 'u3', + to: 'p13', + data: 'angry', + }), + f.emote({ + from: 'u4', + to: 'p12', + data: 'funny', + }), + f.emote({ + from: 'u5', + to: 'p11', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p10', + data: 'cry', + }), + f.emote({ + from: 'u5', + to: 'p9', + data: 'happy', + }), + f.emote({ + from: 'u4', + to: 'p8', + data: 'angry', + }), + f.emote({ + from: 'u3', + to: 'p7', + data: 'funny', + }), + f.emote({ + from: 'u2', + to: 'p6', + data: 'surprised', + }), + f.emote({ + from: 'u1', + to: 'p5', + data: 'cry', + }), + f.emote({ + from: 'u2', + to: 'p4', + data: 'happy', + }), + f.emote({ + from: 'u3', + to: 'p3', + data: 'angry', + }), + f.emote({ + from: 'u4', + to: 'p2', + data: 'funny', + }), + f.emote({ + from: 'u5', + to: 'p1', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p0', + data: 'cry', + }), ]) await Promise.all([ diff --git a/backend/src/server.js b/backend/src/server.js index d58ecd277..5f13daeea 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -2,7 +2,6 @@ import express from 'express' import helmet from 'helmet' import { ApolloServer } from 'apollo-server-express' import CONFIG, { requiredConfigs } from './config' -import mocks from './mocks' import middleware from './middleware' import { getDriver } from './bootstrap/neo4j' import decode from './jwt/decode' @@ -34,7 +33,6 @@ const createServer = options => { schema: middleware(schema), debug: CONFIG.DEBUG, tracing: CONFIG.DEBUG, - mocks: CONFIG.MOCKS ? mocks : false, } const server = new ApolloServer(Object.assign({}, defaults, options)) diff --git a/backend/yarn.lock b/backend/yarn.lock index d327c1bd0..de67894e7 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1493,14 +1493,14 @@ apollo-cache-control@0.8.1: apollo-server-env "2.4.1" graphql-extensions "0.8.1" -apollo-cache-inmemory@~1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.2.tgz#bbf2e4e1eacdf82b2d526f5c2f3b37e5acee3c5e" - integrity sha512-AyCl3PGFv5Qv1w4N9vlg63GBPHXgMCekZy5mhlS042ji0GW84uTySX+r3F61ZX3+KM1vA4m9hQyctrEGiv5XjQ== +apollo-cache-inmemory@~1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.3.tgz#826861d20baca4abc45f7ca7a874105905b8525d" + integrity sha512-S4B/zQNSuYc0M/1Wq8dJDTIO9yRgU0ZwDGnmlqxGGmFombOZb9mLjylewSfQKmjNpciZ7iUIBbJ0mHlPJTzdXg== dependencies: apollo-cache "^1.3.2" apollo-utilities "^1.3.2" - optimism "^0.9.0" + optimism "^0.10.0" ts-invariant "^0.4.0" tslib "^1.9.3" @@ -1512,10 +1512,10 @@ apollo-cache@1.3.2, apollo-cache@^1.3.2: apollo-utilities "^1.3.2" tslib "^1.9.3" -apollo-client@~2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.3.tgz#9bb2d42fb59f1572e51417f341c5f743798d22db" - integrity sha512-DS8pmF5CGiiJ658dG+mDn8pmCMMQIljKJSTeMNHnFuDLV0uAPZoeaAwVFiAmB408Ujqt92oIZ/8yJJAwSIhd4A== +apollo-client@~2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.4.tgz#872c32927263a0d34655c5ef8a8949fbb20b6140" + integrity sha512-oWOwEOxQ9neHHVZrQhHDbI6bIibp9SHgxaLRVPoGvOFy7OH5XUykZE7hBQAVxq99tQjBzgytaZffQkeWo1B4VQ== dependencies: "@types/zen-observable" "^0.8.0" apollo-cache "1.3.2" @@ -3292,10 +3292,10 @@ eslint-plugin-import@~2.18.2: read-pkg-up "^2.0.0" resolve "^1.11.0" -eslint-plugin-jest@~22.15.0: - version "22.15.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.15.0.tgz#fe70bfff7eeb47ca0ab229588a867f82bb8592c5" - integrity sha512-hgnPbSqAIcLLS9ePb12hNHTRkXnkVaCfOwCt2pzQ8KpOKPWGA4HhLMaFN38NBa/0uvLfrZpcIRjT+6tMAfr58Q== +eslint-plugin-jest@~22.15.1: + version "22.15.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.15.1.tgz#54c4a752a44c4bc5a564ecc22b32e1cd16a2961a" + integrity sha512-CWq/RR/3tLaKFB+FZcCJwU9hH5q/bKeO3rFP8G07+q7hcDCFNqpvdphVbEbGE6o6qo1UbciEev4ejUWv7brUhw== dependencies: "@typescript-eslint/experimental-utils" "^1.13.0" @@ -6478,10 +6478,10 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" -optimism@^0.9.0: - version "0.9.5" - resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.9.5.tgz#b8b5dc9150e97b79ddbf2d2c6c0e44de4d255527" - integrity sha512-lNvmuBgONAGrUbj/xpH69FjMOz1d0jvMNoOCKyVynUPzq2jgVlGL4jFYJqrUHzUfBv+jAFSCP61x5UkfbduYJA== +optimism@^0.10.0: + version "0.10.2" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.10.2.tgz#626b6fd28b0923de98ecb36a3fd2d3d4e5632dd9" + integrity sha512-zPfBIxFFWMmQboM9+Z4MSJqc1PXp82v1PFq/GfQaufI69mHKlup7ykGNnfuGIGssXJQkmhSodQ/k9EWwjd8O8A== dependencies: "@wry/context" "^0.4.0" diff --git a/cypress/integration/administration/TagsAndCategories.feature b/cypress/integration/administration/TagsAndCategories.feature index 96196be01..e543b143b 100644 --- a/cypress/integration/administration/TagsAndCategories.feature +++ b/cypress/integration/administration/TagsAndCategories.feature @@ -29,9 +29,9 @@ Feature: Tags and Categories Scenario: See an overview of tags When I navigate to the administration dashboard - And I click on the menu item "Tags" + And I click on the menu item "Hashtags" Then I can see the following table: - | | Name | Users | Posts | - | 1 | Democracy | 3 | 4 | - | 2 | Nature | 2 | 3 | - | 3 | Ecology | 1 | 1 | + | No. | Hashtags | Users | Posts | + | 1 | #Democracy | 3 | 4 | + | 2 | #Nature | 2 | 3 | + | 3 | #Ecology | 1 | 1 | diff --git a/deployment/human-connection/templates/configmap.template.yaml b/deployment/human-connection/templates/configmap.template.yaml index 1e8b37b06..0a7e90343 100644 --- a/deployment/human-connection/templates/configmap.template.yaml +++ b/deployment/human-connection/templates/configmap.template.yaml @@ -6,7 +6,6 @@ SMTP_PORT: "25" GRAPHQL_PORT: "4000" GRAPHQL_URI: "http://nitro-backend.human-connection:4000" - MOCKS: "false" NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687" NEO4J_AUTH: "none" CLIENT_URI: "https://nitro-staging.human-connection.org" diff --git a/docker-compose.maintenance.yml b/docker-compose.maintenance.yml index e536b1157..c029ffb6b 100644 --- a/docker-compose.maintenance.yml +++ b/docker-compose.maintenance.yml @@ -19,7 +19,6 @@ services: - GRAPHQL_URI=http://localhost:4000 - CLIENT_URI=http://localhost:3000 - JWT_SECRET=b/&&7b78BF&fv/Vd - - MOCKS=false - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 - NEO4J_apoc_import_file_enabled=true diff --git a/docker-compose.yml b/docker-compose.yml index bae571b86..3b147c631 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,6 @@ services: - GRAPHQL_URI=http://localhost:4000 - CLIENT_URI=http://localhost:3000 - JWT_SECRET=b/&&7b78BF&fv/Vd - - MOCKS=false - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 neo4j: diff --git a/package.json b/package.json index 0941d3e64..250279bd5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "codecov": "^3.5.0", "cross-env": "^5.2.0", "cypress": "^3.4.1", - "cypress-cucumber-preprocessor": "^1.13.0", + "cypress-cucumber-preprocessor": "^1.13.1", "cypress-file-upload": "^3.3.3", "cypress-plugin-retries": "^1.2.2", "dotenv": "^8.0.0", @@ -34,4 +34,4 @@ "npm-run-all": "^4.1.5", "slug": "^1.1.0" } -} +} \ No newline at end of file diff --git a/webapp/Dockerfile b/webapp/Dockerfile index cad4c171d..84c435c0a 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12.7-alpine as base +FROM node:12.8-alpine as base LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 @@ -18,7 +18,7 @@ COPY . . FROM base as build-and-test RUN cp .env.template .env -RUN yarn install --ignore-engines --production=false --frozen-lockfile --non-interactive +RUN yarn install --production=false --frozen-lockfile --non-interactive RUN yarn run build FROM base as production diff --git a/webapp/components/Emotions/Emotions.spec.js b/webapp/components/Emotions/Emotions.spec.js new file mode 100644 index 000000000..ecc7f9e94 --- /dev/null +++ b/webapp/components/Emotions/Emotions.spec.js @@ -0,0 +1,127 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Emotions from './Emotions.vue' +import Styleguide from '@human-connection/styleguide' +import Vuex from 'vuex' +import PostMutations from '~/graphql/PostMutations.js' + +const localVue = createLocalVue() + +localVue.use(Styleguide) +localVue.use(Vuex) + +describe('Emotions.vue', () => { + let wrapper + let mocks + let propsData + let getters + let funnyButton + let funnyImage + const funnyImageSrc = '/img/svg/emoji/funny_color.svg' + + beforeEach(() => { + mocks = { + $apollo: { + mutate: jest + .fn() + .mockResolvedValueOnce({ + data: { + AddPostEmotions: { + to: { id: 'p143' }, + data: { emotion: 'happy' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + RemovePostEmotions: { + from: { id: 'u176' }, + to: { id: 'p143' }, + data: { emotion: 'happy' }, + }, + }, + }), + query: jest.fn().mockResolvedValue({ + data: { + PostsEmotionsCountByEmotion: 1, + }, + }), + }, + $t: jest.fn(), + } + propsData = { + post: { id: 'p143' }, + } + getters = { + 'auth/user': () => { + return { id: 'u176' } + }, + } + }) + describe('mount', () => { + const Wrapper = () => { + const store = new Vuex.Store({ + getters, + }) + return mount(Emotions, { mocks, propsData, store, localVue }) + } + beforeEach(() => { + wrapper = Wrapper() + }) + + it("queries the post's emotions count for each of the 5 emotions", () => { + expect(mocks.$apollo.query).toHaveBeenCalledTimes(5) + }) + + describe('adding emotions', () => { + let expectedParams + beforeEach(() => { + wrapper.vm.PostsEmotionsCountByEmotion.funny = 0 + funnyButton = wrapper.findAll('button').at(0) + funnyButton.trigger('click') + }) + + it('shows the colored image when the button is active', () => { + funnyImage = wrapper.findAll('img').at(0) + expect(funnyImage.attributes().src).toEqual(funnyImageSrc) + }) + + it('sends the AddPostEmotionsMutation for an emotion when clicked', () => { + expectedParams = { + mutation: PostMutations().AddPostEmotionsMutation, + variables: { to: { id: 'p143' }, data: { emotion: 'funny' } }, + } + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + + it('increases the PostsEmotionsCountByEmotion for the emotion clicked', () => { + expect(wrapper.vm.PostsEmotionsCountByEmotion.funny).toEqual(1) + }) + + it('adds an emotion to selectedEmotions to show the colored image when the button is active', () => { + expect(wrapper.vm.selectedEmotions).toEqual(['funny']) + }) + + describe('removing emotions', () => { + beforeEach(() => { + funnyButton.trigger('click') + }) + + it('sends the RemovePostEmotionsMutation when a user clicks on an active emotion', () => { + expectedParams = { + mutation: PostMutations().RemovePostEmotionsMutation, + variables: { to: { id: 'p143' }, data: { emotion: 'funny' } }, + } + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + + it('decreases the PostsEmotionsCountByEmotion for the emotion clicked', async () => { + expect(wrapper.vm.PostsEmotionsCountByEmotion.funny).toEqual(0) + }) + + it('removes an emotion from selectedEmotions to show the default image', async () => { + expect(wrapper.vm.selectedEmotions).toEqual([]) + }) + }) + }) + }) +}) diff --git a/webapp/components/Emotions/Emotions.vue b/webapp/components/Emotions/Emotions.vue new file mode 100644 index 000000000..3be7ee790 --- /dev/null +++ b/webapp/components/Emotions/Emotions.vue @@ -0,0 +1,115 @@ + + diff --git a/webapp/components/EmotionsButton/EmotionsButton.vue b/webapp/components/EmotionsButton/EmotionsButton.vue new file mode 100644 index 000000000..b4849c31a --- /dev/null +++ b/webapp/components/EmotionsButton/EmotionsButton.vue @@ -0,0 +1,50 @@ + + + diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index e345531d0..c377e1838 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -13,57 +13,39 @@ describe('FilterMenu.vue', () => { let mocks let propsData - const createWrapper = mountMethod => { - return mountMethod(FilterMenu, { - propsData, - mocks, - localVue, - }) - } - beforeEach(() => { mocks = { $t: () => {} } - propsData = {} }) describe('given a user', () => { beforeEach(() => { propsData = { - user: { - id: '4711', - }, + hashtag: null, } }) describe('mount', () => { + const Wrapper = () => { + return mount(FilterMenu, { mocks, localVue, propsData }) + } beforeEach(() => { - wrapper = createWrapper(mount) + wrapper = Wrapper() }) - it('renders a card', () => { + it('does not render a card if there are no hashtags', () => { expect(wrapper.is('.ds-card')).toBe(true) }) - describe('click "filter-by-followed-authors-only" button', () => { - it('emits filterBubble object', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')).toBeTruthy() - }) + it('renders a card if there are hashtags', () => { + propsData.hashtag = 'Frieden' + wrapper = Wrapper() + expect(wrapper.is('.ds-card')).toBe(true) + }) - it('toggles filterBubble.author property', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([ - { author: { followedBy_some: { id: '4711' } } }, - ]) - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{}]) - }) - - it('makes button primary', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect( - wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'), - ).toBe(true) + describe('click "clear-search-button" button', () => { + it('emits clearSearch', () => { + wrapper.find({ name: 'clear-search-button' }).trigger('click') + expect(wrapper.emitted().clearSearch).toHaveLength(1) }) }) }) diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue index 9bfb3ddba..c306588c7 100644 --- a/webapp/components/FilterMenu/FilterMenu.vue +++ b/webapp/components/FilterMenu/FilterMenu.vue @@ -1,26 +1,6 @@ @@ -99,6 +125,10 @@ export default { display: block; } +#filter-posts-by-followers-header { + display: block; +} + .categories-menu-item { text-align: center; } @@ -107,7 +137,8 @@ export default { justify-content: center; } -.category-labels { +.category-labels, +.follow-label { font-size: $font-size-small; } @@ -122,5 +153,8 @@ export default { #filter-posts-header { text-align: center; } + .follow-button { + float: left; + } } diff --git a/webapp/components/Modal/ConfirmModal.vue b/webapp/components/Modal/ConfirmModal.vue index 771eee103..147258849 100644 --- a/webapp/components/Modal/ConfirmModal.vue +++ b/webapp/components/Modal/ConfirmModal.vue @@ -28,8 +28,13 @@