diff --git a/backend/src/seed/factories/badges.js b/backend/src/seed/factories/badges.js index e24a67c21..6f5f8d69a 100644 --- a/backend/src/seed/factories/badges.js +++ b/backend/src/seed/factories/badges.js @@ -3,21 +3,26 @@ import uuid from 'uuid/v4' export default function (params) { const { id = uuid(), - key, + key = '', type = 'crowdfunding', status = 'permanent', - icon + icon = '/img/badges/indiegogo_en_panda.svg' } = params - return ` - mutation { - CreateBadge( - id: "${id}", - key: "${key}", - type: ${type}, - status: ${status}, - icon: "${icon}" - ) { id } + return { + mutation: ` + mutation( + $id: ID + $key: String! + $type: BadgeTypeEnum! + $status: BadgeStatusEnum! + $icon: String! + ) { + CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) { + id + } + } + `, + variables: { id, key, type, status, icon } } - ` } diff --git a/backend/src/seed/factories/categories.js b/backend/src/seed/factories/categories.js index a4b448f4b..5c1b3ce10 100644 --- a/backend/src/seed/factories/categories.js +++ b/backend/src/seed/factories/categories.js @@ -8,14 +8,15 @@ export default function (params) { icon } = params - return ` - mutation { - CreateCategory( - id: "${id}", - name: "${name}", - slug: "${slug}", - icon: "${icon}" - ) { id, name } + return { + mutation: ` + mutation($id: ID, $name: String!, $slug: String, $icon: String!) { + CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) { + id + name + } } - ` + `, + variables: { id, name, slug, icon } + } } diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index 92dca5b14..9964d0559 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -7,19 +7,17 @@ export default function (params) { content = [ faker.lorem.sentence(), faker.lorem.sentence() - ].join('. '), - disabled = false, - deleted = false + ].join('. ') } = params - return ` - mutation { - CreateComment( - id: "${id}", - content: "${content}", - disabled: ${disabled}, - deleted: ${deleted} - ) { id } - } - ` + return { + mutation: ` + mutation($id: ID!, $content: String!) { + CreateComment(id: $id, content: $content) { + id + } + } + `, + variables: { id, content } + } } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 7c23226cb..a0cb310ab 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -71,8 +71,8 @@ export default function Factory (options = {}) { return this }, async create (node, properties) { - const mutation = this.factories[node](properties) - this.lastResponse = await this.graphQLClient.request(mutation) + const { mutation, variables } = this.factories[node](properties) + this.lastResponse = await this.graphQLClient.request(mutation, variables) return this }, async relate (node, relationship, properties) { diff --git a/backend/src/seed/factories/notifications.js b/backend/src/seed/factories/notifications.js index 2e2abdd55..f7797200f 100644 --- a/backend/src/seed/factories/notifications.js +++ b/backend/src/seed/factories/notifications.js @@ -6,12 +6,15 @@ export default function (params) { read = false } = params - return ` - mutation { - CreateNotification( - id: "${id}", - read: ${read}, - ) { id, read } - } - ` + return { + mutation: ` + mutation($id: ID, $read: Boolean) { + CreateNotification(id: $id, read: $read) { + id + read + } + } + `, + variables: { id, read } + } } diff --git a/backend/src/seed/factories/organizations.js b/backend/src/seed/factories/organizations.js index e0b2e52d4..dd4100b26 100644 --- a/backend/src/seed/factories/organizations.js +++ b/backend/src/seed/factories/organizations.js @@ -5,20 +5,17 @@ export default function create (params) { const { id = uuid(), name = faker.company.companyName(), - description = faker.company.catchPhrase(), - disabled = false, - deleted = false + description = faker.company.catchPhrase() } = params - return ` - mutation { - CreateOrganization( - id: "${id}", - name: "${name}", - description: "${description}", - disabled: ${disabled}, - deleted: ${deleted} - ) { name } - } - ` + return { + mutation: ` + mutation($id: ID!, $name: String!, $description: String!) { + CreateOrganization(id: $id, name: $name, description: $description) { + name + } + } + `, + variables: { id, name, description } + } } diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 83d5844d7..cbc73dbf8 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -18,17 +18,31 @@ export default function (params) { deleted = false } = params - return ` - mutation { - CreatePost( - id: "${id}", - slug: "${slug}", - title: "${title}", - content: "${content}", - image: "${image}", - visibility: ${visibility}, - deleted: ${deleted} - ) { title, content } - } - ` + return { + mutation: ` + mutation( + $id: ID! + $slug: String + $title: String! + $content: String! + $image: String + $visibility: VisibilityEnum + $deleted: Boolean + ) { + CreatePost( + id: $id + slug: $slug + title: $title + content: $content + image: $image + visibility: $visibility + deleted: $deleted + ) { + title + content + } + } + `, + variables: { id, slug, title, content, image, visibility, deleted } + } } diff --git a/backend/src/seed/factories/reports.js b/backend/src/seed/factories/reports.js index ccf5299bd..130c20c37 100644 --- a/backend/src/seed/factories/reports.js +++ b/backend/src/seed/factories/reports.js @@ -6,15 +6,15 @@ export default function create (params) { id } = params - return ` - mutation { - report( - description: "${description}", - id: "${id}", - ) { - id, - createdAt + return { + mutation: ` + mutation($id: ID!, $description: String!) { + report(description: $description, id: $id) { + id + createdAt + } } - } - ` + `, + variables: { id, description } + } } diff --git a/backend/src/seed/factories/tags.js b/backend/src/seed/factories/tags.js index c603c5629..558b68957 100644 --- a/backend/src/seed/factories/tags.js +++ b/backend/src/seed/factories/tags.js @@ -3,15 +3,17 @@ import uuid from 'uuid/v4' export default function (params) { const { id = uuid(), - name + name = '#human-connection' } = params - return ` - mutation { - CreateTag( - id: "${id}", - name: "${name}", - ) { name } - } - ` + return { + mutation: ` + mutation($id: ID!, $name: String!) { + CreateTag(id: $id, name: $name) { + name + } + } + `, + variables: { id, name } + } } diff --git a/backend/src/seed/factories/users.js b/backend/src/seed/factories/users.js index 9fe957515..1bca0e243 100644 --- a/backend/src/seed/factories/users.js +++ b/backend/src/seed/factories/users.js @@ -10,34 +10,42 @@ export default function create (params) { password = '1234', role = 'user', avatar = faker.internet.avatar(), - about = faker.lorem.paragraph(), - disabled = false, - deleted = false + about = faker.lorem.paragraph() } = params - return ` - mutation { - CreateUser( - id: "${id}", - name: "${name}", - slug: "${slug}", - password: "${password}", - email: "${email}", - avatar: "${avatar}", - about: "${about}", - role: ${role}, - disabled: ${disabled}, - deleted: ${deleted} + return { + mutation: ` + mutation( + $id: ID! + $name: String + $slug: String + $password: String! + $email: String + $avatar: String + $about: String + $role: UserGroupEnum ) { - id - name - slug - email - avatar - role - deleted - disabled + CreateUser( + id: $id + name: $name + slug: $slug + password: $password + email: $email + avatar: $avatar + about: $about + role: $role + ) { + id + name + slug + email + avatar + role + deleted + disabled + } } - } - ` + `, + variables: { id, name, slug, password, email, avatar, about, role } + } } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 3de70e643..149b461b1 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -1,3 +1,4 @@ +import faker from 'faker' import Factory from './factories' /* eslint-disable no-multi-spaces */ @@ -88,20 +89,23 @@ import Factory from './factories' f.create('Tag', { id: 't4', name: 'Freiheit' }) ]) + const mention1 = 'Hey @jenny-rostock, what\'s up?' + const mention2 = 'Hey @jenny-rostock, here is another notification for you!' + await Promise.all([ asAdmin.create('Post', { id: 'p0' }), asModerator.create('Post', { id: 'p1' }), - asUser.create('Post', { id: 'p2', deleted: true }), + asUser.create('Post', { id: 'p2' }), asTick.create('Post', { id: 'p3' }), asTrick.create('Post', { id: 'p4' }), asTrack.create('Post', { id: 'p5' }), asAdmin.create('Post', { id: 'p6' }), - asModerator.create('Post', { id: 'p7' }), + asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }), asUser.create('Post', { id: 'p8' }), asTick.create('Post', { id: 'p9' }), asTrick.create('Post', { id: 'p10' }), asTrack.create('Post', { id: 'p11' }), - asAdmin.create('Post', { id: 'p12' }), + asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }), asModerator.create('Post', { id: 'p13' }), asUser.create('Post', { id: 'p14' }), asTick.create('Post', { id: 'p15' }) diff --git a/cypress/README.md b/cypress/README.md index e55ff22af..92b1b8185 100644 --- a/cypress/README.md +++ b/cypress/README.md @@ -1,10 +1,23 @@ # End-to-End Testing +## Configure cypress + +First, you have to tell cypress how to connect to your local neo4j database +among other things. You can copy our template configuration and change the new +file according to your needs. + +Make sure you are at the root level of the project. Then: +```bash +# in the top level folder Human-Connection/ +$ cp cypress.env.template.json cypress.env.json +``` + ## Run Tests -To run the tests, make sure you are at the root level of the project, in your console and run the following command: +To run the tests, do this: ```bash +# in the top level folder Human-Connection/ $ yarn cypress:setup ``` diff --git a/cypress/features.md b/cypress/features.md index 345840e39..eb8292c3b 100644 --- a/cypress/features.md +++ b/cypress/features.md @@ -83,6 +83,13 @@ The following features will be implemented. This gets done in three steps: * Editing Comments * Upvote comments of others +### Notifications +[Cucumber features](./integration/notifications) + +* User @-mentionings +* Notify authors for comments +* Administrative notifications to all users + ### Contribution List * Show Posts by Tiles diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 9478d8d4e..85a660f43 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -293,3 +293,43 @@ Then('I can login successfully with password {string}', password => { }) cy.get('.iziToast-wrapper').should('contain', "You are logged in!") }) + +When('I log in with the following credentials:', table => { + const { email, password } = table.hashes()[0] + cy.login({ email, password }) +}) + +When('open the notification menu and click on the first item', () => { + cy.get('.notifications-menu').click() + cy.get('.notification-mention-post').first().click() +}) + +Then('see {int} unread notifications in the top menu', count => { + cy.get('.notifications-menu').should('contain', count) +}) + +Then('I get to the post page of {string}', path => { + path = path.replace('...', '') + cy.url().should('contain', '/post/') + cy.url().should('contain', path) +}) + +When('I start to write a new post with the title {string} beginning with:', (title, intro) => { + cy.get('.post-add-button').click() + cy.get('input[name="title"]').type(title) + cy.get('.ProseMirror').type(intro) +}) + +When('mention {string} in the text', (mention) => { + cy.get('.ProseMirror').type(' @') + cy.get('.suggestion-list__item').contains(mention).click() + cy.debug() +}) + +Then('the notification gets marked as read', () => { + cy.get('.notification').first().should('have.class', 'read') +}) + +Then('there are no notifications in the top menu', () => { + cy.get('.notifications-menu').should('contain', '0') +}) diff --git a/cypress/integration/notifications/Mentions.feature b/cypress/integration/notifications/Mentions.feature new file mode 100644 index 000000000..28f7cf456 --- /dev/null +++ b/cypress/integration/notifications/Mentions.feature @@ -0,0 +1,31 @@ +Feature: Notifications for a mentions + As a user + I want to be notified if sb. mentions me in a post or comment + In order join conversations about or related to me + + Background: + Given we have the following user accounts: + | name | slug | email | password | + | Wolle aus Hamburg | wolle-aus-hamburg | wolle@example.org | 1234 | + | Matt Rider | matt-rider | matt@example.org | 4321 | + + Scenario: Mention another user, re-login as this user and see notifications + Given I log in with the following credentials: + | email | password | + | wolle@example.org | 1234 | + And I start to write a new post with the title "Hey Matt" beginning with: + """ + Big shout to our fellow contributor + """ + And mention "@matt-rider" in the text + And I click on "Save" + When I log out + And I log in with the following credentials: + | email | password | + | matt@example.org | 4321 | + And see 1 unread notifications in the top menu + And open the notification menu and click on the first item + Then I get to the post page of ".../hey-matt" + And the notification gets marked as read + But when I refresh the page + Then there are no notifications in the top menu diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5b4c2055b..a7cb76a27 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -46,7 +46,8 @@ Cypress.Commands.add('login', ({ email, password }) => { cy.get('button[name=submit]') .as('submitButton') .click() - cy.location('pathname').should('eq', '/') // we're in! + cy.get('.iziToast-message').should('contain', 'You are logged in!') + cy.get('.iziToast-close').click() }) Cypress.Commands.add('logout', (email, password) => { diff --git a/webapp/components/PostCard.vue b/webapp/components/PostCard/index.vue similarity index 80% rename from webapp/components/PostCard.vue rename to webapp/components/PostCard/index.vue index 4bb1e4693..acddba5d6 100644 --- a/webapp/components/PostCard.vue +++ b/webapp/components/PostCard/index.vue @@ -4,11 +4,12 @@ :image="post.image" :class="{'post-card': true, 'disabled-content': post.disabled}" > - {{ post.title }} + :to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }" + > + {{ post.title }} + @@ -75,6 +76,7 @@ import HcUser from '~/components/User' import ContentMenu from '~/components/ContentMenu' import { randomBytes } from 'crypto' +import { mapGetters } from 'vuex' export default { name: 'HcPostCard', @@ -89,26 +91,16 @@ export default { } }, computed: { + ...mapGetters({ + user: 'auth/user' + }), excerpt() { - // remove all links from excerpt to prevent issues with the serounding link - let excerpt = this.post.contentExcerpt.replace(/(.+)<\/a>/gim, '$1') - // do not display content that is only linebreaks - if (excerpt.replace(/
/gim, '').trim() === '') { - excerpt = '' - } - - return excerpt + return this.$filters.removeLinks(this.post.contentExcerpt) }, isAuthor() { - return this.$store.getters['auth/user'].id === this.post.author.id - } - }, - methods: { - href(post) { - return this.$router.resolve({ - name: 'post-id-slug', - params: { id: post.id, slug: post.slug } - }).href + const { author } = this.post + if (!author) return false + return this.user.id === this.post.author.id } } } @@ -130,6 +122,7 @@ export default { } .post-link { + margin: 15px; display: block; position: absolute; top: 0; diff --git a/webapp/components/PostCard/spec.js b/webapp/components/PostCard/spec.js new file mode 100644 index 000000000..1914733c0 --- /dev/null +++ b/webapp/components/PostCard/spec.js @@ -0,0 +1,62 @@ +import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' +import PostCard from '.' +import Styleguide from '@human-connection/styleguide' +import Vuex from 'vuex' +import Filters from '~/plugins/vue-filters' + +const localVue = createLocalVue() + +localVue.use(Vuex) +localVue.use(Styleguide) +localVue.use(Filters) + +config.stubs['no-ssr'] = '' +config.stubs['v-popover'] = '' + +describe('PostCard', () => { + let wrapper + let stubs + let mocks + let propsData + let getters + + beforeEach(() => { + propsData = {} + stubs = { + NuxtLink: RouterLinkStub + } + mocks = { + $t: jest.fn() + } + getters = { + 'auth/user': () => { + return {} + } + } + }) + + const Wrapper = () => { + const store = new Vuex.Store({ + getters + }) + return mount(PostCard, { + stubs, + mocks, + propsData, + store, + localVue + }) + } + + describe('given a post', () => { + beforeEach(() => { + propsData.post = { + title: "It's a title" + } + }) + + it('renders title', () => { + expect(Wrapper().text()).toContain("It's a title") + }) + }) +}) diff --git a/webapp/components/notifications/Notification/index.vue b/webapp/components/notifications/Notification/index.vue new file mode 100644 index 000000000..cae34a2f1 --- /dev/null +++ b/webapp/components/notifications/Notification/index.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/webapp/components/notifications/Notification/spec.js b/webapp/components/notifications/Notification/spec.js new file mode 100644 index 000000000..8c6c846a4 --- /dev/null +++ b/webapp/components/notifications/Notification/spec.js @@ -0,0 +1,64 @@ +import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' +import Notification from '.' +import Styleguide from '@human-connection/styleguide' +import Filters from '~/plugins/vue-filters' + +const localVue = createLocalVue() + +localVue.use(Styleguide) +localVue.use(Filters) + +config.stubs['no-ssr'] = '' + +describe('Notification', () => { + let wrapper + let stubs + let mocks + let propsData + beforeEach(() => { + propsData = {} + mocks = { + $t: jest.fn() + } + stubs = { + NuxtLink: RouterLinkStub + } + }) + + const Wrapper = () => { + return mount(Notification, { + stubs, + mocks, + propsData, + localVue + }) + } + + describe('given a notification', () => { + beforeEach(() => { + propsData.notification = { + post: { + title: "It's a title" + } + } + }) + + it('renders title', () => { + expect(Wrapper().text()).toContain("It's a title") + }) + + it('has no class "read"', () => { + expect(Wrapper().classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + }) + + it('has class "read"', () => { + expect(Wrapper().classes()).toContain('read') + }) + }) + }) +}) diff --git a/webapp/components/notifications/NotificationList/index.vue b/webapp/components/notifications/NotificationList/index.vue new file mode 100644 index 000000000..b8235f853 --- /dev/null +++ b/webapp/components/notifications/NotificationList/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/webapp/components/notifications/NotificationList/spec.js b/webapp/components/notifications/NotificationList/spec.js new file mode 100644 index 000000000..c433abd80 --- /dev/null +++ b/webapp/components/notifications/NotificationList/spec.js @@ -0,0 +1,130 @@ +import { + config, + shallowMount, + mount, + createLocalVue, + RouterLinkStub +} from '@vue/test-utils' +import NotificationList from '.' +import Notification from '../Notification' +import Vue from 'vue' +import Vuex from 'vuex' +import Filters from '~/plugins/vue-filters' + +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Vuex) +localVue.use(Styleguide) +localVue.use(Filters) +localVue.filter('truncate', string => string) + +config.stubs['no-ssr'] = '' +config.stubs['v-popover'] = '' + +describe('NotificationList.vue', () => { + let wrapper + let Wrapper + let mocks + let stubs + let store + let propsData + + beforeEach(() => { + store = new Vuex.Store({ + getters: { + 'auth/user': () => { + return {} + } + } + }) + mocks = { + $t: jest.fn() + } + stubs = { + NuxtLink: RouterLinkStub + } + propsData = { + notifications: [ + { + id: 'notification-41', + read: false, + post: { + id: 'post-1', + title: 'some post title', + contentExcerpt: 'this is a post content', + author: { + id: 'john-1', + slug: 'john-doe', + name: 'John Doe' + } + } + }, + { + id: 'notification-42', + read: false, + post: { + id: 'post-2', + title: 'another post title', + contentExcerpt: 'this is yet another post content', + author: { + id: 'john-1', + slug: 'john-doe', + name: 'John Doe' + } + } + } + ] + } + }) + + describe('shallowMount', () => { + const Wrapper = () => { + return shallowMount(NotificationList, { + propsData, + mocks, + store, + localVue + }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders Notification.vue for each notification of the user', () => { + expect(wrapper.findAll(Notification)).toHaveLength(2) + }) + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(NotificationList, { + propsData, + mocks, + stubs, + store, + localVue + }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + describe('click on a notification', () => { + beforeEach(() => { + wrapper + .findAll('.notification-mention-post') + .at(1) + .trigger('click') + }) + + it("emits 'markAsRead' with the notificationId", () => { + expect(wrapper.emitted('markAsRead')).toBeTruthy() + expect(wrapper.emitted('markAsRead')[0]).toEqual(['notification-42']) + }) + }) + }) +}) diff --git a/webapp/components/notifications/NotificationMenu/index.vue b/webapp/components/notifications/NotificationMenu/index.vue new file mode 100644 index 000000000..819858e3b --- /dev/null +++ b/webapp/components/notifications/NotificationMenu/index.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/webapp/components/notifications/NotificationMenu/spec.js b/webapp/components/notifications/NotificationMenu/spec.js new file mode 100644 index 000000000..4c9dd9d43 --- /dev/null +++ b/webapp/components/notifications/NotificationMenu/spec.js @@ -0,0 +1,94 @@ +import { config, shallowMount, createLocalVue } from '@vue/test-utils' +import NotificationMenu from '.' + +import Styleguide from '@human-connection/styleguide' +import Filters from '~/plugins/vue-filters' + +const localVue = createLocalVue() + +localVue.use(Styleguide) +localVue.use(Filters) +localVue.filter('truncate', string => string) + +config.stubs['dropdown'] = '' + +describe('NotificationMenu.vue', () => { + let wrapper + let Wrapper + let mocks + let data + beforeEach(() => { + mocks = { + $t: jest.fn() + } + data = () => { + return { + notifications: [] + } + } + }) + + describe('shallowMount', () => { + const Wrapper = () => { + return shallowMount(NotificationMenu, { + data, + mocks, + localVue + }) + } + + it('counter displays 0', () => { + wrapper = Wrapper() + expect(wrapper.find('ds-button-stub').text()).toEqual('0') + }) + + it('no dropdown is rendered', () => { + wrapper = Wrapper() + expect(wrapper.contains('.dropdown')).toBe(false) + }) + + describe('given some notifications', () => { + beforeEach(() => { + data = () => { + return { + notifications: [ + { + id: 'notification-41', + read: false, + post: { + id: 'post-1', + title: 'some post title', + contentExcerpt: 'this is a post content', + author: { + id: 'john-1', + slug: 'john-doe', + name: 'John Doe' + } + } + }, + { + id: 'notification-42', + read: false, + post: { + id: 'post-2', + title: 'another post title', + contentExcerpt: 'this is yet another post content', + author: { + id: 'john-1', + slug: 'john-doe', + name: 'John Doe' + } + } + } + ] + } + } + }) + + it('displays the total number of notifications', () => { + wrapper = Wrapper() + expect(wrapper.find('ds-button-stub').text()).toEqual('2') + }) + }) + }) +}) diff --git a/webapp/layouts/blank.vue b/webapp/layouts/blank.vue index 140ec9f6e..570799a4d 100644 --- a/webapp/layouts/blank.vue +++ b/webapp/layouts/blank.vue @@ -10,7 +10,7 @@