Merge pull request #1572 from Human-Connection/set_created_at_in_cypher

Fix bug where Post.createdAt is sometimes null
This commit is contained in:
mattwr18 2019-09-16 14:38:13 +02:00 committed by GitHub
commit de0837bbd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 175 additions and 131 deletions

View File

@ -1,18 +0,0 @@
const setCreatedAt = (resolve, root, args, context, info) => {
args.createdAt = new Date().toISOString()
return resolve(root, args, context, info)
}
const setUpdatedAt = (resolve, root, args, context, info) => {
args.updatedAt = new Date().toISOString()
return resolve(root, args, context, info)
}
export default {
Mutation: {
CreatePost: setCreatedAt,
CreateComment: setCreatedAt,
UpdateUser: setUpdatedAt,
UpdatePost: setUpdatedAt,
UpdateComment: setUpdatedAt,
},
}

View File

@ -5,7 +5,6 @@ import activityPub from './activityPubMiddleware'
import softDelete from './softDelete/softDeleteMiddleware' import softDelete from './softDelete/softDeleteMiddleware'
import sluggify from './sluggifyMiddleware' import sluggify from './sluggifyMiddleware'
import excerpt from './excerptMiddleware' import excerpt from './excerptMiddleware'
import dateTime from './dateTimeMiddleware'
import xss from './xssMiddleware' import xss from './xssMiddleware'
import permissions from './permissionsMiddleware' import permissions from './permissionsMiddleware'
import user from './userMiddleware' import user from './userMiddleware'
@ -22,7 +21,6 @@ export default schema => {
permissions, permissions,
sentry, sentry,
activityPub, activityPub,
dateTime,
validation, validation,
sluggify, sluggify,
excerpt, excerpt,
@ -40,7 +38,6 @@ export default schema => {
'sentry', 'sentry',
'permissions', 'permissions',
// 'activityPub', disabled temporarily // 'activityPub', disabled temporarily
'dateTime',
'validation', 'validation',
'sluggify', 'sluggify',
'excerpt', 'excerpt',

View File

@ -1,6 +1,6 @@
import cheerio from 'cheerio' import cheerio from 'cheerio'
export default function(content) { export default content => {
if (!content) return [] if (!content) return []
const $ = cheerio.load(content) const $ = cheerio.load(content)
const userIds = $('a.mention[data-mention-id]') const userIds = $('a.mention[data-mention-id]')

View File

@ -16,7 +16,6 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
} }
const session = context.driver.session() const session = context.driver.session()
const createdAt = new Date().toISOString()
let cypher let cypher
switch (reason) { switch (reason) {
case 'mentioned_in_post': { case 'mentioned_in_post': {
@ -27,7 +26,11 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = $createdAt SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
@ -40,7 +43,11 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
AND NOT (user)<-[:BLOCKED]-(postAuthor) AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = $createdAt SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
@ -53,17 +60,19 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
AND NOT (author)<-[:BLOCKED]-(user) AND NOT (author)<-[:BLOCKED]-(user)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE SET notification.read = FALSE
SET notification.createdAt = $createdAt SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
} }
await session.run(cypher, { await session.run(cypher, {
label,
id, id,
idsOfUsers, idsOfUsers,
reason, reason,
createdAt,
}) })
session.close() session.close()
} }
@ -82,6 +91,7 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content) const idsOfUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo) const comment = await resolve(root, args, context, resolveInfo)
if (comment) { if (comment) {

View File

@ -77,7 +77,7 @@ afterEach(async () => {
describe('notifications', () => { describe('notifications', () => {
const notificationQuery = gql` const notificationQuery = gql`
query($read: Boolean) { query($read: Boolean) {
notifications(read: $read, orderBy: createdAt_desc) { notifications(read: $read, orderBy: updatedAt_desc) {
read read
reason reason
createdAt createdAt
@ -391,7 +391,7 @@ describe('notifications', () => {
expect(Date.parse(createdAtBefore)).toEqual(expect.any(Number)) expect(Date.parse(createdAtBefore)).toEqual(expect.any(Number))
expect(createdAtAfter).toBeTruthy() expect(createdAtAfter).toBeTruthy()
expect(Date.parse(createdAtAfter)).toEqual(expect.any(Number)) expect(Date.parse(createdAtAfter)).toEqual(expect.any(Number))
expect(createdAtBefore).not.toEqual(createdAtAfter) expect(createdAtBefore).toEqual(createdAtAfter)
}) })
}) })
}) })

View File

@ -78,7 +78,7 @@ const invitationLimitReached = rule({
const isAuthor = rule({ const isAuthor = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (parent, args, { user, driver }) => { })(async (_parent, args, { user, driver }) => {
if (!user) return false if (!user) return false
const session = driver.session() const session = driver.session()
const { id: resourceId } = args const { id: resourceId } = args

View File

@ -1,4 +1,4 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import uuid from 'uuid/v4'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
export default { export default {
@ -10,44 +10,44 @@ export default {
// because we use relationships for this. So, we are deleting it from params // because we use relationships for this. So, we are deleting it from params
// before comment creation. // before comment creation.
delete params.postId delete params.postId
params.id = params.id || uuid()
const session = context.driver.session() const session = context.driver.session()
const commentWithoutRelationships = await neo4jgraphql( const createCommentCypher = `
object, MATCH (post:Post {id: $postId})
params, MATCH (author:User {id: $userId})
context, WITH post, author
resolveInfo, CREATE (comment:Comment {params})
false, SET comment.createdAt = toString(datetime())
) SET comment.updatedAt = toString(datetime())
const transactionRes = await session.run(
`
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId})
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN comment, author`, RETURN comment
{ `
userId: context.user.id, const transactionRes = await session.run(createCommentCypher, {
postId, userId: context.user.id,
commentId: commentWithoutRelationships.id, postId,
}, params,
)
const [commentWithAuthor] = transactionRes.records.map(record => {
return {
comment: record.get('comment'),
author: record.get('author'),
}
}) })
const { comment, author } = commentWithAuthor
const commentReturnedWithAuthor = {
...comment.properties,
author: author.properties,
}
session.close() session.close()
return commentReturnedWithAuthor
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return comment
}, },
DeleteComment: async (object, args, context, resolveInfo) => { UpdateComment: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const updateCommentCypher = `
MATCH (comment:Comment {id: $params.id})
SET comment += $params
SET comment.updatedAt = toString(datetime())
RETURN comment
`
const transactionRes = await session.run(updateCommentCypher, { params })
session.close()
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return comment
},
DeleteComment: async (_parent, args, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
const transactionRes = await session.run( const transactionRes = await session.run(
` `

View File

@ -8,10 +8,7 @@ const driver = getDriver()
const neode = getNeode() const neode = getNeode()
const factory = Factory() const factory = Factory()
let variables let variables, mutate, authenticatedUser, commentAuthor, newlyCreatedComment
let mutate
let authenticatedUser
let commentAuthor
beforeAll(() => { beforeAll(() => {
const { server } = createServer({ const { server } = createServer({
@ -57,7 +54,7 @@ const setupPostAndComment = async () => {
content: 'Post to be commented', content: 'Post to be commented',
categoryIds: ['cat9'], categoryIds: ['cat9'],
}) })
await factory.create('Comment', { newlyCreatedComment = await factory.create('Comment', {
id: 'c456', id: 'c456',
postId: 'p1', postId: 'p1',
author: commentAuthor, author: commentAuthor,
@ -160,6 +157,8 @@ describe('UpdateComment', () => {
UpdateComment(content: $content, id: $id) { UpdateComment(content: $content, id: $id) {
id id
content content
createdAt
updatedAt
} }
} }
` `
@ -200,6 +199,33 @@ describe('UpdateComment', () => {
) )
}) })
it('updates a comment, but maintains non-updated attributes', async () => {
const expected = {
data: {
UpdateComment: {
id: 'c456',
content: 'The comment is updated',
createdAt: expect.any(String),
},
},
}
await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('updates the updatedAt attribute', async () => {
newlyCreatedComment = await newlyCreatedComment.toJson()
const {
data: { UpdateComment },
} = await mutate({ mutation: updateCommentMutation, variables })
expect(newlyCreatedComment.updatedAt).toBeTruthy()
expect(Date.parse(newlyCreatedComment.updatedAt)).toEqual(expect.any(Number))
expect(UpdateComment.updatedAt).toBeTruthy()
expect(Date.parse(UpdateComment.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedComment.updatedAt).not.toEqual(UpdateComment.updatedAt)
})
describe('if `content` empty', () => { describe('if `content` empty', () => {
beforeEach(() => { beforeEach(() => {
variables = { ...variables, content: ' <p> </p>' } variables = { ...variables, content: ' <p> </p>' }

View File

@ -15,7 +15,7 @@ const transformReturnType = record => {
export default { export default {
Query: { Query: {
notifications: async (parent, args, context, resolveInfo) => { notifications: async (_parent, args, context, _resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() const session = context.driver.session()
let notifications let notifications
@ -32,11 +32,11 @@ export default {
whereClause = '' whereClause = ''
} }
switch (args.orderBy) { switch (args.orderBy) {
case 'createdAt_asc': case 'updatedAt_asc':
orderByClause = 'ORDER BY notification.createdAt ASC' orderByClause = 'ORDER BY notification.updatedAt ASC'
break break
case 'createdAt_desc': case 'updatedAt_desc':
orderByClause = 'ORDER BY notification.createdAt DESC' orderByClause = 'ORDER BY notification.updatedAt DESC'
break break
default: default:
orderByClause = '' orderByClause = ''

View File

@ -145,47 +145,46 @@ describe('given some notifications', () => {
describe('no filters', () => { describe('no filters', () => {
it('returns all notifications of current user', async () => { it('returns all notifications of current user', async () => {
const expected = { const expected = [
data: { {
notifications: [ from: {
{ __typename: 'Comment',
from: { content: 'You have seen this comment mentioning already',
__typename: 'Comment', },
content: 'You have seen this comment mentioning already', read: true,
}, createdAt: '2019-08-30T15:33:48.651Z',
read: true,
createdAt: '2019-08-30T15:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'Already seen post mention',
},
read: true,
createdAt: '2019-08-30T17:33:48.651Z',
},
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
],
}, },
} {
await expect(query({ query: notificationQuery, variables })).resolves.toMatchObject( from: {
expected, __typename: 'Post',
) content: 'Already seen post mention',
},
read: true,
createdAt: '2019-08-30T17:33:48.651Z',
},
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
]
await expect(query({ query: notificationQuery, variables })).resolves.toMatchObject({
data: {
notifications: expect.arrayContaining(expected),
},
})
}) })
}) })

View File

@ -74,7 +74,7 @@ export default {
}, },
}, },
Mutation: { Mutation: {
CreatePost: async (object, params, context, resolveInfo) => { CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params const { categoryIds } = params
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
@ -82,6 +82,8 @@ export default {
let post let post
const createPostCypher = `CREATE (post:Post {params}) const createPostCypher = `CREATE (post:Post {params})
SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime())
WITH post WITH post
MATCH (author:User {id: $userId}) MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author) MERGE (post)<-[:WROTE]-(author)
@ -96,9 +98,7 @@ export default {
const session = context.driver.session() const session = context.driver.session()
try { try {
const transactionRes = await session.run(createPostCypher, createPostVariables) const transactionRes = await session.run(createPostCypher, createPostVariables)
const posts = transactionRes.records.map(record => { const posts = transactionRes.records.map(record => record.get('post').properties)
return record.get('post').properties
})
post = posts[0] post = posts[0]
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
@ -110,14 +110,15 @@ export default {
return post return post
}, },
UpdatePost: async (object, params, context, resolveInfo) => { UpdatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params const { categoryIds } = params
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
const session = context.driver.session() const session = context.driver.session()
let updatePostCypher = `MATCH (post:Post {id: $params.id}) let updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post = $params SET post += $params
SET post.updatedAt = toString(datetime())
` `
if (categoryIds && categoryIds.length) { if (categoryIds && categoryIds.length) {
@ -129,10 +130,11 @@ export default {
await session.run(cypherDeletePreviousRelations, { params }) await session.run(cypherDeletePreviousRelations, { params })
updatePostCypher += `WITH post updatePostCypher += `
UNWIND $categoryIds AS categoryId WITH post
MATCH (category:Category {id: categoryId}) UNWIND $categoryIds AS categoryId
MERGE (post)-[:CATEGORIZED]->(category) MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
` `
} }
@ -141,12 +143,12 @@ export default {
const transactionRes = await session.run(updatePostCypher, updatePostVariables) const transactionRes = await session.run(updatePostCypher, updatePostVariables)
const [post] = transactionRes.records.map(record => { const [post] = transactionRes.records.map(record => {
return record.get('post') return record.get('post').properties
}) })
session.close() session.close()
return post.properties return post
}, },
DeletePost: async (object, args, context, resolveInfo) => { DeletePost: async (object, args, context, resolveInfo) => {

View File

@ -361,7 +361,7 @@ describe('CreatePost', () => {
}) })
describe('UpdatePost', () => { describe('UpdatePost', () => {
let author let author, newlyCreatedPost
const updatePostMutation = gql` const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
@ -370,12 +370,14 @@ describe('UpdatePost', () => {
categories { categories {
id id
} }
createdAt
updatedAt
} }
} }
` `
beforeEach(async () => { beforeEach(async () => {
author = await factory.create('User', { slug: 'the-author' }) author = await factory.create('User', { slug: 'the-author' })
await factory.create('Post', { newlyCreatedPost = await factory.create('Post', {
author, author,
id: 'p9876', id: 'p9876',
title: 'Old title', title: 'Old title',
@ -421,6 +423,29 @@ describe('UpdatePost', () => {
) )
}) })
it('updates a post, but maintains non-updated attributes', async () => {
const expected = {
data: {
UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) },
},
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('updates the updatedAt attribute', async () => {
newlyCreatedPost = await newlyCreatedPost.toJson()
const {
data: { UpdatePost },
} = await mutate({ mutation: updatePostMutation, variables })
expect(newlyCreatedPost.updatedAt).toBeTruthy()
expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number))
expect(UpdatePost.updatedAt).toBeTruthy()
expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt)
})
describe('no new category ids provided for update', () => { describe('no new category ids provided for update', () => {
it('resolves and keeps current categories', async () => { it('resolves and keeps current categories', async () => {
const expected = { const expected = {

View File

@ -100,7 +100,7 @@ export default {
try { try {
const user = await instance.find('User', args.id) const user = await instance.find('User', args.id)
if (!user) return null if (!user) return null
await user.update(args) await user.update({ ...args, updatedAt: new Date().toISOString() })
return user.toJson() return user.toJson()
} catch (e) { } catch (e) {
throw new UserInputError(e.message) throw new UserInputError(e.message)

View File

@ -2,6 +2,7 @@ type NOTIFIED {
from: NotificationSource from: NotificationSource
to: User to: User
createdAt: String createdAt: String
updatedAt: String
read: Boolean read: Boolean
reason: NotificationReason reason: NotificationReason
} }
@ -11,6 +12,8 @@ union NotificationSource = Post | Comment
enum NotificationOrdering { enum NotificationOrdering {
createdAt_asc createdAt_asc
createdAt_desc createdAt_desc
updatedAt_asc
updatedAt_desc
} }
enum NotificationReason { enum NotificationReason {

View File

@ -97,7 +97,7 @@ export const notificationQuery = i18n => {
${postFragment(lang)} ${postFragment(lang)}
query { query {
notifications(read: false, orderBy: createdAt_desc) { notifications(read: false, orderBy: updatedAt_desc) {
read read
reason reason
createdAt createdAt