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 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,
},
}
}

View File

@ -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 <a class="mention" href="/profile/you/al-capone">@al-capone</a> 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 <a href="/profile/you/al-capone" target="_blank">@al-capone</a> 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 <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 = {
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 =
'<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'
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
}
}

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'
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
}
}

View File

@ -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 =
'<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', () => {
it('extracts ids', () => {
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>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})
describe('handles links', () => {
it('with slug and id', () => {
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>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})
it('with domains', () => {
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>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})
it('special characters', () => {
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>'
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 =
'<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', () => {
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>'
expect(extractIds(content)).toEqual([])
expect(extractMentionedUsers(content)).toEqual([])
})
})
})
})
})
})

View File

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

View File

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

View File

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