Merge pull request #2433 from Human-Connection/2412-favor-transaction-function

Favor transaction functions
This commit is contained in:
mattwr18 2019-12-13 13:20:43 +01:00 committed by GitHub
commit 56c5f4a384
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 961 additions and 687 deletions

View File

@ -11,27 +11,28 @@ export default async (driver, authorizationHeader) => {
} catch (err) { } catch (err) {
return null return null
} }
const query = ` const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserLastActiveTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $id, deleted: false, disabled: false }) MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime()) SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1 LIMIT 1
` `,
const session = driver.session() { id },
let result )
return updateUserLastActiveTransactionResponse.records.map(record => record.get('user'))
try {
result = await session.run(query, { id })
} finally {
session.close()
}
const [currentUser] = await result.records.map(record => {
return record.get('user')
}) })
try {
const [currentUser] = await writeTxResultPromise
if (!currentUser) return null if (!currentUser) return null
return { return {
token, token,
...currentUser, ...currentUser,
} }
} finally {
session.close()
}
} }

View File

@ -2,30 +2,23 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return if (!hashtags.length) return
// 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, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t)
RETURN p, t
`
const session = context.driver.session() const session = context.driver.session()
try { try {
await session.run(cypherDeletePreviousRelations, { await session.writeTransaction(txc => {
postId, return txc.run(
}) `
await session.run(cypherCreateNewTagsAndRelations, { MATCH (post:Post { id: $postId})
postId, OPTIONAL MATCH (post)-[previousRelations:TAGGED]->(tag:Tag)
hashtags, DELETE previousRelations
WITH post
UNWIND $hashtags AS tagName
MERGE (tag:Tag {id: tagName, disabled: false, deleted: false })
MERGE (post)-[:TAGGED]->(tag)
RETURN post, tag
`,
{ postId, hashtags },
)
}) })
} finally { } finally {
session.close() session.close()

View File

@ -7,7 +7,7 @@ import sluggify from './sluggifyMiddleware'
import excerpt from './excerptMiddleware' import excerpt from './excerptMiddleware'
import xss from './xssMiddleware' import xss from './xssMiddleware'
import permissions from './permissionsMiddleware' import permissions from './permissionsMiddleware'
import user from './userMiddleware' import user from './user/userMiddleware'
import includedFields from './includedFieldsMiddleware' import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware' import orderBy from './orderByMiddleware'
import validation from './validation/validationMiddleware' import validation from './validation/validationMiddleware'

View File

@ -38,7 +38,7 @@ const createLocation = async (session, mapboxData) => {
lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null, lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null,
} }
let query = let mutation =
'MERGE (l:Location {id: $id}) ' + 'MERGE (l:Location {id: $id}) ' +
'SET l.name = $nameEN, ' + 'SET l.name = $nameEN, ' +
'l.nameEN = $nameEN, ' + 'l.nameEN = $nameEN, ' +
@ -53,19 +53,23 @@ const createLocation = async (session, mapboxData) => {
'l.type = $type' 'l.type = $type'
if (data.lat && data.lng) { if (data.lat && data.lng) {
query += ', l.lat = $lat, l.lng = $lng' mutation += ', l.lat = $lat, l.lng = $lng'
} }
query += ' RETURN l.id' mutation += ' RETURN l.id'
await session.run(query, data) try {
await session.writeTransaction(transaction => {
return transaction.run(mutation, data)
})
} finally {
session.close() session.close()
} }
}
const createOrUpdateLocations = async (userId, locationName, driver) => { const createOrUpdateLocations = async (userId, locationName, driver) => {
if (isEmpty(locationName)) { if (isEmpty(locationName)) {
return return
} }
const res = await fetch( const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName, locationName,
@ -106,33 +110,44 @@ const createOrUpdateLocations = async (userId, locationName, driver) => {
if (data.context) { if (data.context) {
await asyncForEach(data.context, async ctx => { await asyncForEach(data.context, async ctx => {
await createLocation(session, ctx) await createLocation(session, ctx)
try {
await session.run( await session.writeTransaction(transaction => {
'MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) ' + return transaction.run(
'MERGE (child)<-[:IS_IN]-(parent) ' + `
'RETURN child.id, parent.id', MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{ {
parentId: parent.id, parentId: parent.id,
childId: ctx.id, childId: ctx.id,
}, },
) )
})
parent = ctx parent = ctx
} finally {
session.close()
}
}) })
} }
// delete all current locations from user // delete all current locations from user and add new location
await session.run('MATCH (u:User {id: $userId})-[r:IS_IN]->(l:Location) DETACH DELETE r', { try {
userId: userId, await session.writeTransaction(transaction => {
}) return transaction.run(
// connect user with location `
await session.run( MATCH (user:User {id: $userId})-[relationship:IS_IN]->(location:Location)
'MATCH (u:User {id: $userId}), (l:Location {id: $locationId}) MERGE (u)-[:IS_IN]->(l) RETURN l.id, u.id', DETACH DELETE relationship
{ WITH user
userId: userId, MATCH (location:Location {id: $locationId})
locationId: data.id, MERGE (user)-[:IS_IN]->(location)
}, RETURN location.id, user.id
`,
{ userId: userId, locationId: data.id },
) )
})
} finally {
session.close() session.close()
} }
}
export default createOrUpdateLocations export default createOrUpdateLocations

View File

@ -1,66 +1,73 @@
import extractMentionedUsers from './mentions/extractMentionedUsers' import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
const postAuthorOfComment = async (comment, { context }) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const cypherFindUser = ` const idsOfUsers = extractMentionedUsers(args.content)
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) const post = await resolve(root, args, context, resolveInfo)
RETURN user { .id } if (post && idsOfUsers && idsOfUsers.length)
` await notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const { content } = args
let idsOfUsers = extractMentionedUsers(content)
const comment = await resolve(root, args, context, resolveInfo)
const [postAuthor] = await postAuthorOfComment(comment.id, { context })
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
if (idsOfUsers && idsOfUsers.length)
await notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
if (context.user.id !== postAuthor.id)
await notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context)
return comment
}
const postAuthorOfComment = async (commentId, { context }) => {
const session = context.driver.session() const session = context.driver.session()
let result let postAuthorId
try { try {
result = await session.run(cypherFindUser, { postAuthorId = await session.readTransaction(transaction => {
commentId: comment.id, return transaction.run(
`
MATCH (author:User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN author { .id } as authorId
`,
{ commentId },
)
}) })
return postAuthorId.records.map(record => record.get('authorId'))
} finally { } finally {
session.close() session.close()
} }
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
return postAuthor
} }
const notifyUsers = async (label, id, idsOfUsers, reason, context) => { const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return await validateNotifyUsers(label, reason)
let mentionedCypher
// Checked here, because it does not go through GraphQL checks at all in this file.
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) {
throw new Error('Notification reason is not allowed!')
}
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
let cypher
switch (reason) { switch (reason) {
case 'mentioned_in_post': { case 'mentioned_in_post': {
cypher = ` mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
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 (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
case 'mentioned_in_comment': { case 'mentioned_in_comment': {
cypher = ` mentionedCypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor) AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
`
break
}
}
mentionedCypher += `
SET notification.read = FALSE SET notification.read = FALSE
SET ( SET (
CASE CASE
@ -68,97 +75,47 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
THEN notification END ).createdAt = toString(datetime()) THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
` `
break const session = context.driver.session()
try {
await session.writeTransaction(transaction => {
return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
})
} finally {
session.close()
} }
case 'commented_on_post': { }
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => {
MATCH (user: User) await validateNotifyUsers(label, reason)
WHERE user.id in $idsOfUsers const session = context.driver.session()
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user) try {
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) await session.writeTransaction(async transaction => {
await transaction.run(
`
MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
SET notification.read = FALSE SET notification.read = FALSE
SET ( SET (
CASE CASE
WHEN notification.createdAt IS NULL WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime()) THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
` `,
break { commentId, postAuthorId, reason },
} )
}
const session = context.driver.session()
try {
await session.run(cypher, {
id,
idsOfUsers,
reason,
}) })
} finally { } finally {
session.close() session.close()
} }
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post) {
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
}
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
let idsOfUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo)
if (comment) {
const postAuthor = await postAuthorOfComment(comment, { context })
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
}
return comment
}
const handleCreateComment = async (resolve, root, args, context, resolveInfo) => {
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
if (comment) {
const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id }
`
const session = context.driver.session()
let result
try {
result = await session.run(cypherFindUser, {
commentId: comment.id,
})
} finally {
session.close()
}
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
if (context.user.id !== postAuthor.id) {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context)
}
}
return comment
}
export default { export default {
Mutation: { Mutation: {
CreatePost: handleContentDataOfPost, CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost, UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment, CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment, UpdateComment: handleContentDataOfComment,
}, },
} }

View File

@ -4,11 +4,7 @@ import { createTestClient } from 'apollo-server-testing'
import { getNeode, getDriver } from '../../bootstrap/neo4j' import { getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server' import createServer from '../../server'
let server let server, query, mutate, notifiedUser, authenticatedUser
let query
let mutate
let notifiedUser
let authenticatedUser
const factory = Factory() const factory = Factory()
const driver = getDriver() const driver = getDriver()
const neode = getNeode() const neode = getNeode()
@ -39,7 +35,8 @@ const createCommentMutation = gql`
} }
` `
beforeAll(() => { beforeAll(async () => {
await factory.cleanDatabase()
const createServerResult = createServer({ const createServerResult = createServer({
context: () => { context: () => {
return { return {
@ -173,7 +170,6 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -190,7 +186,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -214,7 +210,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -265,7 +261,7 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -409,7 +405,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -467,7 +463,7 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -501,7 +497,7 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -532,7 +528,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,

View File

@ -47,17 +47,18 @@ const isAuthor = rule({
if (!user) return false if (!user) return false
const { id: resourceId } = args const { id: resourceId } = args
const session = driver.session() const session = driver.session()
try { const authorReadTxPromise = session.readTransaction(async transaction => {
const result = await session.run( const authorTransactionResponse = await transaction.run(
` `
MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId}) MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
RETURN author RETURN author
`, `,
{ resourceId, userId: user.id }, { resourceId, userId: user.id },
) )
const [author] = result.records.map(record => { return authorTransactionResponse.records.map(record => record.get('author'))
return record.get('author')
}) })
try {
const [author] = await authorReadTxPromise
return !!author return !!author
} finally { } finally {
session.close() session.close()

View File

@ -4,10 +4,16 @@ const isUniqueFor = (context, type) => {
return async slug => { return async slug => {
const session = context.driver.session() const session = context.driver.session()
try { try {
const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, { const existingSlug = await session.readTransaction(transaction => {
slug, return transaction.run(
`
MATCH(p:${type} {slug: $slug })
RETURN p.slug
`,
{ slug },
)
}) })
return response.records.length === 0 return existingSlug.records.length === 0
} finally { } finally {
session.close() session.close()
} }

View File

@ -1,10 +1,10 @@
import createOrUpdateLocations from './nodes/locations' import createOrUpdateLocations from '../nodes/locations'
export default { export default {
Mutation: { Mutation: {
SignupVerification: async (resolve, root, args, context, info) => { SignupVerification: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info) const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver) await createOrUpdateLocations(result.id, args.locationName, context.driver)
return result return result
}, },
UpdateUser: async (resolve, root, args, context, info) => { UpdateUser: async (resolve, root, args, context, info) => {

View File

@ -0,0 +1,213 @@
import { gql } from '../../helpers/jest'
import Factory from '../../seed/factories'
import { getNeode, getDriver } from '../../bootstrap/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
const factory = Factory()
const neode = getNeode()
const driver = getDriver()
let authenticatedUser, mutate, variables
const signupVerificationMutation = gql`
mutation(
$name: String!
$password: String!
$email: String!
$nonce: String!
$termsAndConditionsAgreedVersion: String!
$locationName: String
) {
SignupVerification(
name: $name
password: $password
email: $email
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) {
locationName
}
}
`
const updateUserMutation = gql`
mutation($id: ID!, $name: String!, $locationName: String) {
UpdateUser(id: $id, name: $name, locationName: $locationName) {
locationName
}
}
`
let newlyCreatedNodesWithLocales = [
{
city: {
lng: 41.1534,
nameES: 'Hamburg',
nameFR: 'Hamburg',
nameIT: 'Hamburg',
nameEN: 'Hamburg',
type: 'place',
namePT: 'Hamburg',
nameRU: 'Хамбург',
nameDE: 'Hamburg',
nameNL: 'Hamburg',
name: 'Hamburg',
namePL: 'Hamburg',
id: 'place.5977106083398860',
lat: -74.5763,
},
state: {
namePT: 'Nova Jérsia',
nameRU: 'Нью-Джерси',
nameDE: 'New Jersey',
nameNL: 'New Jersey',
nameES: 'Nueva Jersey',
name: 'New Jersey',
namePL: 'New Jersey',
nameFR: 'New Jersey',
nameIT: 'New Jersey',
id: 'region.14919479731700330',
nameEN: 'New Jersey',
type: 'region',
},
country: {
namePT: 'Estados Unidos',
nameRU: 'Соединённые Штаты Америки',
nameDE: 'Vereinigte Staaten',
nameNL: 'Verenigde Staten van Amerika',
nameES: 'Estados Unidos',
namePL: 'Stany Zjednoczone',
name: 'United States of America',
nameFR: 'États-Unis',
nameIT: "Stati Uniti d'America",
id: 'country.9053006287256050',
nameEN: 'United States of America',
type: 'country',
},
},
]
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
user: authenticatedUser,
neode,
driver,
}
},
})
mutate = createTestClient(server).mutate
})
beforeEach(() => {
variables = {}
authenticatedUser = null
})
afterEach(() => {
factory.cleanDatabase()
})
describe('userMiddleware', () => {
describe('SignupVerification', () => {
beforeEach(async () => {
variables = {
...variables,
name: 'John Doe',
password: '123',
email: 'john@example.org',
nonce: '123456',
termsAndConditionsAgreedVersion: '0.1.0',
locationName: 'Hamburg, New Jersey, United States of America',
}
const args = {
email: 'john@example.org',
nonce: '123456',
}
await neode.model('EmailAddress').create(args)
})
it('creates a Location node with localised city/state/country names', async () => {
await mutate({ mutation: signupVerificationMutation, variables })
const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city, state, country`,
)
expect(
locations.records.map(record => {
return {
city: record.get('city').properties,
state: record.get('state').properties,
country: record.get('country').properties,
}
}),
).toEqual(newlyCreatedNodesWithLocales)
})
})
describe('UpdateUser', () => {
let user, userParams
beforeEach(async () => {
newlyCreatedNodesWithLocales = [
{
city: {
lng: 53.55,
nameES: 'Hamburgo',
nameFR: 'Hambourg',
nameIT: 'Amburgo',
nameEN: 'Hamburg',
type: 'region',
namePT: 'Hamburgo',
nameRU: 'Гамбург',
nameDE: 'Hamburg',
nameNL: 'Hamburg',
namePL: 'Hamburg',
name: 'Hamburg',
id: 'region.10793468240398860',
lat: 10,
},
country: {
namePT: 'Alemanha',
nameRU: 'Германия',
nameDE: 'Deutschland',
nameNL: 'Duitsland',
nameES: 'Alemania',
name: 'Germany',
namePL: 'Niemcy',
nameFR: 'Allemagne',
nameIT: 'Germania',
id: 'country.10743216036480410',
nameEN: 'Germany',
type: 'country',
},
},
]
userParams = {
id: 'updating-user',
}
user = await factory.create('User', userParams)
authenticatedUser = await user.toJson()
})
it('creates a Location node with localised city/state/country names', async () => {
variables = {
...variables,
id: 'updating-user',
name: 'Updating user',
locationName: 'Hamburg, Germany',
}
await mutate({ mutation: updateUserMutation, variables })
const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(country:Location) return city, country`,
)
expect(
locations.records.map(record => {
return {
city: record.get('city').properties,
country: record.get('country').properties,
}
}),
).toEqual(newlyCreatedNodesWithLocales)
})
})
})

View File

@ -4,7 +4,7 @@ const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE = const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three' 'You cannot save a post without at least one category or more than three'
const USERNAME_MIN_LENGTH = 3
const validateCreateComment = async (resolve, root, args, context, info) => { const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -14,14 +14,15 @@ const validateCreateComment = async (resolve, root, args, context, info) => {
} }
const session = context.driver.session() const session = context.driver.session()
try { try {
const postQueryRes = await session.run( const postQueryRes = await session.readTransaction(transaction => {
return transaction.run(
` `
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
RETURN post`, RETURN post
{ `,
postId, { postId },
},
) )
})
const [post] = postQueryRes.records.map(record => { const [post] = postQueryRes.records.map(record => {
return record.get('post') return record.get('post')
}) })
@ -72,8 +73,8 @@ const validateReview = async (resolve, root, args, context, info) => {
const { user, driver } = context const { user, driver } = context
if (resourceId === user.id) throw new Error('You cannot review yourself!') if (resourceId === user.id) throw new Error('You cannot review yourself!')
const session = driver.session() const session = driver.session()
const reportReadTxPromise = session.writeTransaction(async txc => { const reportReadTxPromise = session.readTransaction(async transaction => {
const validateReviewTransactionResponse = await txc.run( const validateReviewTransactionResponse = await transaction.run(
` `
MATCH (resource {id: $resourceId}) MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Post OR resource:Comment WHERE resource:User OR resource:Post OR resource:Comment
@ -115,12 +116,31 @@ const validateReview = async (resolve, root, args, context, info) => {
return resolve(root, args, context, info) return resolve(root, args, context, info)
} }
export const validateNotifyUsers = async (label, reason) => {
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) throw new Error('Notification reason is not allowed!')
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
}
const validateUpdateUser = async (resolve, root, params, context, info) => {
const { name } = params
if (typeof name === 'string' && name.trim().length < USERNAME_MIN_LENGTH)
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} character long!`)
return resolve(root, params, context, info)
}
export default { export default {
Mutation: { Mutation: {
CreateComment: validateCreateComment, CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment, UpdateComment: validateUpdateComment,
CreatePost: validatePost, CreatePost: validatePost,
UpdatePost: validateUpdatePost, UpdatePost: validateUpdatePost,
UpdateUser: validateUpdateUser,
fileReport: validateReport, fileReport: validateReport,
review: validateReview, review: validateReview,
}, },

View File

@ -71,6 +71,14 @@ const reviewMutation = gql`
} }
} }
` `
const updateUserMutation = gql`
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
}
}
`
beforeAll(() => { beforeAll(() => {
const { server } = createServer({ const { server } = createServer({
context: () => { context: () => {
@ -397,4 +405,33 @@ describe('validateReview', () => {
}) })
}) })
}) })
describe('validateUpdateUser', () => {
let userParams, variables, updatingUser
beforeEach(async () => {
userParams = {
id: 'updating-user',
name: 'John Doe',
}
variables = {
id: 'updating-user',
name: 'John Doughnut',
}
updatingUser = await factory.create('User', userParams)
authenticatedUser = await updatingUser.toJson()
})
it('with name too short', async () => {
variables = {
...variables,
name: ' ',
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: null },
errors: [{ message: 'Username must be at least 3 character long!' }],
})
})
})
}) })

View File

@ -5,6 +5,7 @@ export default {
Mutation: { Mutation: {
CreateComment: async (object, params, context, resolveInfo) => { CreateComment: async (object, params, context, resolveInfo) => {
const { postId } = params const { postId } = params
const { user, driver } = context
// Adding relationship from comment to post by passing in the postId, // Adding relationship from comment to post by passing in the postId,
// but we do not want to create the comment with postId as an attribute // but we do not want to create the comment with postId as an attribute
// 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
@ -12,9 +13,11 @@ export default {
delete params.postId delete params.postId
params.id = params.id || uuid() params.id = params.id || uuid()
const session = context.driver.session() const session = driver.session()
try {
const createCommentCypher = ` const writeTxResultPromise = session.writeTransaction(async transaction => {
const createCommentTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
MATCH (author:User {id: $userId}) MATCH (author:User {id: $userId})
WITH post, author WITH post, author
@ -23,15 +26,15 @@ export default {
SET comment.updatedAt = toString(datetime()) SET comment.updatedAt = toString(datetime())
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN comment RETURN comment
` `,
const transactionRes = await session.run(createCommentCypher, { { userId: user.id, postId, params },
userId: context.user.id, )
postId, return createCommentTransactionResponse.records.map(
params, record => record.get('comment').properties,
)
}) })
try {
const [comment] = transactionRes.records.map(record => record.get('comment').properties) const [comment] = await writeTxResultPromise
return comment return comment
} finally { } finally {
session.close() session.close()
@ -39,15 +42,22 @@ export default {
}, },
UpdateComment: async (_parent, params, context, _resolveInfo) => { UpdateComment: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateCommentCypher = ` const updateCommentTransactionResponse = await transaction.run(
`
MATCH (comment:Comment {id: $params.id}) MATCH (comment:Comment {id: $params.id})
SET comment += $params SET comment += $params
SET comment.updatedAt = toString(datetime()) SET comment.updatedAt = toString(datetime())
RETURN comment RETURN comment
` `,
const transactionRes = await session.run(updateCommentCypher, { params }) { params },
const [comment] = transactionRes.records.map(record => record.get('comment').properties) )
return updateCommentTransactionResponse.records.map(
record => record.get('comment').properties,
)
})
try {
const [comment] = await writeTxResultPromise
return comment return comment
} finally { } finally {
session.close() session.close()
@ -55,8 +65,8 @@ export default {
}, },
DeleteComment: async (_parent, args, context, _resolveInfo) => { DeleteComment: async (_parent, args, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const transactionRes = await session.run( const deleteCommentTransactionResponse = await transaction.run(
` `
MATCH (comment:Comment {id: $commentId}) MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE SET comment.deleted = TRUE
@ -66,7 +76,12 @@ export default {
`, `,
{ commentId: args.id }, { commentId: args.id },
) )
const [comment] = transactionRes.records.map(record => record.get('comment').properties) return deleteCommentTransactionResponse.records.map(
record => record.get('comment').properties,
)
})
try {
const [comment] = await writeTxResultPromise
return comment return comment
} finally { } finally {
session.close() session.close()

View File

@ -10,7 +10,8 @@ const factory = Factory()
let variables, mutate, authenticatedUser, commentAuthor, newlyCreatedComment let variables, mutate, authenticatedUser, commentAuthor, newlyCreatedComment
beforeAll(() => { beforeAll(async () => {
await factory.cleanDatabase()
const { server } = createServer({ const { server } = createServer({
context: () => { context: () => {
return { return {
@ -19,8 +20,7 @@ beforeAll(() => {
} }
}, },
}) })
const client = createTestClient(server) mutate = createTestClient(server).mutate
mutate = client.mutate
}) })
beforeEach(async () => { beforeEach(async () => {
@ -100,6 +100,7 @@ describe('CreateComment', () => {
await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject(
{ {
data: { CreateComment: { content: "I'm authorised to comment" } }, data: { CreateComment: { content: "I'm authorised to comment" } },
errors: undefined,
}, },
) )
}) })
@ -108,6 +109,7 @@ describe('CreateComment', () => {
await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject(
{ {
data: { CreateComment: { author: { name: 'Author' } } }, data: { CreateComment: { author: { name: 'Author' } } },
errors: undefined,
}, },
) )
}) })
@ -157,6 +159,7 @@ describe('UpdateComment', () => {
it('updates the comment', async () => { it('updates the comment', async () => {
const expected = { const expected = {
data: { UpdateComment: { id: 'c456', content: 'The comment is updated' } }, data: { UpdateComment: { id: 'c456', content: 'The comment is updated' } },
errors: undefined,
} }
await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject(
expected, expected,
@ -172,6 +175,7 @@ describe('UpdateComment', () => {
createdAt: expect.any(String), createdAt: expect.any(String),
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject(
expected, expected,

View File

@ -5,24 +5,29 @@ export default async function createPasswordReset(options) {
const normalizedEmail = normalizeEmail(email) const normalizedEmail = normalizeEmail(email)
const session = driver.session() const session = driver.session()
try { try {
const cypher = ` const createPasswordResetTxPromise = session.writeTransaction(async transaction => {
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email}) const createPasswordResetTransactionResponse = await transaction.run(
CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN e, pr, u
` `
const transactionRes = await session.run(cypher, { MATCH (user:User)-[:PRIMARY_EMAIL]->(email:EmailAddress {email:$email})
CREATE(passwordReset:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (user)-[:REQUESTED]->(passwordReset)
RETURN email, passwordReset, user
`,
{
issuedAt: issuedAt.toISOString(), issuedAt: issuedAt.toISOString(),
nonce, nonce,
email: normalizedEmail, email: normalizedEmail,
}) },
const records = transactionRes.records.map(record => { )
const { email } = record.get('e').properties return createPasswordResetTransactionResponse.records.map(record => {
const { nonce } = record.get('pr').properties const { email } = record.get('email').properties
const { name } = record.get('u').properties const { nonce } = record.get('passwordReset').properties
const { name } = record.get('user').properties
return { email, nonce, name } return { email, nonce, name }
}) })
return records[0] || {} })
const [records] = await createPasswordResetTxPromise
return records || {}
} finally { } finally {
session.close() session.close()
} }

View File

@ -1,35 +0,0 @@
import createPasswordReset from './createPasswordReset'
describe('createPasswordReset', () => {
const issuedAt = new Date()
const nonce = 'abcdef'
describe('email lookup', () => {
let driver
let mockSession
beforeEach(() => {
mockSession = {
close() {},
run: jest.fn().mockReturnValue({
records: {
map: jest.fn(() => []),
},
}),
}
driver = { session: () => mockSession }
})
it('lowercases email address', async () => {
const email = 'stRaNGeCaSiNG@ExAmplE.ORG'
await createPasswordReset({ driver, email, issuedAt, nonce })
expect(mockSession.run.mock.calls).toEqual([
[
expect.any(String),
expect.objectContaining({
email: 'strangecasing@example.org',
}),
],
])
})
})
})

View File

@ -1,25 +1,29 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail({ args, context }) { export default async function alreadyExistingMail({ args, context }) {
const cypher = ` const session = context.driver.session()
try {
const existingEmailAddressTxPromise = session.writeTransaction(async transaction => {
const existingEmailAddressTransactionResponse = await transaction.run(
`
MATCH (email:EmailAddress {email: $email}) MATCH (email:EmailAddress {email: $email})
OPTIONAL MATCH (email)-[:BELONGS_TO]-(user) OPTIONAL MATCH (email)-[:BELONGS_TO]-(user)
RETURN email, user RETURN email, user
` `,
let transactionRes { email: args.email },
const session = context.driver.session() )
try { return existingEmailAddressTransactionResponse.records.map(record => {
transactionRes = await session.run(cypher, { email: args.email })
} finally {
session.close()
}
const [result] = transactionRes.records.map(record => {
return { return {
alreadyExistingEmail: record.get('email').properties, alreadyExistingEmail: record.get('email').properties,
user: record.get('user') && record.get('user').properties, user: record.get('user') && record.get('user').properties,
} }
}) })
const { alreadyExistingEmail, user } = result || {} })
const [emailBelongsToUser] = await existingEmailAddressTxPromise
const { alreadyExistingEmail, user } = emailBelongsToUser || {}
if (user) throw new UserInputError('A user account with this email already exists.') if (user) throw new UserInputError('A user account with this email already exists.')
return alreadyExistingEmail return alreadyExistingEmail
} finally {
session.close()
}
} }

View File

@ -76,16 +76,21 @@ export default {
markAsRead: async (parent, args, context, resolveInfo) => { markAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const cypher = ` const markNotificationAsReadTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE SET notification.read = TRUE
RETURN resource, notification, user RETURN resource, notification, user
` `,
const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id }) { resourceId: args.id, id: currentUser.id },
log(result) )
const notifications = await result.records.map(transformReturnType) log(markNotificationAsReadTransactionResponse)
return notifications[0] return markNotificationAsReadTransactionResponse.records.map(transformReturnType)
})
try {
const [notifications] = await writeTxResultPromise
return notifications
} finally { } finally {
session.close() session.close()
} }

View File

@ -12,25 +12,29 @@ export default {
const stillValid = new Date() const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1) stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = `
MATCH (pr:PasswordReset {nonce: $nonce})
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime()
SET u.encryptedPassword = $encryptedNewPassword
RETURN pr
`
const session = driver.session() const session = driver.session()
try { try {
const transactionRes = await session.run(cypher, { const passwordResetTxPromise = session.writeTransaction(async transaction => {
const passwordResetTransactionResponse = await transaction.run(
`
MATCH (passwordReset:PasswordReset {nonce: $nonce})
MATCH (email:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(user:User)-[:REQUESTED]->(passwordReset)
WHERE duration.between(passwordReset.issuedAt, datetime()).days <= 0 AND passwordReset.usedAt IS NULL
SET passwordReset.usedAt = datetime()
SET user.encryptedPassword = $encryptedNewPassword
RETURN passwordReset
`,
{
stillValid, stillValid,
email, email,
nonce, nonce,
encryptedNewPassword, encryptedNewPassword,
},
)
return passwordResetTransactionResponse.records.map(record => record.get('passwordReset'))
}) })
const [reset] = transactionRes.records.map(record => record.get('pr')) const [reset] = await passwordResetTxPromise
const response = !!(reset && reset.properties.usedAt) return !!(reset && reset.properties.usedAt)
return response
} finally { } finally {
session.close() session.close()
} }

View File

@ -14,14 +14,11 @@ let authenticatedUser
let variables let variables
const getAllPasswordResets = async () => { const getAllPasswordResets = async () => {
const session = driver.session() const passwordResetQuery = await neode.cypher(
try { 'MATCH (passwordReset:PasswordReset) RETURN passwordReset',
const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') )
const resets = transactionRes.records.map(record => record.get('r')) const resets = passwordResetQuery.records.map(record => record.get('passwordReset'))
return resets return resets
} finally {
session.close()
}
} }
beforeEach(() => { beforeEach(() => {

View File

@ -57,17 +57,20 @@ export default {
PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => {
const { postId, data } = params const { postId, data } = params
const session = context.driver.session() const session = context.driver.session()
try { const readTxResultPromise = session.readTransaction(async transaction => {
const transactionRes = await session.run( const emotionsCountTransactionResponse = await transaction.run(
`MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() `
MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-()
RETURN COUNT(DISTINCT emoted) as emotionsCount RETURN COUNT(DISTINCT emoted) as emotionsCount
`, `,
{ postId, data }, { postId, data },
) )
return emotionsCountTransactionResponse.records.map(
const [emotionsCount] = transactionRes.records.map(record => { record => record.get('emotionsCount').low,
return record.get('emotionsCount').low )
}) })
try {
const [emotionsCount] = await readTxResultPromise
return emotionsCount return emotionsCount
} finally { } finally {
session.close() session.close()
@ -76,16 +79,18 @@ export default {
PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => {
const { postId } = params const { postId } = params
const session = context.driver.session() const session = context.driver.session()
try { const readTxResultPromise = session.readTransaction(async transaction => {
const transactionRes = await session.run( const emotionsTransactionResponse = await transaction.run(
`MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) `
RETURN collect(emoted.emotion) as emotion`, MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId})
RETURN collect(emoted.emotion) as emotion
`,
{ userId: context.user.id, postId }, { userId: context.user.id, postId },
) )
return emotionsTransactionResponse.records.map(record => record.get('emotion'))
const [emotions] = transactionRes.records.map(record => {
return record.get('emotion')
}) })
try {
const [emotions] = await readTxResultPromise
return emotions return emotions
} finally { } finally {
session.close() session.close()
@ -98,7 +103,11 @@ export default {
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
params.id = params.id || uuid() params.id = params.id || uuid()
const createPostCypher = `CREATE (post:Post {params}) const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const createPostTransactionResponse = await transaction.run(
`
CREATE (post:Post {params})
SET post.createdAt = toString(datetime()) SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime()) SET post.updatedAt = toString(datetime())
WITH post WITH post
@ -108,15 +117,15 @@ export default {
UNWIND $categoryIds AS categoryId UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId}) MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category) MERGE (post)-[:CATEGORIZED]->(category)
RETURN post` RETURN post
`,
const createPostVariables = { userId: context.user.id, categoryIds, params } { userId: context.user.id, categoryIds, params },
)
const session = context.driver.session() return createPostTransactionResponse.records.map(record => record.get('post').properties)
})
try { try {
const transactionRes = await session.run(createPostCypher, createPostVariables) const [post] = await writeTxResultPromise
const posts = transactionRes.records.map(record => record.get('post').properties) return post
return posts[0]
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Post with this slug already exists!') throw new UserInputError('Post with this slug already exists!')
@ -129,14 +138,14 @@ export default {
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' })
let updatePostCypher = `MATCH (post:Post {id: $params.id}) const session = context.driver.session()
let updatePostCypher = `
MATCH (post:Post {id: $params.id})
SET post += $params SET post += $params
SET post.updatedAt = toString(datetime()) SET post.updatedAt = toString(datetime())
WITH post WITH post
` `
const session = context.driver.session()
try {
if (categoryIds && categoryIds.length) { if (categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = ` const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category) MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
@ -144,7 +153,9 @@ export default {
RETURN post, category RETURN post, category
` `
await session.run(cypherDeletePreviousRelations, { params }) await session.writeTransaction(transaction => {
return transaction.run(cypherDeletePreviousRelations, { params })
})
updatePostCypher += ` updatePostCypher += `
UNWIND $categoryIds AS categoryId UNWIND $categoryIds AS categoryId
@ -156,11 +167,15 @@ export default {
updatePostCypher += `RETURN post` updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params } const updatePostVariables = { categoryIds, params }
try {
const transactionRes = await session.run(updatePostCypher, updatePostVariables) const writeTxResultPromise = session.writeTransaction(async transaction => {
const [post] = transactionRes.records.map(record => { const updatePostTransactionResponse = await transaction.run(
return record.get('post').properties updatePostCypher,
updatePostVariables,
)
return updatePostTransactionResponse.records.map(record => record.get('post').properties)
}) })
const [post] = await writeTxResultPromise
return post return post
} finally { } finally {
session.close() session.close()
@ -169,9 +184,8 @@ export default {
DeletePost: async (object, args, context, resolveInfo) => { DeletePost: async (object, args, context, resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
// we cannot set slug to 'UNAVAILABE' because of unique constraints const deletePostTransactionResponse = await transaction.run(
const transactionRes = await session.run(
` `
MATCH (post:Post {id: $postId}) MATCH (post:Post {id: $postId})
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
@ -185,7 +199,10 @@ export default {
`, `,
{ postId: args.id }, { postId: args.id },
) )
const [post] = transactionRes.records.map(record => record.get('post').properties) return deletePostTransactionResponse.records.map(record => record.get('post').properties)
})
try {
const [post] = await writeTxResultPromise
return post return post
} finally { } finally {
session.close() session.close()
@ -195,21 +212,24 @@ export default {
const { to, data } = params const { to, data } = params
const { user } = context const { user } = context
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const transactionRes = await session.run( const addPostEmotionsTransactionResponse = await transaction.run(
`MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id}) `
MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id})
MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo) MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo)
RETURN userFrom, postTo, emotedRelation`, RETURN userFrom, postTo, emotedRelation`,
{ user, to, data }, { user, to, data },
) )
return addPostEmotionsTransactionResponse.records.map(record => {
const [emoted] = transactionRes.records.map(record => {
return { return {
from: { ...record.get('userFrom').properties }, from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties }, to: { ...record.get('postTo').properties },
...record.get('emotedRelation').properties, ...record.get('emotedRelation').properties,
} }
}) })
})
try {
const [emoted] = await writeTxResultPromise
return emoted return emoted
} finally { } finally {
session.close() session.close()
@ -219,20 +239,25 @@ export default {
const { to, data } = params const { to, data } = params
const { id: from } = context.user const { id: from } = context.user
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const transactionRes = await session.run( const removePostEmotionsTransactionResponse = await transaction.run(
`MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) `
MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id})
DELETE emotedRelation DELETE emotedRelation
RETURN userFrom, postTo`, RETURN userFrom, postTo
`,
{ from, to, data }, { from, to, data },
) )
const [emoted] = transactionRes.records.map(record => { return removePostEmotionsTransactionResponse.records.map(record => {
return { return {
from: { ...record.get('userFrom').properties }, from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties }, to: { ...record.get('postTo').properties },
emotion: data.emotion, emotion: data.emotion,
} }
}) })
})
try {
const [emoted] = await writeTxResultPromise
return emoted return emoted
} finally { } finally {
session.close() session.close()
@ -344,21 +369,28 @@ export default {
relatedContributions: async (parent, params, context, resolveInfo) => { relatedContributions: async (parent, params, context, resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent const { id } = parent
const statement = ` const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const relatedContributionsTransactionResponse = await transaction.run(
`
MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post) MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
WHERE NOT post.deleted AND NOT post.disabled WHERE NOT post.deleted AND NOT post.disabled
RETURN DISTINCT post RETURN DISTINCT post
LIMIT 10 LIMIT 10
` `,
let relatedContributions { id },
const session = context.driver.session() )
return relatedContributionsTransactionResponse.records.map(
record => record.get('post').properties,
)
})
try { try {
const result = await session.run(statement, { id }) const relatedContributions = await writeTxResultPromise
relatedContributions = result.records.map(r => r.get('post').properties) return relatedContributions
} finally { } finally {
session.close() session.close()
} }
return relatedContributions
}, },
}, },
} }

View File

@ -383,7 +383,10 @@ describe('UpdatePost', () => {
}) })
it('updates a post', async () => { it('updates a post', async () => {
const expected = { data: { UpdatePost: { id: 'p9876', content: 'New content' } } } const expected = {
data: { UpdatePost: { id: 'p9876', content: 'New content' } },
errors: undefined,
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
@ -394,6 +397,7 @@ describe('UpdatePost', () => {
data: { data: {
UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected, expected,
@ -421,6 +425,7 @@ describe('UpdatePost', () => {
categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]),
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected, expected,
@ -441,6 +446,7 @@ describe('UpdatePost', () => {
categories: expect.arrayContaining([{ id: 'cat27' }]), categories: expect.arrayContaining([{ id: 'cat27' }]),
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected, expected,
@ -722,6 +728,7 @@ describe('UpdatePost', () => {
}, },
], ],
}, },
errors: undefined,
} }
variables = { orderBy: ['pinned_desc', 'createdAt_desc'] } variables = { orderBy: ['pinned_desc', 'createdAt_desc'] }
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject( await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject(

View File

@ -24,8 +24,8 @@ export default {
const { user } = await getUserAndBadge(params) const { user } = await getUserAndBadge(params)
const session = context.driver.session() const session = context.driver.session()
try { try {
// silly neode cannot remove relationships await session.writeTransaction(transaction => {
await session.run( return transaction.run(
` `
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId}) MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
DELETE reward DELETE reward
@ -36,6 +36,7 @@ export default {
userId, userId,
}, },
) )
})
} finally { } finally {
session.close() session.close()
} }

View File

@ -1,3 +1,5 @@
import log from './helpers/databaseLogger'
export default { export default {
Mutation: { Mutation: {
shout: async (_object, params, context, _resolveInfo) => { shout: async (_object, params, context, _resolveInfo) => {
@ -5,22 +7,24 @@ export default {
const session = context.driver.session() const session = context.driver.session()
try { try {
const transactionRes = await session.run( const shoutWriteTxResultPromise = session.writeTransaction(async transaction => {
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId}) const shoutTransactionResponse = await transaction.run(
`
MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node) MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node)
RETURN COUNT(relation) > 0 as isShouted`, RETURN COUNT(relation) > 0 as isShouted
`,
{ {
id, id,
type, type,
userId: context.user.id, userId: context.user.id,
}, },
) )
log(shoutTransactionResponse)
const [isShouted] = transactionRes.records.map(record => { return shoutTransactionResponse.records.map(record => record.get('isShouted'))
return record.get('isShouted')
}) })
const [isShouted] = await shoutWriteTxResultPromise
return isShouted return isShouted
} finally { } finally {
session.close() session.close()
@ -31,20 +35,24 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
try { try {
const transactionRes = await session.run( const unshoutWriteTxResultPromise = session.writeTransaction(async transaction => {
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id}) const unshoutTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node) WHERE $type IN labels(node)
DELETE relation DELETE relation
RETURN COUNT(relation) > 0 as isShouted`, RETURN COUNT(relation) > 0 as isShouted
`,
{ {
id, id,
type, type,
userId: context.user.id, userId: context.user.id,
}, },
) )
const [isShouted] = transactionRes.records.map(record => { log(unshoutTransactionResponse)
return record.get('isShouted') return unshoutTransactionResponse.records.map(record => record.get('isShouted'))
}) })
const [isShouted] = await unshoutWriteTxResultPromise
return isShouted return isShouted
} finally { } finally {
session.close() session.close()

View File

@ -1,8 +1,10 @@
import log from './helpers/databaseLogger'
export default { export default {
Query: { Query: {
statistics: async (_parent, _args, { driver }) => { statistics: async (_parent, _args, { driver }) => {
const session = driver.session() const session = driver.session()
const response = {} const counts = {}
try { try {
const mapping = { const mapping = {
countUsers: 'User', countUsers: 'User',
@ -13,27 +15,28 @@ export default {
countFollows: 'FOLLOWS', countFollows: 'FOLLOWS',
countShouts: 'SHOUTED', countShouts: 'SHOUTED',
} }
const cypher = ` const statisticsReadTxResultPromise = session.readTransaction(async transaction => {
const statisticsTransactionResponse = await transaction.run(
`
CALL apoc.meta.stats() YIELD labels, relTypesCount CALL apoc.meta.stats() YIELD labels, relTypesCount
RETURN labels, relTypesCount RETURN labels, relTypesCount
` `,
const result = await session.run(cypher) )
const [statistics] = await result.records.map(record => { log(statisticsTransactionResponse)
return statisticsTransactionResponse.records.map(record => {
return { return {
...record.get('labels'), ...record.get('labels'),
...record.get('relTypesCount'), ...record.get('relTypesCount'),
} }
}) })
})
const [statistics] = await statisticsReadTxResultPromise
Object.keys(mapping).forEach(key => { Object.keys(mapping).forEach(key => {
const stat = statistics[mapping[key]] const stat = statistics[mapping[key]]
response[key] = stat ? stat.toNumber() : 0 counts[key] = stat ? stat.toNumber() : 0
}) })
counts.countInvites = counts.countEmails - counts.countUsers
/* return counts
* Note: invites count is calculated this way because invitation codes are not in use yet
*/
response.countInvites = response.countEmails - response.countUsers
return response
} finally { } finally {
session.close() session.close()
} }

View File

@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server' import { AuthenticationError } from 'apollo-server'
import { getNeode } from '../../bootstrap/neo4j' import { getNeode } from '../../bootstrap/neo4j'
import normalizeEmail from './helpers/normalizeEmail' import normalizeEmail from './helpers/normalizeEmail'
import log from './helpers/databaseLogger'
const neode = getNeode() const neode = getNeode()
@ -25,17 +26,18 @@ export default {
email = normalizeEmail(email) email = normalizeEmail(email)
const session = driver.session() const session = driver.session()
try { try {
const result = await session.run( const loginReadTxResultPromise = session.readTransaction(async transaction => {
const loginTransactionResponse = await transaction.run(
` `
MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail}) MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})
RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1
`, `,
{ userEmail: email }, { userEmail: email },
) )
const [currentUser] = await result.records.map(record => { log(loginTransactionResponse)
return record.get('user') return loginTransactionResponse.records.map(record => record.get('user'))
}) })
const [currentUser] = await loginReadTxResultPromise
if ( if (
currentUser && currentUser &&
(await bcrypt.compareSync(password, currentUser.encryptedPassword)) && (await bcrypt.compareSync(password, currentUser.encryptedPassword)) &&

View File

@ -3,6 +3,7 @@ import fileUpload from './fileUpload'
import { getNeode } from '../../bootstrap/neo4j' import { getNeode } from '../../bootstrap/neo4j'
import { UserInputError, ForbiddenError } from 'apollo-server' import { UserInputError, ForbiddenError } from 'apollo-server'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import log from './helpers/databaseLogger'
const neode = getNeode() const neode = getNeode()
@ -100,35 +101,47 @@ export default {
const blockedUser = await neode.find('User', args.id) const blockedUser = await neode.find('User', args.id)
return blockedUser.toJson() return blockedUser.toJson()
}, },
UpdateUser: async (object, args, context, resolveInfo) => { UpdateUser: async (_parent, params, context, _resolveInfo) => {
const { termsAndConditionsAgreedVersion } = args const { termsAndConditionsAgreedVersion } = params
if (termsAndConditionsAgreedVersion) { if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) { if (!regEx.test(termsAndConditionsAgreedVersion)) {
throw new ForbiddenError('Invalid version format!') throw new ForbiddenError('Invalid version format!')
} }
args.termsAndConditionsAgreedAt = new Date().toISOString() params.termsAndConditionsAgreedAt = new Date().toISOString()
} }
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $params.id})
SET user += $params
SET user.updatedAt = toString(datetime())
RETURN user
`,
{ params },
)
return updateUserTransactionResponse.records.map(record => record.get('user').properties)
})
try { try {
const user = await neode.find('User', args.id) const [user] = await writeTxResultPromise
if (!user) return null return user
await user.update({ ...args, updatedAt: new Date().toISOString() }) } catch (error) {
return user.toJson() throw new UserInputError(error.message)
} catch (e) { } finally {
throw new UserInputError(e.message) session.close()
} }
}, },
DeleteUser: async (object, params, context, resolveInfo) => { DeleteUser: async (object, params, context, resolveInfo) => {
const { resource } = params const { resource } = params
const session = context.driver.session() const session = context.driver.session()
let user
try { try {
if (resource && resource.length) { if (resource && resource.length) {
await Promise.all( await session.writeTransaction(transaction => {
resource.map(async node => { resource.map(node => {
await session.run( return transaction.run(
` `
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
@ -136,17 +149,18 @@ export default {
SET resource.content = 'UNAVAILABLE' SET resource.content = 'UNAVAILABLE'
SET resource.contentExcerpt = 'UNAVAILABLE' SET resource.contentExcerpt = 'UNAVAILABLE'
SET comment.deleted = true SET comment.deleted = true
RETURN author`, RETURN author
`,
{ {
userId: context.user.id, userId: context.user.id,
}, },
) )
}), })
) })
} }
// we cannot set slug to 'UNAVAILABE' because of unique constraints const deleteUserTxResultPromise = session.writeTransaction(async transaction => {
const transactionResult = await session.run( const deleteUserTransactionResponse = await transaction.run(
` `
MATCH (user:User {id: $userId}) MATCH (user:User {id: $userId})
SET user.deleted = true SET user.deleted = true
@ -158,14 +172,18 @@ export default {
WITH user WITH user
OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia) OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia)
DETACH DELETE socialMedia DETACH DELETE socialMedia
RETURN user`, RETURN user
`,
{ userId: context.user.id }, { userId: context.user.id },
) )
user = transactionResult.records.map(r => r.get('user').properties)[0] log(deleteUserTransactionResponse)
return deleteUserTransactionResponse.records.map(record => record.get('user').properties)
})
const [user] = await deleteUserTxResultPromise
return user
} finally { } finally {
session.close() session.close()
} }
return user
}, },
}, },
User: { User: {

View File

@ -68,6 +68,7 @@ describe('User', () => {
it('is permitted', async () => { it('is permitted', async () => {
await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ await expect(query({ query: userQuery, variables })).resolves.toMatchObject({
data: { User: [{ name: 'Johnny' }] }, data: { User: [{ name: 'Johnny' }] },
errors: undefined,
}) })
}) })
@ -90,8 +91,7 @@ describe('User', () => {
}) })
describe('UpdateUser', () => { describe('UpdateUser', () => {
let userParams let userParams, variables
let variables
beforeEach(async () => { beforeEach(async () => {
userParams = { userParams = {
@ -111,16 +111,23 @@ describe('UpdateUser', () => {
}) })
const updateUserMutation = gql` const updateUserMutation = gql`
mutation($id: ID!, $name: String, $termsAndConditionsAgreedVersion: String) { mutation(
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String
) {
UpdateUser( UpdateUser(
id: $id id: $id
name: $name name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) { ) {
id id
name name
termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt termsAndConditionsAgreedAt
locationName
} }
} }
` `
@ -152,7 +159,7 @@ describe('UpdateUser', () => {
authenticatedUser = await user.toJson() authenticatedUser = await user.toJson()
}) })
it('name within specifications', async () => { it('updates the name', async () => {
const expected = { const expected = {
data: { data: {
UpdateUser: { UpdateUser: {
@ -160,36 +167,13 @@ describe('UpdateUser', () => {
name: 'John Doughnut', name: 'John Doughnut',
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
}) })
it('with `null` as name', async () => {
const variables = {
id: 'u47',
name: null,
}
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty(
'message',
'child "name" fails because ["name" contains an invalid value, "name" must be a string]',
)
})
it('with too short name', async () => {
const variables = {
id: 'u47',
name: ' ',
}
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty(
'message',
'child "name" fails because ["name" length must be at least 3 characters long]',
)
})
describe('given a new agreed version of terms and conditions', () => { describe('given a new agreed version of terms and conditions', () => {
beforeEach(async () => { beforeEach(async () => {
variables = { ...variables, termsAndConditionsAgreedVersion: '0.0.2' } variables = { ...variables, termsAndConditionsAgreedVersion: '0.0.2' }
@ -202,6 +186,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: expect.any(String), termsAndConditionsAgreedAt: expect.any(String),
}), }),
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -222,6 +207,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: null, termsAndConditionsAgreedAt: null,
}), }),
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -238,6 +224,14 @@ describe('UpdateUser', () => {
const { errors } = await mutate({ mutation: updateUserMutation, variables }) const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty('message', 'Invalid version format!') expect(errors[0]).toHaveProperty('message', 'Invalid version format!')
}) })
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States of America' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States of America' } },
errors: undefined,
})
})
}) })
}) })
@ -372,6 +366,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject(
expectedResponse, expectedResponse,
@ -418,6 +413,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
@ -465,6 +461,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
@ -511,6 +508,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),

View File

@ -26,7 +26,7 @@ enum _UserOrdering {
type User { type User {
id: ID! id: ID!
actorId: String actorId: String
name: String name: String!
email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email") email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email")
slug: String! slug: String!
avatar: String avatar: String

View File

@ -29,10 +29,16 @@ const factories = {
export const cleanDatabase = async (options = {}) => { export const cleanDatabase = async (options = {}) => {
const { driver = getDriver() } = options const { driver = getDriver() } = options
const cypher = 'MATCH (n) DETACH DELETE n'
const session = driver.session() const session = driver.session()
try { try {
return await session.run(cypher) await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (everything)
DETACH DELETE everything
`,
)
})
} finally { } finally {
session.close() session.close()
} }

View File

@ -46,7 +46,7 @@
<script> <script>
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { allowEmbedIframesMutation } from '~/graphql/User.js' import { updateUserMutation } from '~/graphql/User.js'
export default { export default {
name: 'embed-component', name: 'embed-component',
@ -129,7 +129,7 @@ export default {
async updateEmbedSettings(allowEmbedIframes) { async updateEmbedSettings(allowEmbedIframes) {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: allowEmbedIframesMutation(), mutation: updateUserMutation(),
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
allowEmbedIframes, allowEmbedIframes,

View File

@ -33,12 +33,12 @@
</template> </template>
<script> <script>
import gql from 'graphql-tag'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import find from 'lodash/find' import find from 'lodash/find'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import locales from '~/locales' import locales from '~/locales'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
export default { export default {
components: { components: {
@ -87,14 +87,7 @@ export default {
if (!this.currentUser || !this.currentUser.id) return null if (!this.currentUser || !this.currentUser.id) return null
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: gql` mutation: updateUserMutation(),
mutation($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`,
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
locale: this.$i18n.locale(), locale: this.$i18n.locale(),

View File

@ -21,7 +21,7 @@
</template> </template>
<script> <script>
import vueDropzone from 'nuxt-dropzone' import vueDropzone from 'nuxt-dropzone'
import gql from 'graphql-tag' import { updateUserMutation } from '~/graphql/User.js'
export default { export default {
components: { components: {
@ -62,14 +62,7 @@ export default {
const avatarUpload = file[0] const avatarUpload = file[0]
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: gql` mutation: updateUserMutation(),
mutation($id: ID!, $avatarUpload: Upload) {
UpdateUser(id: $id, avatarUpload: $avatarUpload) {
id
avatar
}
}
`,
variables: { variables: {
avatarUpload, avatarUpload,
id: this.user.id, id: this.user.id,

View File

@ -140,23 +140,42 @@ export const unfollowUserMutation = i18n => {
` `
} }
export const allowEmbedIframesMutation = () => { export const updateUserMutation = () => {
return gql` return gql`
mutation($id: ID!, $allowEmbedIframes: Boolean) { mutation(
UpdateUser(id: $id, allowEmbedIframes: $allowEmbedIframes) { $id: ID!
$slug: String
$name: String
$locationName: String
$about: String
$allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$locale: String
$termsAndConditionsAgreedVersion: String
$avatarUpload: Upload
) {
UpdateUser(
id: $id
slug: $slug
name: $name
locationName: $locationName
about: $about
allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
locale: $locale
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatarUpload: $avatarUpload
) {
id id
slug
name
locationName
about
allowEmbedIframes allowEmbedIframes
}
}
`
}
export const showShoutsPubliclyMutation = () => {
return gql`
mutation($id: ID!, $showShoutsPublicly: Boolean) {
UpdateUser(id: $id, showShoutsPublicly: $showShoutsPublicly) {
id
showShoutsPublicly showShoutsPublicly
locale
termsAndConditionsAgreedVersion
avatar
} }
} }
` `
@ -169,14 +188,3 @@ export const checkSlugAvailableQuery = gql`
} }
} }
` `
export const localeMutation = () => {
return gql`
mutation($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`
}

View File

@ -37,7 +37,7 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { allowEmbedIframesMutation } from '~/graphql/User.js' import { updateUserMutation } from '~/graphql/User.js'
export default { export default {
head() { head() {
@ -69,7 +69,7 @@ export default {
async submit() { async submit() {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: allowEmbedIframesMutation(), mutation: updateUserMutation(),
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
allowEmbedIframes: !this.disabled, allowEmbedIframes: !this.disabled,

View File

@ -41,40 +41,14 @@
</template> </template>
<script> <script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { CancelToken } from 'axios' import { CancelToken } from 'axios'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm' import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
import { updateUserMutation } from '~/graphql/User'
let timeout let timeout
const mapboxToken = process.env.MAPBOX_TOKEN const mapboxToken = process.env.MAPBOX_TOKEN
/*
const query = gql`
query getUser($id: ID) {
User(id: $id) {
id
name
locationName
about
}
}
`
*/
const mutation = gql`
mutation($id: ID!, $slug: String, $name: String, $locationName: String, $about: String) {
UpdateUser(id: $id, slug: $slug, name: $name, locationName: $locationName, about: $about) {
id
slug
name
locationName
about
}
}
`
export default { export default {
data() { data() {
return { return {
@ -120,7 +94,7 @@ export default {
locationName = locationName && (locationName.label || locationName) locationName = locationName && (locationName.label || locationName)
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation, mutation: updateUserMutation(),
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
name, name,

View File

@ -10,7 +10,7 @@
<script> <script>
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { showShoutsPubliclyMutation } from '~/graphql/User' import { updateUserMutation } from '~/graphql/User'
export default { export default {
data() { data() {
@ -36,7 +36,7 @@ export default {
async submit() { async submit() {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: showShoutsPubliclyMutation(), mutation: updateUserMutation(),
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
showShoutsPublicly: this.shoutsAllowed, showShoutsPublicly: this.shoutsAllowed,

View File

@ -24,17 +24,10 @@
</template> </template>
<script> <script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { VERSION } from '~/constants/terms-and-conditions-version.js' import { VERSION } from '~/constants/terms-and-conditions-version.js'
const mutation = gql` import { updateUserMutation } from '~/graphql/User.js'
mutation($id: ID!, $termsAndConditionsAgreedVersion: String) {
UpdateUser(id: $id, termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion) {
id
termsAndConditionsAgreedVersion
}
}
`
export default { export default {
layout: 'default', layout: 'default',
head() { head() {
@ -74,7 +67,7 @@ export default {
async submit() { async submit() {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation, mutation: updateUserMutation(),
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
termsAndConditionsAgreedVersion: VERSION, termsAndConditionsAgreedVersion: VERSION,