diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js index 350be75f4..8efdef90f 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.js @@ -21,16 +21,25 @@ const notify = async (postId, idsOfMentionedUsers, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => { const session = context.driver.session() - const cypher = ` - MATCH (p:Post { id: $postId })-[oldRelations:TAGGED]->(oldTags:Tag) - DELETE oldRelations - WITH p + // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement + // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted + // and no new Hashtags and relations will be created. + const cypherDeletePreviousRelations = ` + MATCH (p:Post { id: $postId })-[previousRelations:TAGGED]->(t:Tag) + DELETE previousRelations + RETURN p, t + ` + const cypherCreateNewTagsAndRelations = ` + MATCH (p:Post { id: $postId}) UNWIND $hashtags AS tagName MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false }) MERGE (p)-[:TAGGED]->(t) RETURN p, t ` - await session.run(cypher, { + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { postId, hashtags, }) @@ -38,18 +47,14 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => { } const handleContentData = async (resolve, root, args, context, resolveInfo) => { - console.log('args.content: ', args.content) // extract user ids before xss-middleware removes classes via the following "resolve" call const idsOfMentionedUsers = extractMentionedUsers(args.content) - console.log('idsOfMentionedUsers: ', idsOfMentionedUsers) // extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call const hashtags = extractHashtags(args.content) - console.log('hashtags: ', hashtags) // removes classes from the content const post = await resolve(root, args, context, resolveInfo) - console.log('post.id: ', post.id) await notify(post.id, idsOfMentionedUsers, context) await updateHashtagsOfPost(post.id, hashtags, context) @@ -61,4 +66,4 @@ export default { CreatePost: handleContentData, UpdatePost: handleContentData, }, -} +} \ No newline at end of file diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js index 985654b0f..12b34dff0 100644 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js @@ -1,5 +1,11 @@ -import { GraphQLClient } from 'graphql-request' -import { host, login } from '../../jest/helpers' +import { + GraphQLClient +} from 'graphql-request' +import gql from 'graphql-tag' +import { + host, + login +} from '../../jest/helpers' import Factory from '../../seed/factories' const factory = Factory() @@ -20,22 +26,29 @@ afterEach(async () => { }) describe('currentUser { notifications }', () => { - const query = `query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - } + const query = gql ` + query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + post { + content } - }` + } + } + } + ` describe('authenticated', () => { let headers beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) }) describe('given another user', () => { @@ -60,17 +73,24 @@ describe('currentUser { notifications }', () => { 'Hey @al-capone how do you do?' beforeEach(async () => { - const createPostMutation = ` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { - id - title - content + const createPostMutation = gql ` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + id + title + content + } } - } ` - authorClient = new GraphQLClient(host, { headers: authorHeaders }) - const { CreatePost } = await authorClient.request(createPostMutation, { title, content }) + authorClient = new GraphQLClient(host, { + headers: authorHeaders, + }) + const { + CreatePost + } = await authorClient.request(createPostMutation, { + title, + content, + }) post = CreatePost }) @@ -79,10 +99,19 @@ describe('currentUser { notifications }', () => { 'Hey @al-capone how do you do?' const expected = { currentUser: { - notifications: [{ read: false, post: { content: expectedContent } }], + notifications: [{ + read: false, + post: { + content: expectedContent, + }, + }, ], }, } - await expect(client.request(query, { read: false })).resolves.toEqual(expected) + await expect( + client.request(query, { + read: false, + }), + ).resolves.toEqual(expected) }) describe('who mentions me again', () => { @@ -93,16 +122,21 @@ describe('currentUser { notifications }', () => { // during development and thought: A feature not a bug! This way we // can encode a re-mentioning of users when you edit your post or // comment. - const createPostMutation = ` - mutation($id: ID!, $content: String!) { - UpdatePost(id: $id, content: $content) { - title - content + const createPostMutation = gql ` + mutation($id: ID!, $content: String!) { + UpdatePost(id: $id, content: $content) { + title + content + } } - } ` - authorClient = new GraphQLClient(host, { headers: authorHeaders }) - await authorClient.request(createPostMutation, { id: post.id, content: updatedContent }) + authorClient = new GraphQLClient(host, { + headers: authorHeaders, + }) + await authorClient.request(createPostMutation, { + id: post.id, + content: updatedContent, + }) }) it('creates exactly one more notification', async () => { @@ -110,16 +144,135 @@ describe('currentUser { notifications }', () => { 'Hey @al-capone how do you do? One more mention to @al-capone' const expected = { currentUser: { - notifications: [ - { read: false, post: { content: expectedContent } }, - { read: false, post: { content: expectedContent } }, + notifications: [{ + read: false, + post: { + content: expectedContent, + }, + }, + { + read: false, + post: { + content: expectedContent, + }, + }, ], }, } - await expect(client.request(query, { read: false })).resolves.toEqual(expected) + await expect( + client.request(query, { + read: false, + }), + ).resolves.toEqual(expected) }) }) }) }) }) }) + +describe('Hashtags', () => { + const postId = 'p135' + const postTitle = 'Two Hashtags' + const postContent = + '
Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.
' + const postWithHastagsQuery = gql ` + query($id: ID) { + Post(id: $id) { + tags { + name + } + } + } + ` + const postWithHastagsVariables = { + id: postId, + } + const createPostMutation = gql ` + mutation($postId: ID, $postTitle: String!, $postContent: String!) { + CreatePost(id: $postId, title: $postTitle, content: $postContent) { + id + title + content + } + } + ` + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) + }) + + describe('create a Post with Hashtags', () => { + beforeEach(async () => { + await client.request(createPostMutation, { + postId, + postTitle, + postContent, + }) + }) + + it('both Hashtags are created', async () => { + const expected = [{ + name: 'Democracy', + }, + { + name: 'Liberty', + }, + ] + await expect( + client.request(postWithHastagsQuery, postWithHastagsVariables), + ).resolves.toEqual({ + Post: [{ + tags: expect.arrayContaining(expected), + }, ], + }) + }) + + describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { + // The already existing Hashtag has no class at this point. + const updatedPostContent = + 'Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.
' + const updatePostMutation = gql ` + mutation($postId: ID!, $postTitle: String, $updatedPostContent: String) { + UpdatePost(id: $postId, title: $postTitle, content: $updatedPostContent) { + id + title + content + } + } + ` + + it('only one previous Hashtag and the new Hashtag exists', async () => { + await client.request(updatePostMutation, { + postId, + postTitle, + updatedPostContent, + }) + + const expected = [{ + name: 'Elections', + }, + { + name: 'Liberty', + }, + ] + await expect( + client.request(postWithHastagsQuery, postWithHastagsVariables), + ).resolves.toEqual({ + Post: [{ + tags: expect.arrayContaining(expected), + }, ], + }) + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js index 9c0078e7a..a7c75d2f8 100644 --- a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js +++ b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js @@ -1,21 +1,22 @@ import cheerio from 'cheerio' const ID_REGEX = /\/search\/hashtag\/([\w\-.!~*'"(),]+)/g -export default function(content) { +export default function (content) { if (!content) return [] const $ = cheerio.load(content) - const urls = $('.hashtag') + // We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware. + // But we have to know, which Hashtags are removed from the content es well, so we search for the 'a' html-tag. + const urls = $('a') .map((_, el) => { return $(el).attr('href') }) .get() const hashtags = [] urls.forEach(url => { - console.log('url: ', url) let match while ((match = ID_REGEX.exec(url)) != null) { hashtags.push(match[1]) } }) return hashtags -} +} \ No newline at end of file diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js new file mode 100644 index 000000000..1b37aaa34 --- /dev/null +++ b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js @@ -0,0 +1,51 @@ +import extractHashtags from './extractHashtags' + +describe('extractHashtags', () => { + describe('content undefined', () => { + it('returns empty array', () => { + expect(extractHashtags()).toEqual([]) + }) + }) + + describe('searches through links', () => { + it('finds links with and without ".hashtag" class and extracts Hashtag names', () => { + const content = + '' + expect(extractHashtags(content)).toEqual(['Elections', 'Democracy']) + }) + + it('ignores mentions', () => { + const content = + 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' + expect(extractHashtags(content)).toEqual([]) + }) + + describe('handles links', () => { + it('with domains', () => { + const content = + '' + expect(extractHashtags(content)).toEqual(['Elections', 'Democracy']) + }) + + it('special characters', () => { + const content = + 'Something inspirational about #u!*(),2 and #u.~-3.
' + expect(extractHashtags(content)).toEqual(['u!*(),2', 'u.~-3']) + }) + }) + + describe('does not crash if', () => { + it('`href` contains no Hashtag name', () => { + const content = + 'Something inspirational about #Democracy and #liberty.
' + expect(extractHashtags(content)).toEqual([]) + }) + + it('`href` is empty or invalid', () => { + const content = + 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' + expect(extractHashtags(content)).toEqual([]) + }) + }) + }) +}) diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js index 3ada7e6a9..ccc9944b2 100644 --- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js +++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js @@ -1,7 +1,7 @@ import cheerio from 'cheerio' const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g -export default function(content) { +export default function (content) { if (!content) return [] const $ = cheerio.load(content) const urls = $('.mention') @@ -11,11 +11,10 @@ export default function(content) { .get() const ids = [] urls.forEach(url => { - console.log('url: ', url) let match while ((match = ID_REGEX.exec(url)) != null) { ids.push(match[1]) } }) return ids -} +} \ No newline at end of file diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js index 4983ad72b..fd60f96ab 100644 --- a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js +++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js @@ -1,9 +1,9 @@ -import extractIds from './extractMentionedUsers' +import extractMentionedUsers from './extractMentionedUsers' -describe('extractIds', () => { +describe('extractMentionedUsers', () => { describe('content undefined', () => { it('returns empty array', () => { - expect(extractIds()).toEqual([]) + expect(extractMentionedUsers()).toEqual([]) }) }) @@ -11,33 +11,33 @@ describe('extractIds', () => { it('ignores links without .mention class', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) describe('given a link with .mention class', () => { it('extracts ids', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) describe('handles links', () => { it('with slug and id', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) it('with domains', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) it('special characters', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3']) + expect(extractMentionedUsers(content)).toEqual(['u!*(),2', 'u.~-3']) }) }) @@ -45,15 +45,15 @@ describe('extractIds', () => { it('`href` contains no user id', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) it('`href` is empty or invalid', () => { const content = 'Something inspirational about @bob-der-baumeister and @jenny-rostock.
' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) }) }) }) -}) +}) \ No newline at end of file diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/index.vue index 4e775caaf..9444a1062 100644 --- a/webapp/components/ContributionForm/index.vue +++ b/webapp/components/ContributionForm/index.vue @@ -28,16 +28,16 @@