Store Hashtags of Post content in database and write a lot of tests

Co-Authored-By: mattwr18 <mattwr18@gmail.com>
This commit is contained in:
Wolfgang Huß 2019-07-01 19:51:43 +02:00
parent f7196e8663
commit a4cf2d3ee8
10 changed files with 282 additions and 71 deletions

View File

@ -21,16 +21,25 @@ const notify = async (postId, idsOfMentionedUsers, context) => {
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
const session = context.driver.session() const session = context.driver.session()
const cypher = ` // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
MATCH (p:Post { id: $postId })-[oldRelations:TAGGED]->(oldTags:Tag) // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
DELETE oldRelations // and no new Hashtags and relations will be created.
WITH p 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 UNWIND $hashtags AS tagName
MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false }) MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t) MERGE (p)-[:TAGGED]->(t)
RETURN p, t RETURN p, t
` `
await session.run(cypher, { await session.run(cypherDeletePreviousRelations, {
postId,
})
await session.run(cypherCreateNewTagsAndRelations, {
postId, postId,
hashtags, hashtags,
}) })
@ -38,18 +47,14 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => {
} }
const handleContentData = async (resolve, root, args, context, resolveInfo) => { 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 // extract user ids before xss-middleware removes classes via the following "resolve" call
const idsOfMentionedUsers = extractMentionedUsers(args.content) const idsOfMentionedUsers = extractMentionedUsers(args.content)
console.log('idsOfMentionedUsers: ', idsOfMentionedUsers)
// extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call // extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call
const hashtags = extractHashtags(args.content) const hashtags = extractHashtags(args.content)
console.log('hashtags: ', hashtags)
// removes classes from the content // removes classes from the content
const post = await resolve(root, args, context, resolveInfo) const post = await resolve(root, args, context, resolveInfo)
console.log('post.id: ', post.id)
await notify(post.id, idsOfMentionedUsers, context) await notify(post.id, idsOfMentionedUsers, context)
await updateHashtagsOfPost(post.id, hashtags, context) await updateHashtagsOfPost(post.id, hashtags, context)
@ -61,4 +66,4 @@ export default {
CreatePost: handleContentData, CreatePost: handleContentData,
UpdatePost: handleContentData, UpdatePost: handleContentData,
}, },
} }

View File

@ -1,5 +1,11 @@
import { GraphQLClient } from 'graphql-request' import {
import { host, login } from '../../jest/helpers' GraphQLClient
} from 'graphql-request'
import gql from 'graphql-tag'
import {
host,
login
} from '../../jest/helpers'
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
const factory = Factory() const factory = Factory()
@ -20,22 +26,29 @@ afterEach(async () => {
}) })
describe('currentUser { notifications }', () => { describe('currentUser { notifications }', () => {
const query = `query($read: Boolean) { const query = gql `
currentUser { query($read: Boolean) {
notifications(read: $read, orderBy: createdAt_desc) { currentUser {
read notifications(read: $read, orderBy: createdAt_desc) {
post { read
content post {
} content
}
} }
}` }
}
}
`
describe('authenticated', () => { describe('authenticated', () => {
let headers let headers
beforeEach(async () => { beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' }) headers = await login({
client = new GraphQLClient(host, { headers }) email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
}) })
describe('given another user', () => { describe('given another user', () => {
@ -60,17 +73,24 @@ describe('currentUser { notifications }', () => {
'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?' 'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
beforeEach(async () => { beforeEach(async () => {
const createPostMutation = ` const createPostMutation = gql `
mutation($title: String!, $content: String!) { mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) { CreatePost(title: $title, content: $content) {
id id
title title
content content
}
} }
}
` `
authorClient = new GraphQLClient(host, { headers: authorHeaders }) authorClient = new GraphQLClient(host, {
const { CreatePost } = await authorClient.request(createPostMutation, { title, content }) headers: authorHeaders,
})
const {
CreatePost
} = await authorClient.request(createPostMutation, {
title,
content,
})
post = CreatePost post = CreatePost
}) })
@ -79,10 +99,19 @@ describe('currentUser { notifications }', () => {
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?' 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = { const expected = {
currentUser: { 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', () => { describe('who mentions me again', () => {
@ -93,16 +122,21 @@ describe('currentUser { notifications }', () => {
// during development and thought: A feature not a bug! This way we // 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 // can encode a re-mentioning of users when you edit your post or
// comment. // comment.
const createPostMutation = ` const createPostMutation = gql `
mutation($id: ID!, $content: String!) { mutation($id: ID!, $content: String!) {
UpdatePost(id: $id, content: $content) { UpdatePost(id: $id, content: $content) {
title title
content content
}
} }
}
` `
authorClient = new GraphQLClient(host, { headers: authorHeaders }) authorClient = new GraphQLClient(host, {
await authorClient.request(createPostMutation, { id: post.id, content: updatedContent }) headers: authorHeaders,
})
await authorClient.request(createPostMutation, {
id: post.id,
content: updatedContent,
})
}) })
it('creates exactly one more notification', async () => { it('creates exactly one more notification', async () => {
@ -110,16 +144,135 @@ describe('currentUser { notifications }', () => {
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>' 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
const expected = { const expected = {
currentUser: { currentUser: {
notifications: [ notifications: [{
{ read: false, post: { content: expectedContent } }, read: false,
{ read: false, post: { content: expectedContent } }, 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 =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
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 =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
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),
}, ],
})
})
})
})
})
})

View File

@ -1,21 +1,22 @@
import cheerio from 'cheerio' import cheerio from 'cheerio'
const ID_REGEX = /\/search\/hashtag\/([\w\-.!~*'"(),]+)/g const ID_REGEX = /\/search\/hashtag\/([\w\-.!~*'"(),]+)/g
export default function(content) { export default function (content) {
if (!content) return [] if (!content) return []
const $ = cheerio.load(content) 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) => { .map((_, el) => {
return $(el).attr('href') return $(el).attr('href')
}) })
.get() .get()
const hashtags = [] const hashtags = []
urls.forEach(url => { urls.forEach(url => {
console.log('url: ', url)
let match let match
while ((match = ID_REGEX.exec(url)) != null) { while ((match = ID_REGEX.exec(url)) != null) {
hashtags.push(match[1]) hashtags.push(match[1])
} }
}) })
return hashtags return hashtags
} }

View File

@ -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 =
'<p><a class="hashtag" href="/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>'
expect(extractHashtags(content)).toEqual(['Elections', 'Democracy'])
})
it('ignores mentions', () => {
const content =
'<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractHashtags(content)).toEqual([])
})
describe('handles links', () => {
it('with domains', () => {
const content =
'<p><a class="hashtag" href="http://localhost:3000/search/hashtag/Elections">#Elections</a><a href="http://localhost:3000/search/hashtag/Democracy">#Democracy</a></p>'
expect(extractHashtags(content)).toEqual(['Elections', 'Democracy'])
})
it('special characters', () => {
const content =
'<p>Something inspirational about <a href="/search/hashtag/u!*(),2" class="hashtag" target="_blank">#u!*(),2</a> and <a href="/search/hashtag/u.~-3" target="_blank">#u.~-3</a>.</p>'
expect(extractHashtags(content)).toEqual(['u!*(),2', 'u.~-3'])
})
})
describe('does not crash if', () => {
it('`href` contains no Hashtag name', () => {
const content =
'<p>Something inspirational about <a href="/search/hashtag/" target="_blank">#Democracy</a> and <a href="/search/hashtag" target="_blank">#liberty</a>.</p>'
expect(extractHashtags(content)).toEqual([])
})
it('`href` is empty or invalid', () => {
const content =
'<p>Something inspirational about <a href="" class="hashtag" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" target="_blank">@jenny-rostock</a>.</p>'
expect(extractHashtags(content)).toEqual([])
})
})
})
})

View File

@ -1,7 +1,7 @@
import cheerio from 'cheerio' import cheerio from 'cheerio'
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g
export default function(content) { export default function (content) {
if (!content) return [] if (!content) return []
const $ = cheerio.load(content) const $ = cheerio.load(content)
const urls = $('.mention') const urls = $('.mention')
@ -11,11 +11,10 @@ export default function(content) {
.get() .get()
const ids = [] const ids = []
urls.forEach(url => { urls.forEach(url => {
console.log('url: ', url)
let match let match
while ((match = ID_REGEX.exec(url)) != null) { while ((match = ID_REGEX.exec(url)) != null) {
ids.push(match[1]) ids.push(match[1])
} }
}) })
return ids return ids
} }

View File

@ -1,9 +1,9 @@
import extractIds from './extractMentionedUsers' import extractMentionedUsers from './extractMentionedUsers'
describe('extractIds', () => { describe('extractMentionedUsers', () => {
describe('content undefined', () => { describe('content undefined', () => {
it('returns empty array', () => { it('returns empty array', () => {
expect(extractIds()).toEqual([]) expect(extractMentionedUsers()).toEqual([])
}) })
}) })
@ -11,33 +11,33 @@ describe('extractIds', () => {
it('ignores links without .mention class', () => { it('ignores links without .mention class', () => {
const content = const content =
'<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([]) expect(extractMentionedUsers(content)).toEqual([])
}) })
describe('given a link with .mention class', () => { describe('given a link with .mention class', () => {
it('extracts ids', () => { it('extracts ids', () => {
const content = const content =
'<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3']) expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
}) })
describe('handles links', () => { describe('handles links', () => {
it('with slug and id', () => { it('with slug and id', () => {
const content = const content =
'<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3']) expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
}) })
it('with domains', () => { it('with domains', () => {
const content = const content =
'<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3']) expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
}) })
it('special characters', () => { it('special characters', () => {
const content = const content =
'<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
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', () => { it('`href` contains no user id', () => {
const content = const content =
'<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([]) expect(extractMentionedUsers(content)).toEqual([])
}) })
it('`href` is empty or invalid', () => { it('`href` is empty or invalid', () => {
const content = const content =
'<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>' '<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([]) expect(extractMentionedUsers(content)).toEqual([])
}) })
}) })
}) })
}) })
}) })

View File

@ -28,16 +28,16 @@
<ds-space /> <ds-space />
<div slot="footer" style="text-align: right"> <div slot="footer" style="text-align: right">
<ds-button <ds-button
class="cancel-button"
:disabled="loading || disabled" :disabled="loading || disabled"
ghost ghost
class="cancel-button" @click.prevent="$router.back()"
@click="$router.back()"
> >
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</ds-button> </ds-button>
<ds-button <ds-button
icon="check"
type="submit" type="submit"
icon="check"
:loading="loading" :loading="loading"
:disabled="disabled || errors" :disabled="disabled || errors"
primary primary
@ -52,7 +52,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcEditor from '~/components/Editor' import HcEditor from '~/components/Editor/Editor'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import locales from '~/locales' import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js' import PostMutations from '~/graphql/PostMutations.js'

View File

@ -1,5 +1,5 @@
import { mount, createLocalVue } from '@vue/test-utils' import { mount, createLocalVue } from '@vue/test-utils'
import Editor from './' import Editor from './Editor'
import Vuex from 'vuex' import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
@ -36,7 +36,9 @@ describe('Editor.vue', () => {
propsData, propsData,
localVue, localVue,
sync: false, sync: false,
stubs: { transition: false }, stubs: {
transition: false,
},
store, store,
})) }))
} }

View File

@ -24,7 +24,7 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcEditor from '~/components/Editor' import HcEditor from '~/components/Editor/Editor'
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js' import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
import CommentMutations from '~/graphql/CommentMutations.js' import CommentMutations from '~/graphql/CommentMutations.js'