diff --git a/backend/package.json b/backend/package.json
index 83b88d0b9..0b7c9380f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -19,9 +19,8 @@
"test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
- "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js",
- "db:reset": "cross-env babel-node src/seed/reset-db.js",
- "db:seed": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions run-p --race dev db:script:seed"
+ "db:reset": "babel-node src/seed/reset-db.js",
+ "db:seed": "babel-node src/seed/seed-db.js"
},
"author": "Human Connection gGmbH",
"license": "MIT",
@@ -49,19 +48,19 @@
"apollo-client": "~2.6.4",
"apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15",
- "apollo-server": "~2.9.1",
+ "apollo-server": "~2.9.3",
"apollo-server-express": "^2.9.0",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
- "cross-env": "~5.2.0",
+ "cross-env": "~5.2.1",
"date-fns": "2.0.1",
"debug": "~4.1.1",
"dotenv": "~8.1.0",
"express": "^4.17.1",
"faker": "Marak/faker.js#master",
- "graphql": "^14.5.3",
+ "graphql": "^14.5.4",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.5",
@@ -90,7 +89,7 @@
"metascraper-video": "^5.6.5",
"metascraper-youtube": "^5.6.3",
"minimatch": "^3.0.4",
- "neo4j-driver": "~1.7.5",
+ "neo4j-driver": "~1.7.6",
"neo4j-graphql-js": "^2.7.2",
"neode": "^0.3.2",
"node-fetch": "~2.6.0",
@@ -111,18 +110,18 @@
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.5",
"@babel/register": "~7.5.5",
- "apollo-server-testing": "~2.9.1",
+ "apollo-server-testing": "~2.9.3",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
- "eslint": "~6.2.2",
+ "eslint": "~6.3.0",
"eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.18.2",
- "eslint-plugin-jest": "~22.15.2",
- "eslint-plugin-node": "~9.1.0",
+ "eslint-plugin-jest": "~22.16.0",
+ "eslint-plugin-node": "~9.2.0",
"eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.1",
diff --git a/backend/src/jwt/decode.js b/backend/src/jwt/decode.js
index b98357103..d022f3512 100644
--- a/backend/src/jwt/decode.js
+++ b/backend/src/jwt/decode.js
@@ -13,7 +13,7 @@ export default async (driver, authorizationHeader) => {
}
const session = driver.session()
const query = `
- MATCH (user:User {id: {id} })
+ MATCH (user:User {id: $id, deleted: false, disabled: false })
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1
`
@@ -23,7 +23,6 @@ export default async (driver, authorizationHeader) => {
return record.get('user')
})
if (!currentUser) return null
- if (currentUser.disabled) return null
return {
token,
...currentUser,
diff --git a/backend/src/jwt/decode.spec.js b/backend/src/jwt/decode.spec.js
new file mode 100644
index 000000000..df6914f25
--- /dev/null
+++ b/backend/src/jwt/decode.spec.js
@@ -0,0 +1,104 @@
+import Factory from '../seed/factories/index'
+import { getDriver } from '../bootstrap/neo4j'
+import decode from './decode'
+
+const factory = Factory()
+const driver = getDriver()
+
+// here is the decoded JWT token:
+// {
+// role: 'user',
+// locationName: null,
+// name: 'Jenny Rostock',
+// about: null,
+// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
+// id: 'u3',
+// email: 'user@example.org',
+// slug: 'jenny-rostock',
+// iat: 1550846680,
+// exp: 1637246680,
+// aud: 'http://localhost:3000',
+// iss: 'http://localhost:4000',
+// sub: 'u3'
+// }
+export const validAuthorizationHeader =
+ 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc'
+
+afterEach(async () => {
+ await factory.cleanDatabase()
+})
+
+describe('decode', () => {
+ let authorizationHeader
+ const returnsNull = async () => {
+ await expect(decode(driver, authorizationHeader)).resolves.toBeNull()
+ }
+
+ describe('given `null` as JWT Bearer token', () => {
+ beforeEach(() => {
+ authorizationHeader = null
+ })
+ it('returns null', returnsNull)
+ })
+
+ describe('given no JWT Bearer token', () => {
+ beforeEach(() => {
+ authorizationHeader = undefined
+ })
+ it('returns null', returnsNull)
+ })
+
+ describe('given malformed JWT Bearer token', () => {
+ beforeEach(() => {
+ authorizationHeader = 'blah'
+ })
+ it('returns null', returnsNull)
+ })
+
+ describe('given valid JWT Bearer token', () => {
+ beforeEach(() => {
+ authorizationHeader = validAuthorizationHeader
+ })
+ it('returns null', returnsNull)
+
+ describe('and corresponding user in the database', () => {
+ let user
+ beforeEach(async () => {
+ user = await factory.create('User', {
+ role: 'user',
+ name: 'Jenny Rostock',
+ avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
+ id: 'u3',
+ email: 'user@example.org',
+ slug: 'jenny-rostock',
+ })
+ })
+
+ it('returns user object except email', async () => {
+ await expect(decode(driver, authorizationHeader)).resolves.toMatchObject({
+ role: 'user',
+ name: 'Jenny Rostock',
+ avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
+ id: 'u3',
+ email: null,
+ slug: 'jenny-rostock',
+ })
+ })
+
+ describe('but user is deleted', () => {
+ beforeEach(async () => {
+ await user.update({ updatedAt: new Date().toISOString(), deleted: true })
+ })
+
+ it('returns null', returnsNull)
+ })
+ describe('but user is disabled', () => {
+ beforeEach(async () => {
+ await user.update({ updatedAt: new Date().toISOString(), disabled: true })
+ })
+
+ it('returns null', returnsNull)
+ })
+ })
+ })
+})
diff --git a/backend/src/middleware/dateTimeMiddleware.js b/backend/src/middleware/dateTimeMiddleware.js
index c8af53a7a..ff1fcc996 100644
--- a/backend/src/middleware/dateTimeMiddleware.js
+++ b/backend/src/middleware/dateTimeMiddleware.js
@@ -12,11 +12,9 @@ export default {
CreatePost: setCreatedAt,
CreateComment: setCreatedAt,
CreateOrganization: setCreatedAt,
- CreateNotification: setCreatedAt,
UpdateUser: setUpdatedAt,
UpdatePost: setUpdatedAt,
UpdateComment: setUpdatedAt,
UpdateOrganization: setUpdatedAt,
- UpdateNotification: setUpdatedAt,
},
}
diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js
index c71332db6..4dfcb76d1 100644
--- a/backend/src/middleware/filterBubble/filterBubble.spec.js
+++ b/backend/src/middleware/filterBubble/filterBubble.spec.js
@@ -1,10 +1,16 @@
-import { GraphQLClient } from 'graphql-request'
-import { host, login } from '../../jest/helpers'
+import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
-import { neode } from '../../bootstrap/neo4j'
+import { createTestClient } from 'apollo-server-testing'
+import createServer from '../../server'
+import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
const factory = Factory()
-const instance = neode()
+const neode = getNeode()
+const driver = getDriver()
+
+let authenticatedUser
+let user
+let query
const currentUserParams = {
id: 'u1',
@@ -26,24 +32,42 @@ const randomAuthorParams = {
const categoryIds = ['cat9']
beforeEach(async () => {
- await Promise.all([
+ const [currentUser, followedAuthor, randomAuthor] = await Promise.all([
factory.create('User', currentUserParams),
factory.create('User', followedAuthorParams),
factory.create('User', randomAuthorParams),
])
- await instance.create('Category', {
+ user = currentUser
+ await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
- const [asYourself, asFollowedUser, asSomeoneElse] = await Promise.all([
- Factory().authenticateAs(currentUserParams),
- Factory().authenticateAs(followedAuthorParams),
- Factory().authenticateAs(randomAuthorParams),
- ])
- await asYourself.follow({ id: 'u2', type: 'User' })
- await asFollowedUser.create('Post', { title: 'This is the post of a followed user', categoryIds })
- await asSomeoneElse.create('Post', { title: 'This is some random post', categoryIds })
+ await currentUser.relateTo(followedAuthor, 'following')
+ await factory.create('Post', {
+ author: followedAuthor,
+ title: 'This is the post of a followed user',
+ categoryIds,
+ })
+ await factory.create('Post', {
+ author: randomAuthor,
+ title: 'This is some random post',
+ categoryIds,
+ })
+})
+
+beforeAll(() => {
+ const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ neode,
+ user: authenticatedUser,
+ }
+ },
+ })
+ const client = createTestClient(server)
+ query = client.query
})
afterEach(async () => {
@@ -52,33 +76,44 @@ afterEach(async () => {
describe('Filter posts by author is followed by sb.', () => {
describe('given an authenticated user', () => {
- let authenticatedClient
-
beforeEach(async () => {
- const headers = await login(currentUserParams)
- authenticatedClient = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
describe('no filter bubble', () => {
it('returns all posts', async () => {
- const query = '{ Post(filter: { }) { title } }'
+ const postQuery = gql`
+ {
+ Post(filter: {}) {
+ title
+ }
+ }
+ `
const expected = {
- Post: [
- { title: 'This is some random post' },
- { title: 'This is the post of a followed user' },
- ],
+ data: {
+ Post: [
+ { title: 'This is some random post' },
+ { title: 'This is the post of a followed user' },
+ ],
+ },
}
- await expect(authenticatedClient.request(query)).resolves.toEqual(expected)
+ await expect(query({ query: postQuery })).resolves.toMatchObject(expected)
})
})
describe('filtering for posts of followed users only', () => {
it('returns only posts authored by followed users', async () => {
- const query = '{ Post( filter: { author: { followedBy_some: { id: "u1" } } }) { title } }'
+ const postQuery = gql`
+ {
+ Post(filter: { author: { followedBy_some: { id: "u1" } } }) {
+ title
+ }
+ }
+ `
const expected = {
- Post: [{ title: 'This is the post of a followed user' }],
+ data: { Post: [{ title: 'This is the post of a followed user' }] },
}
- await expect(authenticatedClient.request(query)).resolves.toEqual(expected)
+ await expect(query({ query: postQuery })).resolves.toMatchObject(expected)
})
})
})
diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js
index c9dfe406c..64386800d 100644
--- a/backend/src/middleware/notifications/notificationsMiddleware.js
+++ b/backend/src/middleware/notifications/notificationsMiddleware.js
@@ -4,13 +4,13 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return
// Checked here, because it does not go through GraphQL checks at all in this file.
- const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post']
+ 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', 'comment_on_post'].includes(reason))
+ (label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
@@ -25,8 +25,9 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
- CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
- MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
+ MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
+ SET notification.read = FALSE
+ SET notification.createdAt = $createdAt
`
break
}
@@ -37,20 +38,22 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
- CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
- MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
+ MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
+ SET notification.read = FALSE
+ SET notification.createdAt = $createdAt
`
break
}
- case 'comment_on_post': {
+ case 'commented_on_post': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user)
- CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
- MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
+ MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
+ SET notification.read = FALSE
+ SET notification.createdAt = $createdAt
`
break
}
@@ -105,7 +108,7 @@ const handleCreateComment = async (resolve, root, args, context, resolveInfo) =>
return record.get('user')
})
if (context.user.id !== postAuthor.id) {
- await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context)
+ await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context)
}
}
diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js
index 624cedddc..b737768f2 100644
--- a/backend/src/middleware/notifications/notificationsMiddleware.spec.js
+++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js
@@ -77,14 +77,18 @@ afterEach(async () => {
describe('notifications', () => {
const notificationQuery = gql`
query($read: Boolean) {
- currentUser {
- notifications(read: $read, orderBy: createdAt_desc) {
- read
- reason
- post {
+ notifications(read: $read, orderBy: createdAt_desc) {
+ read
+ reason
+ createdAt
+ from {
+ __typename
+ ... on Post {
+ id
content
}
- comment {
+ ... on Comment {
+ id
content
}
}
@@ -154,18 +158,18 @@ describe('notifications', () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
- currentUser: {
- notifications: [
- {
- read: false,
- reason: 'comment_on_post',
- post: null,
- comment: {
- content: commentContent,
- },
+ notifications: [
+ {
+ read: false,
+ createdAt: expect.any(String),
+ reason: 'commented_on_post',
+ from: {
+ __typename: 'Comment',
+ id: 'c47',
+ content: commentContent,
},
- ],
- },
+ },
+ ],
},
})
const { query } = createTestClient(server)
@@ -183,11 +187,7 @@ describe('notifications', () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
- data: {
- currentUser: {
- notifications: [],
- },
- },
+ data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@@ -211,11 +211,7 @@ describe('notifications', () => {
await notifiedUser.relateTo(commentAuthor, 'blocked')
await createCommentOnPostAction()
const expected = expect.objectContaining({
- data: {
- currentUser: {
- notifications: [],
- },
- },
+ data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@@ -253,18 +249,18 @@ describe('notifications', () => {
'Hey @al-capone how do you do?'
const expected = expect.objectContaining({
data: {
- currentUser: {
- notifications: [
- {
- read: false,
- reason: 'mentioned_in_post',
- post: {
- content: expectedContent,
- },
- comment: null,
+ notifications: [
+ {
+ read: false,
+ createdAt: expect.any(String),
+ reason: 'mentioned_in_post',
+ from: {
+ __typename: 'Post',
+ id: 'p47',
+ content: expectedContent,
},
- ],
- },
+ },
+ ],
},
})
const { query } = createTestClient(server)
@@ -278,7 +274,7 @@ describe('notifications', () => {
).resolves.toEqual(expected)
})
- describe('many times', () => {
+ describe('updates the post and mentions me again', () => {
const updatePostAction = async () => {
const updatedContent = `
One more mention to
@@ -307,33 +303,25 @@ describe('notifications', () => {
authenticatedUser = await notifiedUser.toJson()
}
- it('creates exactly one more notification', async () => {
+ it('creates no duplicate notification for the same resource', async () => {
+ const expectedUpdatedContent =
+ '
One more mention to
@al-capone
and again:
@al-capone
and again
@al-capone
'
await createPostAction()
await updatePostAction()
- const expectedContent =
- '
One more mention to
@al-capone
and again:
@al-capone
and again
@al-capone
'
const expected = expect.objectContaining({
data: {
- currentUser: {
- notifications: [
- {
- read: false,
- reason: 'mentioned_in_post',
- post: {
- content: expectedContent,
- },
- comment: null,
+ notifications: [
+ {
+ read: false,
+ createdAt: expect.any(String),
+ reason: 'mentioned_in_post',
+ from: {
+ __typename: 'Post',
+ id: 'p47',
+ content: expectedUpdatedContent,
},
- {
- read: false,
- reason: 'mentioned_in_post',
- post: {
- content: expectedContent,
- },
- comment: null,
- },
- ],
- },
+ },
+ ],
},
})
await expect(
@@ -345,6 +333,68 @@ describe('notifications', () => {
}),
).resolves.toEqual(expected)
})
+
+ describe('if the notification was marked as read earlier', () => {
+ const markAsReadAction = async () => {
+ const mutation = gql`
+ mutation($id: ID!) {
+ markAsRead(id: $id) {
+ read
+ }
+ }
+ `
+ await mutate({ mutation, variables: { id: 'p47' } })
+ }
+
+ describe('but the next mention happens after the notification was marked as read', () => {
+ it('sets the `read` attribute to false again', async () => {
+ await createPostAction()
+ await markAsReadAction()
+ const {
+ data: {
+ notifications: [{ read: readBefore }],
+ },
+ } = await query({
+ query: notificationQuery,
+ })
+ await updatePostAction()
+ const {
+ data: {
+ notifications: [{ read: readAfter }],
+ },
+ } = await query({
+ query: notificationQuery,
+ })
+ expect(readBefore).toEqual(true)
+ expect(readAfter).toEqual(false)
+ })
+
+ it('updates the `createdAt` attribute', async () => {
+ await createPostAction()
+ await markAsReadAction()
+ const {
+ data: {
+ notifications: [{ createdAt: createdAtBefore }],
+ },
+ } = await query({
+ query: notificationQuery,
+ })
+ await updatePostAction()
+ const {
+ data: {
+ notifications: [{ createdAt: createdAtAfter }],
+ },
+ } = await query({
+ query: notificationQuery,
+ })
+ expect(createdAtBefore).toBeTruthy()
+ expect(Date.parse(createdAtBefore)).toEqual(expect.any(Number))
+ expect(createdAtAfter).toBeTruthy()
+ expect(Date.parse(createdAtAfter)).toEqual(expect.any(Number))
+ expect(createdAtBefore).not.toEqual(createdAtAfter)
+ })
+ })
+ })
})
describe('but the author of the post blocked me', () => {
@@ -355,11 +405,7 @@ describe('notifications', () => {
it('sends no notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
- data: {
- currentUser: {
- notifications: [],
- },
- },
+ data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
@@ -397,18 +443,18 @@ describe('notifications', () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
- currentUser: {
- notifications: [
- {
- read: false,
- reason: 'mentioned_in_comment',
- post: null,
- comment: {
- content: commentContent,
- },
+ notifications: [
+ {
+ read: false,
+ createdAt: expect.any(String),
+ reason: 'mentioned_in_comment',
+ from: {
+ __typename: 'Comment',
+ id: 'c47',
+ content: commentContent,
},
- ],
- },
+ },
+ ],
},
})
const { query } = createTestClient(server)
@@ -440,11 +486,7 @@ describe('notifications', () => {
it('sends no notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
- data: {
- currentUser: {
- notifications: [],
- },
- },
+ data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js
index 83c29d19d..745387e41 100644
--- a/backend/src/middleware/permissionsMiddleware.js
+++ b/backend/src/middleware/permissionsMiddleware.js
@@ -41,32 +41,6 @@ const isMySocialMedia = rule({
return socialMedia.ownedBy.node.id === user.id
})
-const belongsToMe = rule({
- cache: 'no_cache',
-})(async (_, args, context) => {
- const {
- driver,
- user: { id: userId },
- } = context
- const { id: notificationId } = args
- const session = driver.session()
- const result = await session.run(
- `
- MATCH (u:User {id: $userId})<-[:NOTIFIED]-(n:Notification {id: $notificationId})
- RETURN n
- `,
- {
- userId,
- notificationId,
- },
- )
- const [notification] = result.records.map(record => {
- return record.get('n')
- })
- session.close()
- return Boolean(notification)
-})
-
/* TODO: decide if we want to remove this check: the check
* `onlyEnabledContent` throws authorization errors only if you have
* arguments for `disabled` or `deleted` assuming these are filter
@@ -117,13 +91,11 @@ const isAuthor = rule({
resourceId,
},
)
+ session.close()
const [author] = result.records.map(record => {
return record.get('author')
})
- const {
- properties: { id: authorId },
- } = author
- session.close()
+ const authorId = author && author.properties && author.properties.id
return authorId === user.id
})
@@ -149,7 +121,6 @@ const permissions = shield(
Category: allow,
Tag: allow,
Report: isModerator,
- Notification: isAdmin,
statistics: allow,
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),
@@ -158,8 +129,9 @@ const permissions = shield(
isLoggedIn: allow,
Badge: allow,
PostsEmotionsCountByEmotion: allow,
- PostsEmotionsByCurrentUser: allow,
+ PostsEmotionsByCurrentUser: isAuthenticated,
blockedUsers: isAuthenticated,
+ notifications: isAuthenticated,
},
Mutation: {
'*': deny,
@@ -168,7 +140,6 @@ const permissions = shield(
Signup: isAdmin,
SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
- UpdateNotification: belongsToMe,
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
@@ -198,6 +169,7 @@ const permissions = shield(
RemovePostEmotions: isAuthenticated,
block: isAuthenticated,
unblock: isAuthenticated,
+ markAsRead: isAuthenticated,
},
User: {
email: isMyOwn,
diff --git a/backend/src/middleware/softDeleteMiddleware.js b/backend/src/middleware/softDeleteMiddleware.js
index cc5aa06c5..d3fd13cfc 100644
--- a/backend/src/middleware/softDeleteMiddleware.js
+++ b/backend/src/middleware/softDeleteMiddleware.js
@@ -13,15 +13,16 @@ const setDefaultFilters = (resolve, root, args, context, info) => {
return resolve(root, args, context, info)
}
-const obfuscateDisabled = async (resolve, root, args, context, info) => {
- if (!isModerator(context) && root.disabled) {
+const obfuscate = async (resolve, root, args, context, info) => {
+ if (root.deleted || (!isModerator(context) && root.disabled)) {
root.content = 'UNAVAILABLE'
root.contentExcerpt = 'UNAVAILABLE'
root.title = 'UNAVAILABLE'
- root.image = 'UNAVAILABLE'
+ root.slug = 'UNAVAILABLE'
root.avatar = 'UNAVAILABLE'
root.about = 'UNAVAILABLE'
root.name = 'UNAVAILABLE'
+ root.image = null // avoid unecessary 500 errors
}
return resolve(root, args, context, info)
}
@@ -40,7 +41,7 @@ export default {
}
return resolve(root, args, context, info)
},
- Post: obfuscateDisabled,
- User: obfuscateDisabled,
- Comment: obfuscateDisabled,
+ Post: obfuscate,
+ User: obfuscate,
+ Comment: obfuscate,
}
diff --git a/backend/src/middleware/softDeleteMiddleware.spec.js b/backend/src/middleware/softDeleteMiddleware.spec.js
index 03c97c020..a749de819 100644
--- a/backend/src/middleware/softDeleteMiddleware.spec.js
+++ b/backend/src/middleware/softDeleteMiddleware.spec.js
@@ -1,49 +1,70 @@
-import { GraphQLClient } from 'graphql-request'
import Factory from '../seed/factories'
-import { host, login } from '../jest/helpers'
-import { neode } from '../bootstrap/neo4j'
+import { gql } from '../jest/helpers'
+import { neode as getNeode, getDriver } from '../bootstrap/neo4j'
+import createServer from '../server'
+import { createTestClient } from 'apollo-server-testing'
const factory = Factory()
-const instance = neode()
+const neode = getNeode()
+const driver = getDriver()
-let client
let query
-let action
+let mutate
+let graphqlQuery
const categoryIds = ['cat9']
+let authenticatedUser
+let user
+let moderator
+let troll
+
+const action = () => {
+ return query({ query: graphqlQuery })
+}
beforeAll(async () => {
// For performance reasons we do this only once
- await Promise.all([
- factory.create('User', { id: 'u1', role: 'user', email: 'user@example.org', password: '1234' }),
+ const users = await Promise.all([
+ factory.create('User', { id: 'u1', role: 'user' }),
factory.create('User', {
id: 'm1',
role: 'moderator',
- email: 'moderator@example.org',
password: '1234',
}),
factory.create('User', {
id: 'u2',
role: 'user',
name: 'Offensive Name',
+ slug: 'offensive-name',
avatar: '/some/offensive/avatar.jpg',
about: 'This self description is very offensive',
- email: 'troll@example.org',
- password: '1234',
- }),
- instance.create('Category', {
- id: 'cat9',
- name: 'Democracy & Politics',
- icon: 'university',
}),
])
- await factory.authenticateAs({ email: 'user@example.org', password: '1234' })
+ user = users[0]
+ moderator = users[1]
+ troll = users[2]
+
+ await neode.create('Category', {
+ id: 'cat9',
+ name: 'Democracy & Politics',
+ icon: 'university',
+ })
+
await Promise.all([
- factory.follow({ id: 'u2', type: 'User' }),
- factory.create('Post', { id: 'p1', title: 'Deleted post', deleted: true, categoryIds }),
+ user.relateTo(troll, 'following'),
factory.create('Post', {
+ author: user,
+ id: 'p1',
+ title: 'Deleted post',
+ slug: 'deleted-post',
+ deleted: true,
+ categoryIds,
+ }),
+ factory.create('Post', {
+ author: user,
id: 'p3',
title: 'Publicly visible post',
+ slug: 'publicly-visible-post',
deleted: false,
categoryIds,
}),
@@ -51,32 +72,56 @@ beforeAll(async () => {
await Promise.all([
factory.create('Comment', {
+ author: user,
id: 'c2',
postId: 'p3',
content: 'Enabled comment on public post',
}),
])
- await Promise.all([factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' })])
-
- const asTroll = Factory()
- await asTroll.authenticateAs({ email: 'troll@example.org', password: '1234' })
- await asTroll.create('Post', {
+ await factory.create('Post', {
id: 'p2',
+ author: troll,
title: 'Disabled post',
content: 'This is an offensive post content',
+ contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false,
categoryIds,
})
- await asTroll.create('Comment', { id: 'c1', postId: 'p3', content: 'Disabled comment' })
- await Promise.all([asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' })])
+ await factory.create('Comment', {
+ id: 'c1',
+ author: troll,
+ postId: 'p3',
+ content: 'Disabled comment',
+ contentExcerpt: 'Disabled comment',
+ })
- const asModerator = Factory()
- await asModerator.authenticateAs({ email: 'moderator@example.org', password: '1234' })
- await asModerator.mutate('mutation { disable( id: "p2") }')
- await asModerator.mutate('mutation { disable( id: "c1") }')
- await asModerator.mutate('mutation { disable( id: "u2") }')
+ const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ neode,
+ user: authenticatedUser,
+ }
+ },
+ })
+ const client = createTestClient(server)
+ query = client.query
+ mutate = client.mutate
+
+ authenticatedUser = await moderator.toJson()
+ const disableMutation = gql`
+ mutation($id: ID!) {
+ disable(id: $id)
+ }
+ `
+ await Promise.all([
+ mutate({ mutation: disableMutation, variables: { id: 'c1' } }),
+ mutate({ mutation: disableMutation, variables: { id: 'u2' } }),
+ mutate({ mutation: disableMutation, variables: { id: 'p2' } }),
+ ])
+ authenticatedUser = null
})
afterAll(async () => {
@@ -85,93 +130,124 @@ afterAll(async () => {
describe('softDeleteMiddleware', () => {
describe('read disabled content', () => {
- let user
- let post
- let comment
+ let subject
const beforeComment = async () => {
- query = '{ User(id: "u1") { following { comments { content contentExcerpt } } } }'
- const response = await action()
- comment = response.User[0].following[0].comments[0]
+ graphqlQuery = gql`
+ {
+ User(id: "u1") {
+ following {
+ comments {
+ content
+ contentExcerpt
+ }
+ }
+ }
+ }
+ `
+ const { data } = await action()
+ subject = data.User[0].following[0].comments[0]
}
const beforeUser = async () => {
- query = '{ User(id: "u1") { following { name about avatar } } }'
- const response = await action()
- user = response.User[0].following[0]
+ graphqlQuery = gql`
+ {
+ User(id: "u1") {
+ following {
+ name
+ slug
+ about
+ avatar
+ }
+ }
+ }
+ `
+ const { data } = await action()
+ subject = data.User[0].following[0]
}
const beforePost = async () => {
- query =
- '{ User(id: "u1") { following { contributions { title image content contentExcerpt } } } }'
- const response = await action()
- post = response.User[0].following[0].contributions[0]
- }
-
- action = () => {
- return client.request(query)
+ graphqlQuery = gql`
+ {
+ User(id: "u1") {
+ following {
+ contributions {
+ title
+ slug
+ image
+ content
+ contentExcerpt
+ }
+ }
+ }
+ }
+ `
+ const { data } = await action()
+ subject = data.User[0].following[0].contributions[0]
}
describe('as moderator', () => {
beforeEach(async () => {
- const headers = await login({ email: 'moderator@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await moderator.toJson()
})
describe('User', () => {
beforeEach(beforeUser)
- it('displays name', () => expect(user.name).toEqual('Offensive Name'))
+ it('displays name', () => expect(subject.name).toEqual('Offensive Name'))
+ it('displays slug', () => expect(subject.slug).toEqual('offensive-name'))
it('displays about', () =>
- expect(user.about).toEqual('This self description is very offensive'))
- it('displays avatar', () => expect(user.avatar).toEqual('/some/offensive/avatar.jpg'))
+ expect(subject.about).toEqual('This self description is very offensive'))
+ it('displays avatar', () => expect(subject.avatar).toEqual('/some/offensive/avatar.jpg'))
})
describe('Post', () => {
beforeEach(beforePost)
- it('displays title', () => expect(post.title).toEqual('Disabled post'))
+ it('displays title', () => expect(subject.title).toEqual('Disabled post'))
+ it('displays slug', () => expect(subject.slug).toEqual('disabled-post'))
it('displays content', () =>
- expect(post.content).toEqual('This is an offensive post content'))
+ expect(subject.content).toEqual('This is an offensive post content'))
it('displays contentExcerpt', () =>
- expect(post.contentExcerpt).toEqual('This is an offensive post content'))
- it('displays image', () => expect(post.image).toEqual('/some/offensive/image.jpg'))
+ expect(subject.contentExcerpt).toEqual('This is an offensive post content'))
+ it('displays image', () => expect(subject.image).toEqual('/some/offensive/image.jpg'))
})
describe('Comment', () => {
beforeEach(beforeComment)
- it('displays content', () => expect(comment.content).toEqual('Disabled comment'))
+ it('displays content', () => expect(subject.content).toEqual('Disabled comment'))
it('displays contentExcerpt', () =>
- expect(comment.contentExcerpt).toEqual('Disabled comment'))
+ expect(subject.contentExcerpt).toEqual('Disabled comment'))
})
})
describe('as user', () => {
beforeEach(async () => {
- const headers = await login({ email: 'user@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
describe('User', () => {
beforeEach(beforeUser)
- it('displays name', () => expect(user.name).toEqual('UNAVAILABLE'))
- it('obfuscates about', () => expect(user.about).toEqual('UNAVAILABLE'))
- it('obfuscates avatar', () => expect(user.avatar).toEqual('UNAVAILABLE'))
+ it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE'))
+ it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE'))
+ it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE'))
+ it('obfuscates avatar', () => expect(subject.avatar).toEqual('UNAVAILABLE'))
})
describe('Post', () => {
beforeEach(beforePost)
- it('obfuscates title', () => expect(post.title).toEqual('UNAVAILABLE'))
- it('obfuscates content', () => expect(post.content).toEqual('UNAVAILABLE'))
- it('obfuscates contentExcerpt', () => expect(post.contentExcerpt).toEqual('UNAVAILABLE'))
- it('obfuscates image', () => expect(post.image).toEqual('UNAVAILABLE'))
+ it('obfuscates title', () => expect(subject.title).toEqual('UNAVAILABLE'))
+ it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE'))
+ it('obfuscates content', () => expect(subject.content).toEqual('UNAVAILABLE'))
+ it('obfuscates contentExcerpt', () => expect(subject.contentExcerpt).toEqual('UNAVAILABLE'))
+ it('obfuscates image', () => expect(subject.image).toEqual(null))
})
describe('Comment', () => {
beforeEach(beforeComment)
- it('obfuscates content', () => expect(comment.content).toEqual('UNAVAILABLE'))
- it('obfuscates contentExcerpt', () => expect(comment.contentExcerpt).toEqual('UNAVAILABLE'))
+ it('obfuscates content', () => expect(subject.content).toEqual('UNAVAILABLE'))
+ it('obfuscates contentExcerpt', () => expect(subject.contentExcerpt).toEqual('UNAVAILABLE'))
})
})
})
@@ -179,43 +255,57 @@ describe('softDeleteMiddleware', () => {
describe('Query', () => {
describe('Post', () => {
beforeEach(async () => {
- query = '{ Post { title } }'
+ graphqlQuery = gql`
+ {
+ Post {
+ title
+ }
+ }
+ `
})
describe('as user', () => {
beforeEach(async () => {
- const headers = await login({ email: 'user@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
it('hides deleted or disabled posts', async () => {
- const expected = { Post: [{ title: 'Publicly visible post' }] }
- await expect(action()).resolves.toEqual(expected)
+ const expected = { data: { Post: [{ title: 'Publicly visible post' }] } }
+ await expect(action()).resolves.toMatchObject(expected)
})
})
describe('as moderator', () => {
beforeEach(async () => {
- const headers = await login({ email: 'moderator@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await moderator.toJson()
})
it('shows disabled but hides deleted posts', async () => {
const expected = [{ title: 'Disabled post' }, { title: 'Publicly visible post' }]
- const { Post } = await action()
+ const {
+ data: { Post },
+ } = await action()
await expect(Post).toEqual(expect.arrayContaining(expected))
})
})
describe('.comments', () => {
beforeEach(async () => {
- query = '{ Post(id: "p3") { title comments { content } } }'
+ graphqlQuery = gql`
+ {
+ Post(id: "p3") {
+ title
+ comments {
+ content
+ }
+ }
+ }
+ `
})
describe('as user', () => {
beforeEach(async () => {
- const headers = await login({ email: 'user@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
it('conceals disabled comments', async () => {
@@ -224,7 +314,9 @@ describe('softDeleteMiddleware', () => {
{ content: 'UNAVAILABLE' },
]
const {
- Post: [{ comments }],
+ data: {
+ Post: [{ comments }],
+ },
} = await action()
await expect(comments).toEqual(expect.arrayContaining(expected))
})
@@ -232,8 +324,7 @@ describe('softDeleteMiddleware', () => {
describe('as moderator', () => {
beforeEach(async () => {
- const headers = await login({ email: 'moderator@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await moderator.toJson()
})
it('shows disabled comments', async () => {
@@ -242,7 +333,9 @@ describe('softDeleteMiddleware', () => {
{ content: 'Disabled comment' },
]
const {
- Post: [{ comments }],
+ data: {
+ Post: [{ comments }],
+ },
} = await action()
await expect(comments).toEqual(expect.arrayContaining(expected))
})
@@ -251,58 +344,70 @@ describe('softDeleteMiddleware', () => {
describe('filter (deleted: true)', () => {
beforeEach(() => {
- query = '{ Post(deleted: true) { title } }'
+ graphqlQuery = gql`
+ {
+ Post(deleted: true) {
+ title
+ }
+ }
+ `
})
describe('as user', () => {
beforeEach(async () => {
- const headers = await login({ email: 'user@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
it('throws authorisation error', async () => {
- await expect(action()).rejects.toThrow('Not Authorised!')
+ const { data, errors } = await action()
+ expect(data).toEqual({ Post: null })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('as moderator', () => {
beforeEach(async () => {
- const headers = await login({ email: 'moderator@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await moderator.toJson()
})
- it('shows deleted posts', async () => {
- const expected = { Post: [{ title: 'Deleted post' }] }
- await expect(action()).resolves.toEqual(expected)
+ it('does not show deleted posts', async () => {
+ const expected = { data: { Post: [{ title: 'UNAVAILABLE' }] } }
+ await expect(action()).resolves.toMatchObject(expected)
})
})
})
describe('filter (disabled: true)', () => {
beforeEach(() => {
- query = '{ Post(disabled: true) { title } }'
+ graphqlQuery = gql`
+ {
+ Post(disabled: true) {
+ title
+ }
+ }
+ `
})
describe('as user', () => {
beforeEach(async () => {
- const headers = await login({ email: 'user@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await user.toJson()
})
it('throws authorisation error', async () => {
- await expect(action()).rejects.toThrow('Not Authorised!')
+ const { data, errors } = await action()
+ expect(data).toEqual({ Post: null })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('as moderator', () => {
beforeEach(async () => {
- const headers = await login({ email: 'moderator@example.org', password: '1234' })
- client = new GraphQLClient(host, { headers })
+ authenticatedUser = await moderator.toJson()
})
it('shows disabled posts', async () => {
- const expected = { Post: [{ title: 'Disabled post' }] }
- await expect(action()).resolves.toEqual(expected)
+ const expected = { data: { Post: [{ title: 'Disabled post' }] } }
+ await expect(action()).resolves.toMatchObject(expected)
})
})
})
diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js
index 134c85c0c..0ecb6c115 100644
--- a/backend/src/middleware/validation/validationMiddleware.js
+++ b/backend/src/middleware/validation/validationMiddleware.js
@@ -52,29 +52,9 @@ const validatePost = async (resolve, root, args, context, info) => {
}
const validateUpdatePost = async (resolve, root, args, context, info) => {
- const { id, categoryIds } = args
- const session = context.driver.session()
- const categoryQueryRes = await session.run(
- `
- MATCH (post:Post {id: $id})-[:CATEGORIZED]->(category:Category)
- RETURN category`,
- { id },
- )
- session.close()
- const [category] = categoryQueryRes.records.map(record => {
- return record.get('category')
- })
-
- if (category) {
- if (categoryIds && categoryIds.length > 3) {
- throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
- }
- } else {
- if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
- throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
- }
- }
- return resolve(root, args, context, info)
+ const { categoryIds } = args
+ if (typeof categoryIds === 'undefined') return resolve(root, args, context, info)
+ return validatePost(resolve, root, args, context, info)
}
export default {
diff --git a/backend/src/models/Comment.js b/backend/src/models/Comment.js
new file mode 100644
index 000000000..c89103e5d
--- /dev/null
+++ b/backend/src/models/Comment.js
@@ -0,0 +1,48 @@
+import uuid from 'uuid/v4'
+
+module.exports = {
+ id: { type: 'string', primary: true, default: uuid },
+ createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
+ updatedAt: {
+ type: 'string',
+ isoDate: true,
+ required: true,
+ default: () => new Date().toISOString(),
+ },
+ content: { type: 'string', disallow: [null], min: 3 },
+ contentExcerpt: { type: 'string', allow: [null] },
+ deleted: { type: 'boolean', default: false },
+ disabled: { type: 'boolean', default: false },
+ post: {
+ type: 'relationship',
+ relationship: 'COMMENTS',
+ target: 'Post',
+ direction: 'out',
+ },
+ author: {
+ type: 'relationship',
+ relationship: 'WROTE',
+ target: 'User',
+ direction: 'in',
+ },
+ disabledBy: {
+ type: 'relationship',
+ relationship: 'DISABLED',
+ target: 'User',
+ direction: 'in',
+ },
+ notified: {
+ type: 'relationship',
+ relationship: 'NOTIFIED',
+ target: 'User',
+ direction: 'out',
+ properties: {
+ read: { type: 'boolean', default: false },
+ reason: {
+ type: 'string',
+ valid: ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'],
+ },
+ createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
+ },
+ },
+}
diff --git a/backend/src/models/Location.js b/backend/src/models/Location.js
new file mode 100644
index 000000000..bd6e0b5d9
--- /dev/null
+++ b/backend/src/models/Location.js
@@ -0,0 +1,21 @@
+module.exports = {
+ id: { type: 'string', primary: true },
+ lat: { type: 'number' },
+ lng: { type: 'number' },
+ type: { type: 'string' },
+ name: { type: 'string' },
+ nameES: { type: 'string' },
+ nameFR: { type: 'string' },
+ nameIT: { type: 'string' },
+ nameEN: { type: 'string' },
+ namePT: { type: 'string' },
+ nameDE: { type: 'string' },
+ nameNL: { type: 'string' },
+ namePL: { type: 'string' },
+ isIn: {
+ type: 'relationship',
+ relationship: 'IS_IN',
+ target: 'Location',
+ direction: 'out',
+ },
+}
diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js
deleted file mode 100644
index b54a99574..000000000
--- a/backend/src/models/Notification.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import uuid from 'uuid/v4'
-
-module.exports = {
- id: {
- type: 'uuid',
- primary: true,
- default: uuid,
- },
- read: {
- type: 'boolean',
- default: false,
- },
- reason: {
- type: 'string',
- valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'],
- invalid: [null],
- default: 'mentioned_in_post',
- },
- createdAt: {
- type: 'string',
- isoDate: true,
- default: () => new Date().toISOString(),
- },
- user: {
- type: 'relationship',
- relationship: 'NOTIFIED',
- target: 'User',
- direction: 'out',
- },
- post: {
- type: 'relationship',
- relationship: 'NOTIFIED',
- target: 'Post',
- direction: 'in',
- },
-}
diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js
index 7dd62287d..5ac8378c2 100644
--- a/backend/src/models/Post.js
+++ b/backend/src/models/Post.js
@@ -23,6 +23,20 @@ module.exports = {
target: 'User',
direction: 'in',
},
+ notified: {
+ type: 'relationship',
+ relationship: 'NOTIFIED',
+ target: 'User',
+ direction: 'out',
+ properties: {
+ read: { type: 'boolean', default: false },
+ reason: {
+ type: 'string',
+ valid: ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'],
+ },
+ createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
+ },
+ },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: {
type: 'string',
diff --git a/backend/src/models/SocialMedia.js b/backend/src/models/SocialMedia.js
index d41391ec1..42d2da30e 100644
--- a/backend/src/models/SocialMedia.js
+++ b/backend/src/models/SocialMedia.js
@@ -8,7 +8,7 @@ module.exports = {
type: 'relationship',
relationship: 'OWNED_BY',
target: 'User',
- direction: 'in',
+ direction: 'out',
eager: true,
cascade: 'detach',
},
diff --git a/backend/src/models/Tag.js b/backend/src/models/Tag.js
new file mode 100644
index 000000000..90b5f8772
--- /dev/null
+++ b/backend/src/models/Tag.js
@@ -0,0 +1,17 @@
+module.exports = {
+ id: { type: 'string', primary: true },
+ deleted: { type: 'boolean', default: false },
+ disabled: { type: 'boolean', default: false },
+ updatedAt: {
+ type: 'string',
+ isoDate: true,
+ required: true,
+ default: () => new Date().toISOString(),
+ },
+ post: {
+ type: 'relationship',
+ relationship: 'TAGGED',
+ target: 'Post',
+ direction: 'in',
+ },
+}
diff --git a/backend/src/models/User.js b/backend/src/models/User.js
index fa578f8ad..28ab46d3c 100644
--- a/backend/src/models/User.js
+++ b/backend/src/models/User.js
@@ -83,4 +83,16 @@ module.exports = {
target: 'Notification',
direction: 'in',
},
+ shouted: {
+ type: 'relationship',
+ relationship: 'SHOUTED',
+ target: 'Post',
+ direction: 'out',
+ },
+ isIn: {
+ type: 'relationship',
+ relationship: 'IS_IN',
+ target: 'Location',
+ direction: 'out',
+ },
}
diff --git a/backend/src/models/index.js b/backend/src/models/index.js
index 295082de4..a7d3c8252 100644
--- a/backend/src/models/index.js
+++ b/backend/src/models/index.js
@@ -7,6 +7,8 @@ export default {
EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
Post: require('./Post.js'),
- Notification: require('./Notification.js'),
+ Comment: require('./Comment.js'),
Category: require('./Category.js'),
+ Tag: require('./Tag.js'),
+ Location: require('./Location.js'),
}
diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js
index b8f120057..e8fa63d97 100644
--- a/backend/src/schema/index.js
+++ b/backend/src/schema/index.js
@@ -20,6 +20,7 @@ export default applyScalars(
'Statistics',
'LoggedInUser',
'SocialMedia',
+ 'NOTIFIED',
],
// add 'User' here as soon as possible
},
@@ -32,6 +33,7 @@ export default applyScalars(
'Statistics',
'LoggedInUser',
'SocialMedia',
+ 'NOTIFIED',
],
// add 'User' here as soon as possible
},
diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js
index 89a2040f4..1f6803e09 100644
--- a/backend/src/schema/resolvers/comments.js
+++ b/backend/src/schema/resolvers/comments.js
@@ -1,4 +1,5 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
+import Resolver from './helpers/Resolver'
export default {
Mutation: {
@@ -46,10 +47,29 @@ export default {
session.close()
return commentReturnedWithAuthor
},
- DeleteComment: async (object, params, context, resolveInfo) => {
- const comment = await neo4jgraphql(object, params, context, resolveInfo, false)
-
+ DeleteComment: async (object, args, context, resolveInfo) => {
+ const session = context.driver.session()
+ const transactionRes = await session.run(
+ `
+ MATCH (comment:Comment {id: $commentId})
+ SET comment.deleted = TRUE
+ SET comment.content = 'UNAVAILABLE'
+ SET comment.contentExcerpt = 'UNAVAILABLE'
+ RETURN comment
+ `,
+ { commentId: args.id },
+ )
+ const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return comment
},
},
+ Comment: {
+ ...Resolver('Comment', {
+ hasOne: {
+ author: '<-[:WROTE]-(related:User)',
+ post: '-[:COMMENTS]->(related:Post)',
+ disabledBy: '<-[:DISABLED]-(related:User)',
+ },
+ }),
+ },
}
diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js
index 0b6d5f727..dcb2d31f8 100644
--- a/backend/src/schema/resolvers/comments.spec.js
+++ b/backend/src/schema/resolvers/comments.spec.js
@@ -1,48 +1,34 @@
-import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories'
-import { host, login, gql } from '../../jest/helpers'
-import { neode } from '../../bootstrap/neo4j'
+import { gql } from '../../jest/helpers'
+import { createTestClient } from 'apollo-server-testing'
+import createServer from '../../server'
+import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
-let client
-let createCommentVariables
-let createCommentVariablesSansPostId
-let createCommentVariablesWithNonExistentPost
-let userParams
-let headers
+const driver = getDriver()
+const neode = getNeode()
const factory = Factory()
-const instance = neode()
-const categoryIds = ['cat9']
-const createPostMutation = gql`
- mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) {
- CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
- id
- }
- }
-`
-const createCommentMutation = gql`
- mutation($id: ID, $postId: ID!, $content: String!) {
- CreateComment(id: $id, postId: $postId, content: $content) {
- id
- content
- }
- }
-`
-const createPostVariables = {
- id: 'p1',
- title: 'post to comment on',
- content: 'please comment on me',
- categoryIds,
-}
+let variables
+let mutate
+let authenticatedUser
+let commentAuthor
+
+beforeAll(() => {
+ const { server } = createServer({
+ context: () => {
+ return {
+ driver,
+ user: authenticatedUser,
+ }
+ },
+ })
+ const client = createTestClient(server)
+ mutate = client.mutate
+})
beforeEach(async () => {
- userParams = {
- name: 'TestUser',
- email: 'test@example.org',
- password: '1234',
- }
- await factory.create('User', userParams)
- await instance.create('Category', {
+ variables = {}
+ await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
@@ -53,335 +39,243 @@ afterEach(async () => {
await factory.cleanDatabase()
})
+const createCommentMutation = gql`
+ mutation($id: ID, $postId: ID!, $content: String!) {
+ CreateComment(id: $id, postId: $postId, content: $content) {
+ id
+ content
+ author {
+ name
+ }
+ }
+ }
+`
+const setupPostAndComment = async () => {
+ commentAuthor = await factory.create('User')
+ await factory.create('Post', {
+ id: 'p1',
+ content: 'Post to be commented',
+ categoryIds: ['cat9'],
+ })
+ await factory.create('Comment', {
+ id: 'c456',
+ postId: 'p1',
+ author: commentAuthor,
+ content: 'Comment to be deleted',
+ })
+ variables = {
+ ...variables,
+ id: 'c456',
+ content: 'The comment is updated',
+ }
+}
+
describe('CreateComment', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
- createCommentVariables = {
+ variables = {
+ ...variables,
postId: 'p1',
content: "I'm not authorised to comment",
}
- client = new GraphQLClient(host)
- await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow(
- 'Not Authorised',
- )
+ const { errors } = await mutate({ mutation: createCommentMutation, variables })
+ expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
- headers = await login(userParams)
- client = new GraphQLClient(host, {
- headers,
- })
- createCommentVariables = {
- postId: 'p1',
- content: "I'm authorised to comment",
- }
- await client.request(createPostMutation, createPostVariables)
+ const user = await neode.create('User', { name: 'Author' })
+ authenticatedUser = await user.toJson()
})
- it('creates a comment', async () => {
- const expected = {
- CreateComment: {
+ describe('given a post', () => {
+ beforeEach(async () => {
+ await factory.create('Post', { categoryIds: ['cat9'], id: 'p1' })
+ variables = {
+ ...variables,
+ postId: 'p1',
content: "I'm authorised to comment",
- },
- }
-
- await expect(
- client.request(createCommentMutation, createCommentVariables),
- ).resolves.toMatchObject(expected)
- })
-
- it('assigns the authenticated user as author', async () => {
- await client.request(createCommentMutation, createCommentVariables)
-
- const { User } = await client.request(gql`
- {
- User(name: "TestUser") {
- comments {
- content
- }
- }
}
- `)
+ })
- expect(User).toEqual([
- {
- comments: [
- {
- content: "I'm authorised to comment",
- },
- ],
- },
- ])
- })
+ it('creates a comment', async () => {
+ await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject(
+ {
+ data: { CreateComment: { content: "I'm authorised to comment" } },
+ },
+ )
+ })
- it('throw an error if an empty string is sent from the editor as content', async () => {
- createCommentVariables = {
- postId: 'p1',
- content: '
', - } + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) + }) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) + describe('comment content contains only whitespaces', () => { + beforeEach(() => { + variables = { ...variables, content: '
' } + }) - it('throws an error if postId is sent as an empty string', async () => { - createCommentVariables = { - postId: 'p1', - content: '', - } + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) + }) - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) + describe('invalid post id', () => { + beforeEach(() => { + variables = { ...variables, postId: 'does-not-exist' } + }) - it('throws an error if content is sent as an string of empty characters', async () => { - createCommentVariables = { - postId: 'p1', - content: ' ', - } - - await expect(client.request(createCommentMutation, createCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) - }) - - it('throws an error if postId is sent as an empty string', async () => { - createCommentVariablesSansPostId = { - postId: '', - content: 'this comment should not be created', - } - - await expect( - client.request(createCommentMutation, createCommentVariablesSansPostId), - ).rejects.toThrow('Comment cannot be created without a post!') - }) - - it('throws an error if postId is sent as an string of empty characters', async () => { - createCommentVariablesSansPostId = { - postId: ' ', - content: 'this comment should not be created', - } - - await expect( - client.request(createCommentMutation, createCommentVariablesSansPostId), - ).rejects.toThrow('Comment cannot be created without a post!') - }) - - it('throws an error if the post does not exist in the database', async () => { - createCommentVariablesWithNonExistentPost = { - postId: 'p2', - content: "comment should not be created cause the post doesn't exist", - } - - await expect( - client.request(createCommentMutation, createCommentVariablesWithNonExistentPost), - ).rejects.toThrow('Comment cannot be created without a post!') + it('throw UserInput error', async () => { + const { data, errors } = await mutate({ mutation: createCommentMutation, variables }) + expect(data).toEqual({ CreateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Comment cannot be created without a post!') + }) + }) }) }) }) -describe('ManageComments', () => { - let authorParams - beforeEach(async () => { - authorParams = { - email: 'author@example.org', - password: '1234', - } - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { - id: 'p1', - content: 'Post to be commented', - categoryIds, - }) - await asAuthor.create('Comment', { - id: 'c456', - postId: 'p1', - content: 'Comment to be deleted', - }) - }) - - describe('UpdateComment', () => { - const updateCommentMutation = gql` - mutation($content: String!, $id: ID!) { - UpdateComment(content: $content, id: $id) { - id - content - } +describe('UpdateComment', () => { + const updateCommentMutation = gql` + mutation($content: String!, $id: ID!) { + UpdateComment(content: $content, id: $id) { + id + content } - ` - - let updateCommentVariables = { - id: 'c456', - content: 'The comment is updated', } + ` + + describe('given a post and a comment', () => { + beforeEach(setupPostAndComment) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + const randomGuy = await factory.create('User') + authenticatedUser = await randomGuy.toJson() }) it('throws authorization error', async () => { - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = await commentAuthor.toJson() }) it('updates the comment', async () => { const expected = { - UpdateComment: { - id: 'c456', - content: 'The comment is updated', - }, + data: { UpdateComment: { id: 'c456', content: 'The comment is updated' } }, } - await expect( - client.request(updateCommentMutation, updateCommentVariables), - ).resolves.toEqual(expected) - }) - - it('throw an error if an empty string is sent from the editor as content', async () => { - updateCommentVariables = { - id: 'c456', - content: '', - } - - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', + await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject( + expected, ) }) - it('throws an error if a comment sent from the editor does not contain a single letter character', async () => { - updateCommentVariables = { - id: 'c456', - content: '
', - } + describe('if `content` empty', () => { + beforeEach(() => { + variables = { ...variables, content: '
' } + }) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Comment must be at least 1 character long!', - ) + it('throws InputError', async () => { + const { errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Comment must be at least 1 character long!') + }) }) - it('throws an error if commentId is sent as an empty string', async () => { - updateCommentVariables = { - id: '', - content: '
Hello
', - } + describe('if comment does not exist for given id', () => { + beforeEach(() => { + variables = { ...variables, id: 'does-not-exist' } + }) - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised!', - ) - }) - - it('throws an error if the comment does not exist in the database', async () => { - updateCommentVariables = { - id: 'c1000', - content: 'Hello
', - } - - await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow( - 'Not Authorised!', - ) + it('returns null', async () => { + const { data, errors } = await mutate({ mutation: updateCommentMutation, variables }) + expect(data).toMatchObject({ UpdateComment: null }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) }) }) }) +}) - describe('DeleteComment', () => { - const deleteCommentMutation = gql` - mutation($id: ID!) { - DeleteComment(id: $id) { - id - } +describe('DeleteComment', () => { + const deleteCommentMutation = gql` + mutation($id: ID!) { + DeleteComment(id: $id) { + id + content + contentExcerpt + deleted } - ` - - const deleteCommentVariables = { - id: 'c456', } + ` + + describe('given a post and a comment', () => { + beforeEach(setupPostAndComment) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const result = await mutate({ mutation: deleteCommentMutation, variables }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) + const randomGuy = await factory.create('User') + authenticatedUser = await randomGuy.toJson() }) it('throws authorization error', async () => { - await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: deleteCommentMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { - headers, - }) + authenticatedUser = await commentAuthor.toJson() }) - it('deletes the comment', async () => { + it('marks the comment as deleted and blacks out content', async () => { + const { data } = await mutate({ mutation: deleteCommentMutation, variables }) const expected = { DeleteComment: { id: 'c456', + deleted: true, + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', }, } - await expect( - client.request(deleteCommentMutation, deleteCommentVariables), - ).resolves.toEqual(expected) + expect(data).toMatchObject(expected) }) }) }) diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index fd41205a3..9a6f77513 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -61,7 +61,6 @@ export default function Resolver(type, options = {}) { const id = parent[idAttribute] const statement = ` MATCH(u:${type} {${idAttribute}: {id}})${connection} - WHERE NOT related.deleted = true AND NOT related.disabled = true RETURN COUNT(DISTINCT(related)) as count ` const result = await instance.cypher(statement, { id }) diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index ddc1985cf..0219df02c 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -1,14 +1,80 @@ -import { neo4jgraphql } from 'neo4j-graphql-js' +const resourceTypes = ['Post', 'Comment'] + +const transformReturnType = record => { + return { + ...record.get('notification').properties, + from: { + __typename: record.get('resource').labels.find(l => resourceTypes.includes(l)), + ...record.get('resource').properties, + }, + to: { + ...record.get('user').properties, + }, + } +} export default { Query: { - Notification: (object, params, context, resolveInfo) => { - return neo4jgraphql(object, params, context, resolveInfo, false) + notifications: async (parent, args, context, resolveInfo) => { + const { user: currentUser } = context + const session = context.driver.session() + let notifications + let whereClause + let orderByClause + switch (args.read) { + case true: + whereClause = 'WHERE notification.read = TRUE' + break + case false: + whereClause = 'WHERE notification.read = FALSE' + break + default: + whereClause = '' + } + switch (args.orderBy) { + case 'createdAt_asc': + orderByClause = 'ORDER BY notification.createdAt ASC' + break + case 'createdAt_desc': + orderByClause = 'ORDER BY notification.createdAt DESC' + break + default: + orderByClause = '' + } + + try { + const cypher = ` + MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) + ${whereClause} + RETURN resource, notification, user + ${orderByClause} + ` + const result = await session.run(cypher, { id: currentUser.id }) + notifications = await result.records.map(transformReturnType) + } finally { + session.close() + } + return notifications }, }, Mutation: { - UpdateNotification: (object, params, context, resolveInfo) => { - return neo4jgraphql(object, params, context, resolveInfo, false) + markAsRead: async (parent, args, context, resolveInfo) => { + const { user: currentUser } = context + const session = context.driver.session() + let notification + try { + const cypher = ` + MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) + SET notification.read = TRUE + RETURN resource, notification, user + ` + const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id }) + const notifications = await result.records.map(transformReturnType) + notification = notifications[0] + } finally { + session.close() + } + return notification }, }, } diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 3ca7727e4..83d308428 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -1,395 +1,363 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { getDriver } from '../../bootstrap/neo4j' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../.././server' -let client const factory = Factory() -const instance = neode() -const userParams = { - id: 'you', - email: 'test@example.org', - password: '1234', -} -const categoryIds = ['cat9'] +const driver = getDriver() +let authenticatedUser +let user +let author +let variables +let query +let mutate + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) beforeEach(async () => { - await factory.create('User', userParams) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) + authenticatedUser = null + variables = { orderBy: 'createdAt_asc' } }) afterEach(async () => { await factory.cleanDatabase() }) -describe('Notification', () => { - const notificationQuery = gql` - query { - Notification { - id - } - } - ` - - describe('unauthenticated', () => { - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(notificationQuery)).rejects.toThrow('Not Authorised') - }) +describe('given some notifications', () => { + beforeEach(async () => { + const categoryIds = ['cat1'] + author = await factory.create('User', { id: 'author' }) + user = await factory.create('User', { id: 'you' }) + const [neighbor] = await Promise.all([ + factory.create('User', { id: 'neighbor' }), + factory.create('Category', { id: 'cat1' }), + ]) + const [post1, post2, post3] = await Promise.all([ + factory.create('Post', { author, id: 'p1', categoryIds, content: 'Not for you' }), + factory.create('Post', { + author, + id: 'p2', + categoryIds, + content: 'Already seen post mention', + }), + factory.create('Post', { + author, + id: 'p3', + categoryIds, + content: 'You have been mentioned in a post', + }), + ]) + const [comment1, comment2, comment3] = await Promise.all([ + factory.create('Comment', { + author, + postId: 'p3', + id: 'c1', + content: 'You have seen this comment mentioning already', + }), + factory.create('Comment', { + author, + postId: 'p3', + id: 'c2', + content: 'You have been mentioned in a comment', + }), + factory.create('Comment', { + author, + postId: 'p3', + id: 'c3', + content: 'Somebody else was mentioned in a comment', + }), + ]) + await Promise.all([ + post1.relateTo(neighbor, 'notified', { + createdAt: '2019-08-29T17:33:48.651Z', + read: false, + reason: 'mentioned_in_post', + }), + post2.relateTo(user, 'notified', { + createdAt: '2019-08-30T17:33:48.651Z', + read: true, + reason: 'mentioned_in_post', + }), + post3.relateTo(user, 'notified', { + createdAt: '2019-08-31T17:33:48.651Z', + read: false, + reason: 'mentioned_in_post', + }), + comment1.relateTo(user, 'notified', { + createdAt: '2019-08-30T15:33:48.651Z', + read: true, + reason: 'mentioned_in_comment', + }), + comment2.relateTo(user, 'notified', { + createdAt: '2019-08-30T19:33:48.651Z', + read: false, + reason: 'mentioned_in_comment', + }), + comment3.relateTo(neighbor, 'notified', { + createdAt: '2019-09-01T17:33:48.651Z', + read: false, + reason: 'mentioned_in_comment', + }), + ]) }) -}) -describe('currentUser notifications', () => { - const variables = {} - - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, - }) - }) - - describe('given some notifications', () => { - beforeEach(async () => { - const neighborParams = { - email: 'neighbor@example.org', - password: '1234', - id: 'neighbor', + describe('notifications', () => { + const notificationQuery = gql` + query($read: Boolean, $orderBy: NotificationOrdering) { + notifications(read: $read, orderBy: $orderBy) { + from { + __typename + ... on Post { + content + } + ... on Comment { + content + } + } + read + createdAt } - await Promise.all([ - factory.create('User', neighborParams), - factory.create('Notification', { - id: 'post-mention-not-for-you', - reason: 'mentioned_in_post', - }), - factory.create('Notification', { - id: 'post-mention-already-seen', - read: true, - reason: 'mentioned_in_post', - }), - factory.create('Notification', { - id: 'post-mention-unseen', - reason: 'mentioned_in_post', - }), - factory.create('Notification', { - id: 'comment-mention-not-for-you', - reason: 'mentioned_in_comment', - }), - factory.create('Notification', { - id: 'comment-mention-already-seen', - read: true, - reason: 'mentioned_in_comment', - }), - factory.create('Notification', { - id: 'comment-mention-unseen', - reason: 'mentioned_in_comment', - }), - ]) - await factory.authenticateAs(neighborParams) - await factory.create('Post', { id: 'p1', categoryIds }) - await Promise.all([ - factory.relate('Notification', 'User', { - from: 'post-mention-not-for-you', - to: 'neighbor', - }), - factory.relate('Notification', 'Post', { - from: 'p1', - to: 'post-mention-not-for-you', - }), - factory.relate('Notification', 'User', { - from: 'post-mention-unseen', - to: 'you', - }), - factory.relate('Notification', 'Post', { - from: 'p1', - to: 'post-mention-unseen', - }), - factory.relate('Notification', 'User', { - from: 'post-mention-already-seen', - to: 'you', - }), - factory.relate('Notification', 'Post', { - from: 'p1', - to: 'post-mention-already-seen', - }), - ]) - // Comment and its notifications - await Promise.all([ - factory.create('Comment', { - id: 'c1', - postId: 'p1', - }), - ]) - await Promise.all([ - factory.relate('Notification', 'User', { - from: 'comment-mention-not-for-you', - to: 'neighbor', - }), - factory.relate('Notification', 'Comment', { - from: 'c1', - to: 'comment-mention-not-for-you', - }), - factory.relate('Notification', 'User', { - from: 'comment-mention-unseen', - to: 'you', - }), - factory.relate('Notification', 'Comment', { - from: 'c1', - to: 'comment-mention-unseen', - }), - factory.relate('Notification', 'User', { - from: 'comment-mention-already-seen', - to: 'you', - }), - factory.relate('Notification', 'Comment', { - from: 'c1', - to: 'comment-mention-already-seen', - }), - ]) - }) - - describe('filter for read: false', () => { - const queryCurrentUserNotificationsFilterRead = gql` - query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - id - post { - id - } - comment { - id - } - } - } - } - ` - const variables = { read: false } - it('returns only unread notifications of current user', async () => { - const expected = { - currentUser: { - notifications: expect.arrayContaining([ - { - id: 'post-mention-unseen', - post: { - id: 'p1', - }, - comment: null, - }, - { - id: 'comment-mention-unseen', - post: null, - comment: { - id: 'c1', - }, - }, - ]), - }, - } - await expect( - client.request(queryCurrentUserNotificationsFilterRead, variables), - ).resolves.toEqual(expected) - }) - }) - - describe('no filters', () => { - const queryCurrentUserNotifications = gql` - query { - currentUser { - notifications(orderBy: createdAt_desc) { - id - post { - id - } - comment { - id - } - } - } - } - ` - it('returns all notifications of current user', async () => { - const expected = { - currentUser: { - notifications: expect.arrayContaining([ - { - id: 'post-mention-unseen', - post: { - id: 'p1', - }, - comment: null, - }, - { - id: 'post-mention-already-seen', - post: { - id: 'p1', - }, - comment: null, - }, - { - id: 'comment-mention-unseen', - comment: { - id: 'c1', - }, - post: null, - }, - { - id: 'comment-mention-already-seen', - comment: { - id: 'c1', - }, - post: null, - }, - ]), - }, - } - await expect(client.request(queryCurrentUserNotifications, variables)).resolves.toEqual( - expected, - ) - }) - }) - }) - }) -}) - -describe('UpdateNotification', () => { - const mutationUpdateNotification = gql` - mutation($id: ID!, $read: Boolean) { - UpdateNotification(id: $id, read: $read) { - id - read } - } - ` - const variablesPostUpdateNotification = { - id: 'post-mention-to-be-updated', - read: true, - } - const variablesCommentUpdateNotification = { - id: 'comment-mention-to-be-updated', - read: true, - } - - describe('given some notifications', () => { - let headers - - beforeEach(async () => { - const mentionedParams = { - id: 'mentioned-1', - email: 'mentioned@example.org', - password: '1234', - slug: 'mentioned', - } - await Promise.all([ - factory.create('User', mentionedParams), - factory.create('Notification', { - id: 'post-mention-to-be-updated', - reason: 'mentioned_in_post', - }), - factory.create('Notification', { - id: 'comment-mention-to-be-updated', - reason: 'mentioned_in_comment', - }), - ]) - await factory.authenticateAs(userParams) - await factory.create('Post', { id: 'p1', categoryIds }) - await Promise.all([ - factory.relate('Notification', 'User', { - from: 'post-mention-to-be-updated', - to: 'mentioned-1', - }), - factory.relate('Notification', 'Post', { - from: 'p1', - to: 'post-mention-to-be-updated', - }), - ]) - // Comment and its notifications - await Promise.all([ - factory.create('Comment', { - id: 'c1', - postId: 'p1', - }), - ]) - await Promise.all([ - factory.relate('Notification', 'User', { - from: 'comment-mention-to-be-updated', - to: 'mentioned-1', - }), - factory.relate('Notification', 'Comment', { - from: 'p1', - to: 'comment-mention-to-be-updated', - }), - ]) - }) - + ` describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect( - client.request(mutationUpdateNotification, variablesPostUpdateNotification), - ).rejects.toThrow('Not Authorised') + const { errors } = await query({ query: notificationQuery }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { beforeEach(async () => { - headers = await login({ - email: 'test@example.org', - password: '1234', - }) - client = new GraphQLClient(host, { - headers, + authenticatedUser = await user.toJson() + }) + + describe('no filters', () => { + it('returns all notifications of current user', async () => { + const expected = { + data: { + notifications: [ + { + from: { + __typename: 'Comment', + content: 'You have seen this comment mentioning already', + }, + 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( + expected, + ) }) }) + describe('filter for read: false', () => { + it('returns only unread notifications of current user', async () => { + const expected = expect.objectContaining({ + data: { + notifications: [ + { + 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: { ...variables, read: false } }), + ).resolves.toEqual(expected) + }) + + describe('if a resource gets deleted', () => { + const deletePostAction = async () => { + authenticatedUser = await author.toJson() + const deletePostMutation = gql` + mutation($id: ID!) { + DeletePost(id: $id) { + id + deleted + } + } + ` + await expect( + mutate({ mutation: deletePostMutation, variables: { id: 'p3' } }), + ).resolves.toMatchObject({ data: { DeletePost: { id: 'p3', deleted: true } } }) + authenticatedUser = await user.toJson() + } + + it('reduces notifications list', async () => { + await expect( + query({ query: notificationQuery, variables: { ...variables, read: false } }), + ).resolves.toMatchObject({ + data: { notifications: [expect.any(Object), expect.any(Object)] }, + }) + await deletePostAction() + await expect( + query({ query: notificationQuery, variables: { ...variables, read: false } }), + ).resolves.toMatchObject({ data: { notifications: [] } }) + }) + }) + }) + }) + }) + + describe('markAsRead', () => { + const markAsReadMutation = gql` + mutation($id: ID!) { + markAsRead(id: $id) { + from { + __typename + ... on Post { + content + } + ... on Comment { + content + } + } + read + createdAt + } + } + ` + describe('unauthenticated', () => { it('throws authorization error', async () => { - await expect( - client.request(mutationUpdateNotification, variablesPostUpdateNotification), - ).rejects.toThrow('Not Authorised') + const result = await mutate({ + mutation: markAsReadMutation, + variables: { ...variables, id: 'p1' }, + }) + expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() }) - describe('and owner', () => { + describe('not being notified at all', () => { beforeEach(async () => { - headers = await login({ - email: 'mentioned@example.org', - password: '1234', + variables = { + ...variables, + id: 'p1', + } + }) + + it('returns null', async () => { + const response = await mutate({ mutation: markAsReadMutation, variables }) + expect(response.data.markAsRead).toEqual(null) + expect(response.errors).toBeUndefined() + }) + }) + + describe('being notified', () => { + describe('on a post', () => { + beforeEach(async () => { + variables = { + ...variables, + id: 'p3', + } }) - client = new GraphQLClient(host, { - headers, + + it('updates `read` attribute and returns NOTIFIED relationship', async () => { + const { data } = await mutate({ mutation: markAsReadMutation, variables }) + expect(data).toEqual({ + markAsRead: { + from: { + __typename: 'Post', + content: 'You have been mentioned in a post', + }, + read: true, + createdAt: '2019-08-31T17:33:48.651Z', + }, + }) + }) + + describe('but notification was already marked as read', () => { + beforeEach(async () => { + variables = { + ...variables, + id: 'p2', + } + }) + it('returns null', async () => { + const response = await mutate({ mutation: markAsReadMutation, variables }) + expect(response.data.markAsRead).toEqual(null) + expect(response.errors).toBeUndefined() + }) }) }) - it('updates post notification', async () => { - const expected = { - UpdateNotification: { - id: 'post-mention-to-be-updated', - read: true, - }, - } - await expect( - client.request(mutationUpdateNotification, variablesPostUpdateNotification), - ).resolves.toEqual(expected) - }) + describe('on a comment', () => { + beforeEach(async () => { + variables = { + ...variables, + id: 'c2', + } + }) - it('updates comment notification', async () => { - const expected = { - UpdateNotification: { - id: 'comment-mention-to-be-updated', - read: true, - }, - } - await expect( - client.request(mutationUpdateNotification, variablesCommentUpdateNotification), - ).resolves.toEqual(expected) + it('updates `read` attribute and returns NOTIFIED relationship', async () => { + const { data } = await mutate({ mutation: markAsReadMutation, variables }) + expect(data).toEqual({ + markAsRead: { + from: { + __typename: 'Comment', + content: 'You have been mentioned in a comment', + }, + read: true, + createdAt: '2019-08-30T19:33:48.651Z', + }, + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 46d7c414f..f1dad0f70 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -3,6 +3,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import fileUpload from './fileUpload' import { getBlockedUsers, getBlockedByUsers } from './users.js' import { mergeWith, isArray } from 'lodash' +import Resolver from './helpers/Resolver' const filterForBlockedUsers = async (params, context) => { if (!context.user) return params @@ -11,6 +12,8 @@ const filterForBlockedUsers = async (params, context) => { getBlockedByUsers(context), ]) const badIds = [...blockedByUsers.map(b => b.id), ...blockedUsers.map(b => b.id)] + if (!badIds.length) return params + params.filter = mergeWith( params.filter, { @@ -70,13 +73,42 @@ export default { }, }, Mutation: { + CreatePost: async (object, params, context, resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) + params.id = params.id || uuid() + + const createPostCypher = `CREATE (post:Post {params}) + WITH post + MATCH (author:User {id: $userId}) + MERGE (post)<-[:WROTE]-(author) + WITH post + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + RETURN post` + + const createPostVariables = { userId: context.user.id, categoryIds, params } + + const session = context.driver.session() + const transactionRes = await session.run(createPostCypher, createPostVariables) + + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + + session.close() + + return post.properties + }, UpdatePost: async (object, params, context, resolveInfo) => { const { categoryIds } = params delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) const session = context.driver.session() - let updatePostCypher = `MATCH (post:Post {id: $params.id}) + let updatePostCypher = `MATCH (post:Post {id: $params.id}) SET post = $params ` @@ -109,34 +141,25 @@ export default { return post.properties }, - CreatePost: async (object, params, context, resolveInfo) => { - const { categoryIds } = params - delete params.categoryIds - params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - params.id = params.id || uuid() - - const createPostCypher = `CREATE (post:Post {params}) - WITH post - MATCH (author:User {id: $userId}) - MERGE (post)<-[:WROTE]-(author) - WITH post - UNWIND $categoryIds AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (post)-[:CATEGORIZED]->(category) - RETURN post` - - const createPostVariables = { userId: context.user.id, categoryIds, params } - + DeletePost: async (object, args, context, resolveInfo) => { const session = context.driver.session() - const transactionRes = await session.run(createPostCypher, createPostVariables) - - const [post] = transactionRes.records.map(record => { - return record.get('post') - }) - - session.close() - - return post.properties + // we cannot set slug to 'UNAVAILABE' because of unique constraints + const transactionRes = await session.run( + ` + MATCH (post:Post {id: $postId}) + OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment) + SET post.deleted = TRUE + SET post.content = 'UNAVAILABLE' + SET post.contentExcerpt = 'UNAVAILABLE' + SET post.title = 'UNAVAILABLE' + SET comment.deleted = TRUE + REMOVE post.image + RETURN post + `, + { postId: args.id }, + ) + const [post] = transactionRes.records.map(record => record.get('post').properties) + return post }, AddPostEmotions: async (object, params, context, resolveInfo) => { const session = context.driver.session() @@ -179,4 +202,50 @@ export default { return emoted }, }, + Post: { + ...Resolver('Post', { + undefinedToNull: ['activityId', 'objectId', 'image', 'language'], + hasMany: { + tags: '-[:TAGGED]->(related:Tag)', + categories: '-[:CATEGORIZED]->(related:Category)', + comments: '<-[:COMMENTS]-(related:Comment)', + shoutedBy: '<-[:SHOUTED]-(related:User)', + emotions: '<-[related:EMOTED]', + }, + hasOne: { + author: '<-[:WROTE]-(related:User)', + disabledBy: '<-[:DISABLED]-(related:User)', + }, + count: { + commentsCount: + '<-[:COMMENTS]-(related:Comment) WHERE NOT related.deleted = true AND NOT related.disabled = true', + shoutedCount: + '<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true', + emotionsCount: '<-[related:EMOTED]-(:User)', + }, + boolean: { + shoutedByCurrentUser: + 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', + }, + }), + relatedContributions: async (parent, params, context, resolveInfo) => { + if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions + const { id } = parent + const statement = ` + MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post) + WHERE NOT post.deleted AND NOT post.disabled + RETURN DISTINCT post + LIMIT 10 + ` + let relatedContributions + const session = context.driver.session() + try { + const result = await session.run(statement, { id }) + relatedContributions = result.records.map(r => r.get('post').properties) + } finally { + session.close() + } + return relatedContributions + }, + }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 62507af0e..7c5e88d69 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -1,384 +1,406 @@ -import { GraphQLClient } from 'graphql-request' import { createTestClient } from 'apollo-server-testing' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode, getDriver } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' import createServer from '../../server' const driver = getDriver() const factory = Factory() -const instance = neode() +const neode = getNeode() -let client -let userParams -let authorParams +let query +let mutate +let authenticatedUser +let user -const postId = 'p3589' -const postTitle = 'I am a title' -const postContent = 'Some content' -const oldTitle = 'Old title' -const oldContent = 'Old content' -const newTitle = 'New title' -const newContent = 'New content' -const postSaveError = 'You cannot save a post without at least one category or more than three' const categoryIds = ['cat9', 'cat4', 'cat15'] -let createPostVariables - -const postQueryWithCategories = gql` - query($id: ID) { - Post(id: $id) { - categories { - id - } - } - } -` -const postQueryFilteredByCategory = gql` - query Post($filter: _PostFilter) { - Post(filter: $filter) { - title - id - categories { - id - } - } - } -` -const postCategoriesFilterParam = { categories_some: { id_in: categoryIds } } -const postQueryFilteredByCategoryVariables = { - filter: postCategoriesFilterParam, -} +let variables const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { + CreatePost( + id: $id + title: $title + content: $content + language: $language + categoryIds: $categoryIds + ) { id title content slug disabled deleted + language + author { + name + } } } ` + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + beforeEach(async () => { - userParams = { + variables = {} + user = await factory.create('User', { id: 'u198', name: 'TestUser', email: 'test@example.org', password: '1234', - } - authorParams = { - id: 'u25', - email: 'author@example.org', - password: '1234', - } - await factory.create('User', userParams) + }) await Promise.all([ - instance.create('Category', { + neode.create('Category', { id: 'cat9', name: 'Democracy & Politics', icon: 'university', }), - instance.create('Category', { + neode.create('Category', { id: 'cat4', name: 'Environment & Nature', icon: 'tree', }), - instance.create('Category', { + neode.create('Category', { id: 'cat15', name: 'Consumption & Sustainability', icon: 'shopping-cart', }), - instance.create('Category', { + neode.create('Category', { id: 'cat27', name: 'Animal Protection', icon: 'paw', }), ]) - createPostVariables = { - id: postId, - title: postTitle, - content: postContent, - categoryIds, - } + authenticatedUser = null }) afterEach(async () => { await factory.cleanDatabase() }) +describe('Post', () => { + const postQuery = gql` + query Post($filter: _PostFilter) { + Post(filter: $filter) { + id + categories { + id + } + } + } + ` + + describe('can be filtered', () => { + it('by categories', async () => { + await Promise.all([ + factory.create('Post', { id: 'p31', categoryIds: ['cat4'] }), + factory.create('Post', { id: 'p32', categoryIds: ['cat15'] }), + factory.create('Post', { id: 'p33', categoryIds: ['cat9'] }), + ]) + const expected = { + data: { + Post: [ + { + id: 'p33', + categories: [{ id: 'cat9' }], + }, + ], + }, + } + variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) + }) + }) +}) + describe('CreatePost', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'p3589', + title: 'I am a title', + content: 'Some content', + categoryIds, + } + }) + describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: createPostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('creates a post', async () => { - const expected = { - CreatePost: { - title: postTitle, - content: postContent, - }, - } - await expect(client.request(createPostMutation, createPostVariables)).resolves.toMatchObject( + const expected = { data: { CreatePost: { title: 'I am a title', content: 'Some content' } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( expected, ) }) it('assigns the authenticated user as author', async () => { - await client.request(createPostMutation, createPostVariables) - const { User } = await client.request( - gql` - { - User(name: "TestUser") { - contributions { - title - } - } - } - `, - { headers }, + const expected = { + data: { + CreatePost: { + title: 'I am a title', + author: { + name: 'TestUser', + }, + }, + }, + } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, ) - expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) }) - describe('disabled and deleted', () => { - it('initially false', async () => { - const expected = { CreatePost: { disabled: false, deleted: false } } - await expect( - client.request(createPostMutation, createPostVariables), - ).resolves.toMatchObject(expected) - }) + it('`disabled` and `deleted` default to `false`', async () => { + const expected = { data: { CreatePost: { disabled: false, deleted: false } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) describe('language', () => { + beforeEach(() => { + variables = { ...variables, language: 'es' } + }) + it('allows a user to set the language of the post', async () => { - const createPostWithLanguageMutation = gql` - mutation($title: String!, $content: String!, $language: String, $categoryIds: [ID]) { - CreatePost( - title: $title - content: $content - language: $language - categoryIds: $categoryIds - ) { - language - } - } - ` - const createPostWithLanguageVariables = { - title: postTitle, - content: postContent, - language: 'en', - categoryIds, - } - const expected = { CreatePost: { language: 'en' } } - await expect( - client.request(createPostWithLanguageMutation, createPostWithLanguageVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + const expected = { data: { CreatePost: { language: 'es' } } } + await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( + expected, + ) }) }) describe('categories', () => { - it('throws an error if categoryIds is not an array', async () => { - createPostVariables.categoryIds = null - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) + describe('null', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: null } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) - it('requires at least one category for successful creation', async () => { - createPostVariables.categoryIds = [] - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) + describe('empty', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: [] } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) - it('allows a maximum of three category for successful update', async () => { - createPostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4'] - await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( - postSaveError, - ) - }) - - it('allows a user to filter for posts by category', async () => { - await client.request(createPostMutation, createPostVariables) - const categoryIdsArray = [{ id: 'cat4' }, { id: 'cat15' }, { id: 'cat9' }] - const expected = { - Post: [ - { - title: postTitle, - id: postId, - categories: expect.arrayContaining(categoryIdsArray), - }, - ], - } - await expect( - client.request(postQueryFilteredByCategory, postQueryFilteredByCategoryVariables), - ).resolves.toEqual(expected) + describe('more than 3 items', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] } + }) + it('throws UserInputError', async () => { + const { + errors: [error], + } = await mutate({ mutation: createPostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) }) }) }) describe('UpdatePost', () => { - let updatePostVariables + let author const updatePostMutation = gql` mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id content + categories { + id + } } } ` beforeEach(async () => { - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { - id: postId, - title: oldTitle, - content: oldContent, + author = await factory.create('User', { slug: 'the-author' }) + await factory.create('Post', { + author, + id: 'p9876', + title: 'Old title', + content: 'Old content', categoryIds, }) - updatePostVariables = { - id: postId, - title: newTitle, - content: newContent, + variables = { + ...variables, + id: 'p9876', + title: 'New title', + content: 'New content', } }) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updatePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorization error', async () => { - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - 'Not Authorised', - ) + const { errors } = await mutate({ mutation: updatePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { - let headers beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await author.toJson() }) it('updates a post', async () => { - updatePostVariables.categoryIds = ['cat27'] - const expected = { UpdatePost: { id: postId, content: newContent } } - await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + const expected = { data: { UpdatePost: { id: 'p9876', content: 'New content' } } } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( expected, ) }) - describe('categories', () => { - it('allows a user to update other attributes without passing in categoryIds explicitly', async () => { - const expected = { UpdatePost: { id: postId, content: newContent } } - await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + describe('no new category ids provided for update', () => { + it('resolves and keeps current categories', async () => { + const expected = { + data: { + UpdatePost: { + id: 'p9876', + categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), + }, + }, + } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('given category ids', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat27'] } + }) + + it('updates categories of a post', async () => { + const expected = { + data: { + UpdatePost: { + id: 'p9876', + categories: expect.arrayContaining([{ id: 'cat27' }]), + }, + }, + } + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( expected, ) }) - it('allows a user to update the categories of a post', async () => { - updatePostVariables.categoryIds = ['cat27'] - await client.request(updatePostMutation, updatePostVariables) - const expected = [{ id: 'cat27' }] - const postQueryWithCategoriesVariables = { - id: postId, - } - await expect( - client.request(postQueryWithCategories, postQueryWithCategoriesVariables), - ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) - }) + describe('more than 3 categories', () => { + beforeEach(() => { + variables = { ...variables, categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'] } + }) - it('allows a maximum of three category for a successful update', async () => { - updatePostVariables.categoryIds = ['cat9', 'cat27', 'cat15', 'cat4'] - await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( - postSaveError, - ) + it('allows a maximum of three category for a successful update', async () => { + const { + errors: [error], + } = await mutate({ mutation: updatePostMutation, variables }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', + ) + }) }) describe('post created without categories somehow', () => { - let ownerNode, owner, postMutationAction + let owner + beforeEach(async () => { - const postSomehowCreated = await instance.create('Post', { + const postSomehowCreated = await neode.create('Post', { id: 'how-was-this-created', - title: postTitle, - content: postContent, }) - ownerNode = await instance.create('User', { + owner = await neode.create('User', { id: 'author-of-post-without-category', name: 'Hacker', slug: 'hacker', email: 'hacker@example.org', password: '1234', }) - owner = await ownerNode.toJson() - await postSomehowCreated.relateTo(ownerNode, 'author') - postMutationAction = async (user, mutation, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { mutate } = createTestClient(server) - - return mutate({ - mutation, - variables, - }) - } - updatePostVariables.id = 'how-was-this-created' + await postSomehowCreated.relateTo(owner, 'author') + authenticatedUser = await owner.toJson() + variables = { ...variables, id: 'how-was-this-created' } }) it('throws an error if categoryIds is not an array', async () => { - const mustAddCategoryToPost = await postMutationAction( - owner, - updatePostMutation, - updatePostVariables, + const { + errors: [error], + } = await mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + categoryIds: null, + }, + }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', ) - expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) }) it('requires at least one category for successful update', async () => { - updatePostVariables.categoryIds = [] - const mustAddCategoryToPost = await postMutationAction( - owner, - updatePostMutation, - updatePostVariables, + const { + errors: [error], + } = await mutate({ + mutation: updatePostMutation, + variables: { + ...variables, + categoryIds: [], + }, + }) + expect(error).toHaveProperty( + 'message', + 'You cannot save a post without at least one category or more than three', ) - expect(mustAddCategoryToPost.errors[0]).toHaveProperty('message', postSaveError) }) }) }) @@ -386,73 +408,117 @@ describe('UpdatePost', () => { }) describe('DeletePost', () => { - const mutation = gql` + let author + const deletePostMutation = gql` mutation($id: ID!) { DeletePost(id: $id) { id + deleted content + contentExcerpt + image + comments { + deleted + content + contentExcerpt + } } } ` - const variables = { - id: postId, - } - beforeEach(async () => { - const asAuthor = Factory() - await asAuthor.create('User', authorParams) - await asAuthor.authenticateAs(authorParams) - await asAuthor.create('Post', { - id: postId, + author = await factory.create('User') + await factory.create('Post', { + id: 'p4711', + author, + title: 'I will be deleted', content: 'To be deleted', + image: 'path/to/some/image', categoryIds, }) + variables = { ...variables, id: 'p4711' } }) describe('unauthenticated', () => { it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + const { errors } = await mutate({ mutation: deletePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated but not the author', () => { - let headers beforeEach(async () => { - headers = await login(userParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await user.toJson() }) it('throws authorization error', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + const { errors } = await mutate({ mutation: deletePostMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated as author', () => { - let headers beforeEach(async () => { - headers = await login(authorParams) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await author.toJson() }) - it('deletes a post', async () => { - const expected = { DeletePost: { id: postId, content: 'To be deleted' } } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + it('marks the post as deleted and blacks out attributes', async () => { + const expected = { + data: { + DeletePost: { + id: 'p4711', + deleted: true, + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: null, + comments: [], + }, + }, + } + await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + describe('if there are comments on the post', () => { + beforeEach(async () => { + await factory.create('Comment', { + postId: 'p4711', + content: 'to be deleted comment content', + contentExcerpt: 'to be deleted comment content', + }) + }) + + it('marks the comments as deleted', async () => { + const expected = { + data: { + DeletePost: { + id: 'p4711', + deleted: true, + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + image: null, + comments: [ + { + deleted: true, + // Should we black out the comment content in the database, too? + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + }, + ], + }, + }, + } + await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) }) }) }) describe('emotions', () => { - let addPostEmotionsVariables, - someUser, - ownerNode, - owner, - postMutationAction, - user, - postQueryAction, - postToEmote, - postToEmoteNode + let author, postToEmote const PostsEmotionsCountQuery = gql` query($id: ID!) { Post(id: $id) { @@ -472,104 +538,75 @@ describe('emotions', () => { } } ` - const addPostEmotionsMutation = gql` - mutation($to: _PostInput!, $data: _EMOTEDInput!) { - AddPostEmotions(to: $to, data: $data) { - from { - id - } - to { - id - } - emotion - } - } - ` + beforeEach(async () => { - userParams.id = 'u1987' - authorParams.id = 'u257' - createPostVariables.id = 'p1376' - const someUserNode = await instance.create('User', userParams) - someUser = await someUserNode.toJson() - ownerNode = await instance.create('User', authorParams) - owner = await ownerNode.toJson() - postToEmoteNode = await instance.create('Post', createPostVariables) - postToEmote = await postToEmoteNode.toJson() - await postToEmoteNode.relateTo(ownerNode, 'author') + author = await neode.create('User', { id: 'u257' }) + postToEmote = await factory.create('Post', { + author, + id: 'p1376', + categoryIds, + }) - postMutationAction = async (user, mutation, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { mutate } = createTestClient(server) - - return mutate({ - mutation, - variables, - }) - } - postQueryAction = async (postQuery, variables) => { - const { server } = createServer({ - context: () => { - return { - user, - neode: instance, - driver, - } - }, - }) - const { query } = createTestClient(server) - return query({ query: postQuery, variables }) - } - addPostEmotionsVariables = { - to: { id: postToEmote.id }, + variables = { + ...variables, + to: { id: 'p1376' }, data: { emotion: 'happy' }, } }) describe('AddPostEmotions', () => { + const addPostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + AddPostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` let postsEmotionsQueryVariables + beforeEach(async () => { - postsEmotionsQueryVariables = { id: postToEmote.id } + postsEmotionsQueryVariables = { id: 'p1376' } }) describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + it('throws authorization error', async () => { - user = null - const addPostEmotions = await postMutationAction( - user, - addPostEmotionsMutation, - addPostEmotionsVariables, - ) + const addPostEmotions = await mutate({ + mutation: addPostEmotionsMutation, + variables, + }) expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated and not the author', () => { - beforeEach(() => { - user = someUser + beforeEach(async () => { + authenticatedUser = await user.toJson() }) it('adds an emotion to the post', async () => { const expected = { data: { AddPostEmotions: { - from: { id: user.id }, - to: addPostEmotionsVariables.to, + from: { id: 'u198' }, + to: { id: 'p1376' }, emotion: 'happy', }, }, } - await expect( - postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + await expect(mutate({ mutation: addPostEmotionsMutation, variables })).resolves.toEqual( + expect.objectContaining(expected), + ) }) it('limits the addition of the same emotion to 1', async () => { @@ -582,48 +619,53 @@ describe('emotions', () => { ], }, } - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await mutate({ mutation: addPostEmotionsMutation, variables }) + await mutate({ mutation: addPostEmotionsMutation, variables }) await expect( - postQueryAction(PostsEmotionsCountQuery, postsEmotionsQueryVariables), + query({ query: PostsEmotionsCountQuery, variables: postsEmotionsQueryVariables }), ).resolves.toEqual(expect.objectContaining(expected)) }) it('allows a user to add more than one emotion', async () => { - const expectedEmotions = [ - { emotion: 'happy', User: { id: user.id } }, - { emotion: 'surprised', User: { id: user.id } }, - ] - const expectedResponse = { - data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + const expected = { + data: { + Post: [ + { + emotions: expect.arrayContaining([ + { emotion: 'happy', User: { id: 'u198' } }, + { emotion: 'surprised', User: { id: 'u198' } }, + ]), + }, + ], + }, } - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) - addPostEmotionsVariables.data.emotion = 'surprised' - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await mutate({ mutation: addPostEmotionsMutation, variables }) + variables = { ...variables, data: { emotion: 'surprised' } } + await mutate({ mutation: addPostEmotionsMutation, variables }) await expect( - postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), - ).resolves.toEqual(expect.objectContaining(expectedResponse)) + query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }), + ).resolves.toEqual(expect.objectContaining(expected)) }) }) describe('authenticated as author', () => { - beforeEach(() => { - user = owner + beforeEach(async () => { + authenticatedUser = await author.toJson() }) it('adds an emotion to the post', async () => { const expected = { data: { AddPostEmotions: { - from: { id: owner.id }, - to: addPostEmotionsVariables.to, + from: { id: 'u257' }, + to: { id: 'p1376' }, emotion: 'happy', }, }, } - await expect( - postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + await expect(mutate({ mutation: addPostEmotionsMutation, variables })).resolves.toEqual( + expect.objectContaining(expected), + ) }) }) }) @@ -644,37 +686,41 @@ describe('emotions', () => { } ` beforeEach(async () => { - await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) - await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await author.relateTo(postToEmote, 'emoted', { emotion: 'happy' }) + await user.relateTo(postToEmote, 'emoted', { emotion: 'cry' }) - postsEmotionsQueryVariables = { id: postToEmote.id } + postsEmotionsQueryVariables = { id: 'p1376' } removePostEmotionsVariables = { - to: { id: postToEmote.id }, + to: { id: 'p1376' }, data: { emotion: 'cry' }, } }) describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + it('throws authorization error', async () => { - user = null - const removePostEmotions = await postMutationAction( - user, - removePostEmotionsMutation, - removePostEmotionsVariables, - ) + const removePostEmotions = await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') }) }) describe('authenticated', () => { describe('but not the emoter', () => { + beforeEach(async () => { + authenticatedUser = await author.toJson() + }) + it('returns null if the emotion could not be found', async () => { - user = someUser - const removePostEmotions = await postMutationAction( - user, - removePostEmotionsMutation, - removePostEmotionsVariables, - ) + const removePostEmotions = await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) expect(removePostEmotions).toEqual( expect.objectContaining({ data: { RemovePostEmotions: null } }), ) @@ -682,30 +728,39 @@ describe('emotions', () => { }) describe('as the emoter', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + it('removes an emotion from a post', async () => { - user = owner const expected = { data: { RemovePostEmotions: { - to: { id: postToEmote.id }, - from: { id: user.id }, + to: { id: 'p1376' }, + from: { id: 'u198' }, emotion: 'cry', }, }, } await expect( - postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables), + mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }), ).resolves.toEqual(expect.objectContaining(expected)) }) it('removes only the requested emotion, not all emotions', async () => { - const expectedEmotions = [{ emotion: 'happy', User: { id: authorParams.id } }] + const expectedEmotions = [{ emotion: 'happy', User: { id: 'u257' } }] const expectedResponse = { data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, } - await postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables) + await mutate({ + mutation: removePostEmotionsMutation, + variables: removePostEmotionsVariables, + }) await expect( - postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }), ).resolves.toEqual(expect.objectContaining(expectedResponse)) }) }) @@ -728,30 +783,42 @@ describe('emotions', () => { } ` beforeEach(async () => { - await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + await user.relateTo(postToEmote, 'emoted', { emotion: 'cry' }) PostsEmotionsCountByEmotionVariables = { - postId: postToEmote.id, + postId: 'p1376', data: { emotion: 'cry' }, } - PostsEmotionsByCurrentUserVariables = { postId: postToEmote.id } + PostsEmotionsByCurrentUserVariables = { postId: 'p1376' } }) describe('PostsEmotionsCountByEmotion', () => { it("returns a post's emotions count", async () => { const expectedResponse = { data: { PostsEmotionsCountByEmotion: 1 } } await expect( - postQueryAction(PostsEmotionsCountByEmotionQuery, PostsEmotionsCountByEmotionVariables), + query({ + query: PostsEmotionsCountByEmotionQuery, + variables: PostsEmotionsCountByEmotionVariables, + }), ).resolves.toEqual(expect.objectContaining(expectedResponse)) }) }) - describe('PostsEmotionsCountByEmotion', () => { - it("returns a currentUser's emotions on a post", async () => { - const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } - await expect( - postQueryAction(PostsEmotionsByCurrentUserQuery, PostsEmotionsByCurrentUserVariables), - ).resolves.toEqual(expect.objectContaining(expectedResponse)) + describe('PostsEmotionsByCurrentUser', () => { + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it("returns a currentUser's emotions on a post", async () => { + const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } + await expect( + query({ + query: PostsEmotionsByCurrentUserQuery, + variables: PostsEmotionsByCurrentUserVariables, + }), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) }) }) }) diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 7287a79f4..512d8d956 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -12,6 +12,7 @@ describe('report', () => { let returnedObject let variables let createPostVariables + let user const categoryIds = ['cat9'] beforeEach(async () => { @@ -20,10 +21,10 @@ describe('report', () => { id: 'whatever', } headers = {} - await factory.create('User', { - id: 'u1', + user = await factory.create('User', { email: 'test@example.org', password: '1234', + id: 'u1', }) await factory.create('User', { id: 'u2', @@ -127,11 +128,8 @@ describe('report', () => { describe('reported resource is a post', () => { beforeEach(async () => { - await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) await factory.create('Post', { + author: user, id: 'p23', title: 'Matt and Robert having a pair-programming', categoryIds, @@ -182,12 +180,9 @@ describe('report', () => { content: 'please comment on me', categoryIds, } - const asAuthenticatedUser = await factory.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) - await asAuthenticatedUser.create('Post', createPostVariables) - await asAuthenticatedUser.create('Comment', { + await factory.create('Post', { ...createPostVariables, author: user }) + await factory.create('Comment', { + author: user, postId: 'p1', id: 'c34', content: 'Robert getting tired.', diff --git a/backend/src/schema/resolvers/socialMedia.js b/backend/src/schema/resolvers/socialMedia.js index d67a41636..49aa6788d 100644 --- a/backend/src/schema/resolvers/socialMedia.js +++ b/backend/src/schema/resolvers/socialMedia.js @@ -32,7 +32,7 @@ export default { SocialMedia: Resolver('SocialMedia', { idAttribute: 'url', hasOne: { - ownedBy: '<-[:OWNED_BY]-(related:User)', + ownedBy: '-[:OWNED_BY]->(related:User)', }, }), } diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index be790ca3a..a634eaf85 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -24,11 +24,11 @@ export default { // } const session = driver.session() const result = await session.run( - 'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' + - 'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1', - { - userEmail: email, - }, + ` + 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 + `, + { userEmail: email }, ) session.close() const [currentUser] = await result.records.map(record => { diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index ff0c0db4e..4fe21f92a 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -1,50 +1,48 @@ -import { GraphQLClient, request } from 'graphql-request' import jwt from 'jsonwebtoken' import CONFIG from './../../config' import Factory from '../../seed/factories' -import { host, login } from '../../jest/helpers' +import { gql } from '../../jest/helpers' +import { createTestClient } from 'apollo-server-testing' +import createServer, { context } from '../../server' +import encode from '../../jwt/encode' const factory = Factory() - -// here is the decoded JWT token: -// { -// role: 'user', -// locationName: null, -// name: 'Jenny Rostock', -// about: null, -// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', -// id: 'u3', -// email: 'user@example.org', -// slug: 'jenny-rostock', -// iat: 1550846680, -// exp: 1637246680, -// aud: 'http://localhost:3000', -// iss: 'http://localhost:4000', -// sub: 'u3' -// } -const jennyRostocksHeaders = { - authorization: - 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc', -} +let query +let mutate +let variables +let req +let user const disable = async id => { - const moderatorParams = { email: 'moderator@example.org', role: 'moderator', password: '1234' } - const asModerator = Factory() - await asModerator.create('User', moderatorParams) - await asModerator.authenticateAs(moderatorParams) - await asModerator.mutate('mutation($id: ID!) { disable(id: $id) }', { id }) + await factory.create('User', { id: 'u2', role: 'moderator' }) + const moderatorBearerToken = encode({ id: 'u2' }) + req = { headers: { authorization: `Bearer ${moderatorBearerToken}` } } + await mutate({ + mutation: gql` + mutation($id: ID!) { + disable(id: $id) + } + `, + variables: { id }, + }) + req = { headers: {} } } -beforeEach(async () => { - await factory.create('User', { - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', - id: 'acb2d923-f3af-479e-9f00-61b12e864666', - name: 'Matilde Hermiston', - slug: 'matilde-hermiston', - role: 'user', - email: 'test@example.org', - password: '1234', +beforeEach(() => { + user = null + req = { headers: {} } +}) + +beforeAll(() => { + const { server } = createServer({ + context: () => { + // One of the rare occasions where we test + // the actual `context` implementation here + return context({ req }) + }, }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate }) afterEach(async () => { @@ -52,261 +50,269 @@ afterEach(async () => { }) describe('isLoggedIn', () => { - const query = '{ isLoggedIn }' + const isLoggedInQuery = gql` + { + isLoggedIn + } + ` + const respondsWith = async expected => { + await expect(query({ query: isLoggedInQuery })).resolves.toMatchObject(expected) + } + describe('unauthenticated', () => { it('returns false', async () => { - await expect(request(host, query)).resolves.toEqual({ - isLoggedIn: false, - }) + await respondsWith({ data: { isLoggedIn: false } }) }) }) - describe('with malformed JWT Bearer token', () => { - const headers = { authorization: 'blah' } - const client = new GraphQLClient(host, { headers }) - - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, - }) + describe('authenticated', () => { + beforeEach(async () => { + user = await factory.create('User', { id: 'u3' }) + const userBearerToken = encode({ id: 'u3' }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) - }) - describe('with valid JWT Bearer token', () => { - const client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + it('returns true', async () => { + await respondsWith({ data: { isLoggedIn: true } }) + }) - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, + describe('but user is disabled', () => { + beforeEach(async () => { + await disable('u3') + }) + + it('returns false', async () => { + await respondsWith({ data: { isLoggedIn: false } }) }) }) - describe('and a corresponding user in the database', () => { - describe('user is enabled', () => { - it('returns true', async () => { - // see the decoded token above - await factory.create('User', { id: 'u3' }) - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: true, - }) - }) + describe('but user is deleted', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), deleted: true }) }) - describe('user is disabled', () => { - beforeEach(async () => { - await factory.create('User', { id: 'u3' }) - await disable('u3') - }) - - it('returns false', async () => { - await expect(client.request(query)).resolves.toEqual({ - isLoggedIn: false, - }) - }) + it('returns false', async () => { + await respondsWith({ data: { isLoggedIn: false } }) }) }) }) }) describe('currentUser', () => { - const query = `{ - currentUser { - id - slug - name - avatar - email - role + const currentUserQuery = gql` + { + currentUser { + id + slug + name + avatar + email + role + } } - }` + ` + + const respondsWith = async expected => { + await expect(query({ query: currentUserQuery, variables })).resolves.toMatchObject(expected) + } describe('unauthenticated', () => { it('returns null', async () => { - const expected = { currentUser: null } - await expect(request(host, query)).resolves.toEqual(expected) + await respondsWith({ data: { currentUser: null } }) }) }) - describe('with valid JWT Bearer Token', () => { - let client - let headers - - describe('but no corresponding user in the database', () => { - beforeEach(async () => { - client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) - }) - - it('returns null', async () => { - const expected = { currentUser: null } - await expect(client.request(query)).resolves.toEqual(expected) - }) - }) - + describe('authenticated', () => { describe('and corresponding user in the database', () => { beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) + await factory.create('User', { + id: 'u3', + // the `id` is the only thing that has to match the decoded JWT bearer token + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + email: 'test@example.org', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + }) + const userBearerToken = encode({ id: 'u3' }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) it('returns the whole user object', async () => { const expected = { - currentUser: { - avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', - email: 'test@example.org', - id: 'acb2d923-f3af-479e-9f00-61b12e864666', - name: 'Matilde Hermiston', - slug: 'matilde-hermiston', - role: 'user', + data: { + currentUser: { + id: 'u3', + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', + email: 'test@example.org', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + }, }, } - await expect(client.request(query)).resolves.toEqual(expected) + await respondsWith(expected) }) }) }) }) describe('login', () => { - const mutation = params => { - const { email, password } = params - return ` - mutation { - login(email:"${email}", password:"${password}") - }` + const loginMutation = gql` + mutation($email: String!, $password: String!) { + login(email: $email, password: $password) + } + ` + + const respondsWith = async expected => { + await expect(mutate({ mutation: loginMutation, variables })).resolves.toMatchObject(expected) } + beforeEach(async () => { + variables = { email: 'test@example.org', password: '1234' } + user = await factory.create('User', { + ...variables, + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + }) + }) + describe('ask for a `token`', () => { - describe('with valid email/password combination', () => { - it('responds with a JWT token', async () => { - const data = await request( - host, - mutation({ - email: 'test@example.org', - password: '1234', - }), - ) - const token = data.login + describe('with a valid email/password combination', () => { + it('responds with a JWT bearer token', async done => { + const { + data: { login: token }, + } = await mutate({ mutation: loginMutation, variables }) jwt.verify(token, CONFIG.JWT_SECRET, (err, data) => { expect(data.email).toEqual('test@example.org') expect(err).toBeNull() + done() + }) + }) + + describe('but user account is deleted', () => { + beforeEach(async () => { + await user.update({ updatedAt: new Date().toISOString(), deleted: true }) + }) + + it('responds with "Incorrect email address or password."', async () => { + await respondsWith({ + data: null, + errors: [{ message: 'Incorrect email address or password.' }], + }) + }) + }) + + describe('but user account is disabled', () => { + beforeEach(async () => { + await disable('acb2d923-f3af-479e-9f00-61b12e864666') + }) + + it('responds with "Your account has been disabled."', async () => { + await respondsWith({ + data: null, + errors: [{ message: 'Your account has been disabled.' }], + }) }) }) }) - describe('valid email/password but user is disabled', () => { - it('responds with "Your account has been disabled."', async () => { - await disable('acb2d923-f3af-479e-9f00-61b12e864666') - await expect( - request( - host, - mutation({ - email: 'test@example.org', - password: '1234', - }), - ), - ).rejects.toThrow('Your account has been disabled.') - }) - }) - describe('with a valid email but incorrect password', () => { + beforeEach(() => { + variables = { ...variables, email: 'test@example.org', password: 'wrong' } + }) + it('responds with "Incorrect email address or password."', async () => { - await expect( - request( - host, - mutation({ - email: 'test@example.org', - password: 'wrong', - }), - ), - ).rejects.toThrow('Incorrect email address or password.') + await respondsWith({ + errors: [{ message: 'Incorrect email address or password.' }], + }) }) }) describe('with a non-existing email', () => { + beforeEach(() => { + variables = { + ...variables, + email: 'non-existent@example.org', + password: '1234', + } + }) + it('responds with "Incorrect email address or password."', async () => { - await expect( - request( - host, - mutation({ - email: 'non-existent@example.org', - password: 'wrong', - }), - ), - ).rejects.toThrow('Incorrect email address or password.') + await respondsWith({ + errors: [{ message: 'Incorrect email address or password.' }], + }) }) }) }) }) describe('change password', () => { - let headers - let client + const changePasswordMutation = gql` + mutation($oldPassword: String!, $newPassword: String!) { + changePassword(oldPassword: $oldPassword, newPassword: $newPassword) + } + ` - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - }) - - const mutation = params => { - const { oldPassword, newPassword } = params - return ` - mutation { - changePassword(oldPassword:"${oldPassword}", newPassword:"${newPassword}") - }` + const respondsWith = async expected => { + await expect(mutate({ mutation: changePasswordMutation, variables })).resolves.toMatchObject( + expected, + ) } - describe('should be authenticated before changing password', () => { + beforeEach(async () => { + variables = { ...variables, oldPassword: 'what', newPassword: 'ever' } + }) + + describe('unauthenticated', () => { it('throws "Not Authorised!"', async () => { - await expect( - request( - host, - mutation({ - oldPassword: '1234', - newPassword: '1234', - }), - ), - ).rejects.toThrow('Not Authorised!') + await respondsWith({ errors: [{ message: 'Not Authorised!' }] }) }) }) - describe('old and new password should not match', () => { - it('responds with "Old password and new password should be different"', async () => { - await expect( - client.request( - mutation({ - oldPassword: '1234', - newPassword: '1234', - }), - ), - ).rejects.toThrow('Old password and new password should be different') + describe('authenticated', () => { + beforeEach(async () => { + await factory.create('User', { id: 'u3' }) + const userBearerToken = encode({ id: 'u3' }) + req = { headers: { authorization: `Bearer ${userBearerToken}` } } }) - }) + describe('old password === new password', () => { + beforeEach(() => { + variables = { ...variables, oldPassword: '1234', newPassword: '1234' } + }) - describe('incorrect old password', () => { - it('responds with "Old password isn\'t valid"', async () => { - await expect( - client.request( - mutation({ - oldPassword: 'notOldPassword', - newPassword: '12345', - }), - ), - ).rejects.toThrow('Old password is not correct') + it('responds with "Old password and new password should be different"', async () => { + await respondsWith({ + errors: [{ message: 'Old password and new password should be different' }], + }) + }) }) - }) - describe('correct password', () => { - it('changes the password if given correct credentials "', async () => { - const response = await client.request( - mutation({ + describe('incorrect old password', () => { + beforeEach(() => { + variables = { + ...variables, + oldPassword: 'notOldPassword', + newPassword: '12345', + } + }) + + it('responds with "Old password isn\'t valid"', async () => { + await respondsWith({ errors: [{ message: 'Old password is not correct' }] }) + }) + }) + + describe('correct password', () => { + beforeEach(() => { + variables = { + ...variables, oldPassword: '1234', newPassword: '12345', - }), - ) - await expect(response).toEqual( - expect.objectContaining({ - changePassword: expect.any(String), - }), - ) + } + }) + + it('changes the password if given correct credentials "', async () => { + await respondsWith({ data: { changePassword: expect.any(String) } }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 4710942b6..44d4cff50 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -102,23 +102,49 @@ export default { const { resource } = params const session = context.driver.session() - if (resource && resource.length) { - await Promise.all( - resource.map(async node => { - await session.run( - ` + let user + try { + if (resource && resource.length) { + await Promise.all( + resource.map(async node => { + await session.run( + ` MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) SET resource.deleted = true + SET resource.content = 'UNAVAILABLE' + SET resource.contentExcerpt = 'UNAVAILABLE' + SET comment.deleted = true RETURN author`, - { - userId: context.user.id, - }, - ) - }), + { + userId: context.user.id, + }, + ) + }), + ) + } + + // we cannot set slug to 'UNAVAILABE' because of unique constraints + const transactionResult = await session.run( + ` + MATCH (user:User {id: $userId}) + SET user.deleted = true + SET user.name = 'UNAVAILABLE' + SET user.about = 'UNAVAILABLE' + WITH user + OPTIONAL MATCH (user)<-[:BELONGS_TO]-(email:EmailAddress) + DETACH DELETE email + WITH user + OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia) + DETACH DELETE socialMedia + RETURN user`, + { userId: context.user.id }, ) + user = transactionResult.records.map(r => r.get('user').properties)[0] + } finally { session.close() } - return neo4jgraphql(object, params, context, resolveInfo, false) + return user }, }, User: { @@ -147,23 +173,27 @@ export default { 'MATCH (this)<-[:BLOCKED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', }, count: { - contributionsCount: '-[:WROTE]->(related:Post)', + contributionsCount: + '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', friendsCount: '<-[:FRIENDS]->(related:User)', followingCount: '-[:FOLLOWS]->(related:User)', followedByCount: '<-[:FOLLOWS]-(related:User)', - commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)', - shoutedCount: '-[:SHOUTED]->(related:Post)', + commentedCount: + '-[:WROTE]->(c:Comment)-[:COMMENTS]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', + shoutedCount: + '-[:SHOUTED]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', badgesCount: '<-[:REWARDED]-(related:Badge)', }, hasOne: { invitedBy: '<-[:INVITED]-(related:User)', disabledBy: '<-[:DISABLED]-(related:User)', + location: '-[:IS_IN]->(related:Location)', }, hasMany: { followedBy: '<-[:FOLLOWS]-(related:User)', following: '-[:FOLLOWS]->(related:User)', friends: '-[:FRIENDS]-(related:User)', - socialMedia: '-[:OWNED_BY]->(related:SocialMedia', + socialMedia: '<-[:OWNED_BY]-(related:SocialMedia)', contributions: '-[:WROTE]->(related:Post)', comments: '-[:WROTE]->(related:Comment)', shouted: '-[:SHOUTED]->(related:Post)', diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 454b457e6..e8e6205ca 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -1,274 +1,463 @@ -import { GraphQLClient } from 'graphql-request' import Factory from '../../seed/factories' -import { host, login, gql } from '../../jest/helpers' -import { neode } from '../../bootstrap/neo4j' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' -let client const factory = Factory() -const instance = neode() const categoryIds = ['cat9'] +let user + +let query +let mutate +let authenticatedUser +let variables + +const driver = getDriver() +const neode = getNeode() + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) afterEach(async () => { await factory.cleanDatabase() }) -describe('users', () => { - describe('User', () => { - describe('query by email address', () => { - beforeEach(async () => { - await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) - }) - - const query = `query($email: String) { User(email: $email) { name } }` - const variables = { email: 'any-email-address@example.org' } - beforeEach(() => { - client = new GraphQLClient(host) - }) - - it('is forbidden', async () => { - await expect(client.request(query, variables)).rejects.toThrow('Not Authorised') - }) - - describe('as admin', () => { - beforeEach(async () => { - const userParams = { - role: 'admin', - email: 'admin@example.org', - password: '1234', - } - const factory = Factory() - await factory.create('User', userParams) - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) - - it('is permitted', async () => { - await expect(client.request(query, variables)).resolves.toEqual({ - User: [{ name: 'Johnny' }], - }) - }) - }) +describe('User', () => { + describe('query by email address', () => { + beforeEach(async () => { + await factory.create('User', { name: 'Johnny', email: 'any-email-address@example.org' }) }) - }) - describe('UpdateUser', () => { - const userParams = { - email: 'user@example.org', - password: '1234', - id: 'u47', - name: 'John Doe', - } - const variables = { - id: 'u47', - name: 'John Doughnut', - } - - const mutation = ` - mutation($id: ID!, $name: String) { - UpdateUser(id: $id, name: $name) { - id + const userQuery = gql` + query($email: String) { + User(email: $email) { name } } ` + const variables = { email: 'any-email-address@example.org' } - beforeEach(async () => { - await factory.create('User', userParams) + it('is forbidden', async () => { + await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + }) }) - describe('as another user', () => { + describe('as admin', () => { beforeEach(async () => { - const someoneElseParams = { - email: 'someone-else@example.org', - password: '1234', - name: 'James Doe', - } - - await factory.create('User', someoneElseParams) - const headers = await login(someoneElseParams) - client = new GraphQLClient(host, { headers }) - }) - - it('is not allowed to change other user accounts', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') - }) - }) - - describe('as the same user', () => { - beforeEach(async () => { - const headers = await login(userParams) - client = new GraphQLClient(host, { headers }) - }) - - it('name within specifications', async () => { - const expected = { - UpdateUser: { - id: 'u47', - name: 'John Doughnut', - }, - } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) - }) - - it('with `null` as name', async () => { - const variables = { - id: 'u47', - name: null, - } - const expected = '"name" must be a string' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) - - it('with too short name', async () => { - const variables = { - id: 'u47', - name: ' ', - } - const expected = '"name" length must be at least 3 characters long' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) - }) - }) - }) - - describe('DeleteUser', () => { - let deleteUserVariables - let asAuthor - const deleteUserMutation = gql` - mutation($id: ID!, $resource: [Deletable]) { - DeleteUser(id: $id, resource: $resource) { - id - contributions { - id - deleted - } - comments { - id - deleted - } - } - } - ` - beforeEach(async () => { - await factory.create('User', { - email: 'test@example.org', - password: '1234', - id: 'u343', - }) - await factory.create('User', { - email: 'friends-account@example.org', - password: '1234', - id: 'u565', - }) - deleteUserVariables = { id: 'u343', resource: [] } - }) - - describe('unauthenticated', () => { - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) - }) - }) - - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ - email: 'test@example.org', + const admin = await factory.create('User', { + role: 'admin', + email: 'admin@example.org', password: '1234', }) - client = new GraphQLClient(host, { headers }) + authenticatedUser = await admin.toJson() }) - describe("attempting to delete another user's account", () => { - it('throws an authorization error', async () => { - deleteUserVariables = { id: 'u565' } - await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( - 'Not Authorised', - ) - }) - }) - - describe('attempting to delete my own account', () => { - let expectedResponse - beforeEach(async () => { - asAuthor = Factory() - await asAuthor.authenticateAs({ - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) - await asAuthor.create('Post', { - id: 'p139', - content: 'Post by user u343', - categoryIds, - }) - await asAuthor.create('Comment', { - id: 'c155', - postId: 'p139', - content: 'Comment by user u343', - }) - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: false }], - }, - } - }) - it("deletes my account, but doesn't delete posts or comments by default", async () => { - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) - }) - - describe("deletes a user's", () => { - it('posts on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: false }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) - }) - - it('comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: false }], - comments: [{ id: 'c155', deleted: true }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) - }) - - it('posts and comments on request', async () => { - deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] } - expectedResponse = { - DeleteUser: { - id: 'u343', - contributions: [{ id: 'p139', deleted: true }], - comments: [{ id: 'c155', deleted: true }], - }, - } - await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( - expectedResponse, - ) - }) + it('is permitted', async () => { + await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ + data: { User: [{ name: 'Johnny' }] }, + }) + }) + }) + }) +}) + +describe('UpdateUser', () => { + const userParams = { + email: 'user@example.org', + password: '1234', + id: 'u47', + name: 'John Doe', + } + const variables = { + id: 'u47', + name: 'John Doughnut', + } + + const updateUserMutation = gql` + mutation($id: ID!, $name: String) { + UpdateUser(id: $id, name: $name) { + id + name + } + } + ` + + beforeEach(async () => { + user = await factory.create('User', userParams) + }) + + describe('as another user', () => { + beforeEach(async () => { + const someoneElseParams = { + email: 'someone-else@example.org', + password: '1234', + name: 'James Doe', + } + + const someoneElse = await factory.create('User', someoneElseParams) + authenticatedUser = await someoneElse.toJson() + }) + + it('is not allowed to change other user accounts', async () => { + const { errors } = await mutate({ mutation: updateUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('as the same user', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('name within specifications', async () => { + const expected = { + data: { + UpdateUser: { + id: 'u47', + name: 'John Doughnut', + }, + }, + } + await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( + 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('DeleteUser', () => { + const deleteUserMutation = gql` + mutation($id: ID!, $resource: [Deletable]) { + DeleteUser(id: $id, resource: $resource) { + id + name + about + deleted + contributions { + id + content + contentExcerpt + deleted + comments { + id + content + contentExcerpt + deleted + } + } + comments { + id + content + contentExcerpt + deleted + } + } + } + ` + beforeEach(async () => { + variables = { id: ' u343', resource: [] } + + user = await factory.create('User', { + name: 'My name should be deleted', + about: 'along with my about', + id: 'u343', + }) + await factory.create('User', { + email: 'friends-account@example.org', + password: '1234', + id: 'not-my-account', + }) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe("attempting to delete another user's account", () => { + beforeEach(() => { + variables = { ...variables, id: 'not-my-account' } + }) + + it('throws an authorization error', async () => { + const { errors } = await mutate({ mutation: deleteUserMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('attempting to delete my own account', () => { + beforeEach(() => { + variables = { ...variables, id: 'u343' } + }) + + describe('given posts and comments', () => { + beforeEach(async () => { + await factory.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) + await factory.create('Post', { + author: user, + id: 'p139', + content: 'Post by user u343', + categoryIds, + }) + await factory.create('Comment', { + author: user, + id: 'c155', + content: 'Comment by user u343', + }) + await factory.create('Comment', { + postId: 'p139', + id: 'c156', + content: "A comment by someone else on user u343's post", + }) + }) + + it("deletes my account, but doesn't delete posts or comments by default", async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, + }, + } + await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject( + expectedResponse, + ) + }) + + describe('deletion of all post requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post'] } + }) + + describe("marks user's posts as deleted", () => { + it('posts on request', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'Comment by user u343', + contentExcerpt: 'Comment by user u343', + deleted: false, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + }) + + describe('deletion of all comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Comment'] } + }) + + it('marks comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'Post by user u343', + contentExcerpt: 'Post by user u343', + deleted: false, + comments: [ + { + id: 'c156', + content: "A comment by someone else on user u343's post", + contentExcerpt: "A comment by someone else on user u343's post", + deleted: false, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + + describe('deletion of all post and comments requested', () => { + beforeEach(() => { + variables = { ...variables, resource: ['Post', 'Comment'] } + }) + + it('marks posts and comments as deleted', async () => { + const expectedResponse = { + data: { + DeleteUser: { + id: 'u343', + name: 'UNAVAILABLE', + about: 'UNAVAILABLE', + deleted: true, + contributions: [ + { + id: 'p139', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + comments: [ + { + id: 'c156', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + ], + comments: [ + { + id: 'c155', + content: 'UNAVAILABLE', + contentExcerpt: 'UNAVAILABLE', + deleted: true, + }, + ], + }, + }, + } + await expect( + mutate({ mutation: deleteUserMutation, variables }), + ).resolves.toMatchObject(expectedResponse) + }) + }) + }) + + describe('connected `EmailAddress` nodes', () => { + it('will be removed completely', async () => { + await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('EmailAddress')).resolves.toHaveLength(1) + }) + }) + + describe('connected `SocialMedia` nodes', () => { + beforeEach(async () => { + const socialMedia = await factory.create('SocialMedia') + await socialMedia.relateTo(user, 'ownedBy') + }) + + it('will be removed completely', async () => { + await expect(neode.all('SocialMedia')).resolves.toHaveLength(1) + await mutate({ mutation: deleteUserMutation, variables }) + await expect(neode.all('SocialMedia')).resolves.toHaveLength(0) }) }) }) diff --git a/backend/src/schema/types/Location.gql b/backend/src/schema/types/Location.gql new file mode 100644 index 000000000..e7053e345 --- /dev/null +++ b/backend/src/schema/types/Location.gql @@ -0,0 +1,17 @@ +type Location { + id: ID! + name: String! + nameEN: String + nameDE: String + nameFR: String + nameNL: String + nameIT: String + nameES: String + namePT: String + namePL: String + type: String! + lat: Float + lng: Float + parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") +} + diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql index a66c446be..e870e01dc 100644 --- a/backend/src/schema/types/enum/ReasonNotification.gql +++ b/backend/src/schema/types/enum/ReasonNotification.gql @@ -1,5 +1,5 @@ enum ReasonNotification { mentioned_in_post mentioned_in_comment - comment_on_post -} \ No newline at end of file + commented_on_post +} diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index e0a2c328b..dc2cec8f5 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -4,7 +4,7 @@ type Query { currentUser: User # Get the latest Network Statistics statistics: Statistics! - findPosts(query: String!, limit: Int = 10): [Post]! + findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]! @cypher( statement: """ CALL db.index.fulltext.queryNodes('full_text_search', $query) @@ -51,23 +51,6 @@ type Statistics { countShouts: Int! } -type Location { - id: ID! - name: String! - nameEN: String - nameDE: String - nameFR: String - nameNL: String - nameIT: String - nameES: String - namePT: String - namePL: String - type: String! - lat: Float - lng: Float - parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") -} - type Report { id: ID! submitter: User @relation(name: "REPORTED", direction: "IN") diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql new file mode 100644 index 000000000..b90e30598 --- /dev/null +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -0,0 +1,28 @@ +type NOTIFIED { + from: NotificationSource + to: User + createdAt: String + read: Boolean + reason: NotificationReason +} + +union NotificationSource = Post | Comment + +enum NotificationOrdering { + createdAt_asc + createdAt_desc +} + +enum NotificationReason { + mentioned_in_post + mentioned_in_comment + commented_on_post +} + +type Query { + notifications(read: Boolean, orderBy: NotificationOrdering): [NOTIFIED] +} + +type Mutation { + markAsRead(id: ID!): NOTIFIED +} diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql deleted file mode 100644 index a3543445f..000000000 --- a/backend/src/schema/types/type/Notification.gql +++ /dev/null @@ -1,9 +0,0 @@ -type Notification { - id: ID! - read: Boolean - reason: ReasonNotification - createdAt: String - user: User @relation(name: "NOTIFIED", direction: "OUT") - post: Post @relation(name: "NOTIFIED", direction: "IN") - comment: Comment @relation(name: "NOTIFIED", direction: "IN") -} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index cbbcddfc9..0e1121cd2 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -20,6 +20,7 @@ type Post { @cypher( statement: """ MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post) + WHERE NOT post.deleted AND NOT post.disabled RETURN DISTINCT post LIMIT 10 """ @@ -29,6 +30,11 @@ type Post { categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") comments: [Comment]! @relation(name: "COMMENTS", direction: "IN") + commentsCount: Int! + @cypher( + statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" + ) + shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN") shoutedCount: Int! @cypher( @@ -38,10 +44,7 @@ type Post { # Has the currently logged in user shouted that post? shoutedByCurrentUser: Boolean! @cypher( - statement: """ - MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) - RETURN COUNT(u) >= 1 - """ + statement: "MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1" ) emotions: [EMOTED] @@ -52,26 +55,18 @@ type Post { type Mutation { CreatePost( id: ID - activityId: String - objectId: String title: String! slug: String content: String! image: String imageUpload: Upload visibility: Visibility - deleted: Boolean - disabled: Boolean - createdAt: String - updatedAt: String language: String categoryIds: [ID] contentExcerpt: String ): Post UpdatePost( id: ID! - activityId: String - objectId: String title: String! slug: String content: String! @@ -79,10 +74,6 @@ type Mutation { image: String imageUpload: Upload visibility: Visibility - deleted: Boolean - disabled: Boolean - createdAt: String - updatedAt: String language: String categoryIds: [ID] ): Post diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 46e699410..6151d0708 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -17,15 +17,13 @@ type User { location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String about: String - socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT") + socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") #createdAt: DateTime #updatedAt: DateTime createdAt: String updatedAt: String - notifications(read: Boolean): [Notification]! @relation(name: "NOTIFIED", direction: "IN") - friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)") @@ -64,7 +62,7 @@ type User { ) comments: [Comment]! @relation(name: "WROTE", direction: "OUT") - commentedCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment)-[:COMMENTS]->(p:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true AND NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") + commentedCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(:Comment)-[:COMMENTS]->(p:Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") diff --git a/backend/src/seed/factories/categories.js b/backend/src/seed/factories/categories.js index 341f1b1fd..d3f5fed21 100644 --- a/backend/src/seed/factories/categories.js +++ b/backend/src/seed/factories/categories.js @@ -1,17 +1,18 @@ import uuid from 'uuid/v4' -export default function(params) { - const { id = uuid(), name, slug, icon } = params - +export default function create() { return { - mutation: ` - mutation($id: ID, $name: String!, $slug: String, $icon: String!) { - CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) { - id - name + factory: async ({ args, neodeInstance }) => { + const defaults = { + id: uuid(), + icon: 'img/badges/fundraisingbox_de_airship.svg', + name: 'Some category name', } - } - `, - variables: { id, name, slug, icon }, + args = { + ...defaults, + ...args, + } + return neodeInstance.create('Category', args) + }, } } diff --git a/backend/src/seed/factories/comments.js b/backend/src/seed/factories/comments.js index 20933e947..de3390e1a 100644 --- a/backend/src/seed/factories/comments.js +++ b/backend/src/seed/factories/comments.js @@ -1,21 +1,38 @@ import faker from 'faker' import uuid from 'uuid/v4' -export default function(params) { - const { - id = uuid(), - postId = 'p6', - content = [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), - } = params - +export default function create() { return { - mutation: ` - mutation($id: ID!, $postId: ID!, $content: String!) { - CreateComment(id: $id, postId: $postId, content: $content) { - id - } + factory: async ({ args, neodeInstance, factoryInstance }) => { + const defaults = { + id: uuid(), + content: [faker.lorem.sentence(), faker.lorem.sentence()].join('. '), } - `, - variables: { id, postId, content }, + args = { + ...defaults, + ...args, + } + args.contentExcerpt = args.contentExcerpt || args.content + + let { post, postId } = args + delete args.post + delete args.postId + if (post && postId) throw new Error('You provided both post and postId') + if (postId) post = await neodeInstance.find('Post', postId) + post = post || (await factoryInstance.create('Post')) + + let { author, authorId } = args + delete args.author + delete args.authorId + if (author && authorId) throw new Error('You provided both author and authorId') + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User')) + + delete args.author + const comment = await neodeInstance.create('Comment', args) + await comment.relateTo(post, 'post') + await comment.relateTo(author, 'author') + return comment + }, } } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 56518bd06..913e6efa1 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -2,13 +2,12 @@ import { GraphQLClient, request } from 'graphql-request' import { getDriver, neode } from '../../bootstrap/neo4j' import createBadge from './badges.js' import createUser from './users.js' -import createOrganization from './organizations.js' import createPost from './posts.js' import createComment from './comments.js' import createCategory from './categories.js' import createTag from './tags.js' -import createReport from './reports.js' -import createNotification from './notifications.js' +import createSocialMedia from './socialMedia.js' +import createLocation from './locations.js' export const seedServerHost = 'http://127.0.0.1:4001' @@ -25,13 +24,12 @@ const authenticatedHeaders = async ({ email, password }, host) => { const factories = { Badge: createBadge, User: createUser, - Organization: createOrganization, Post: createPost, Comment: createComment, Category: createCategory, Tag: createTag, - Report: createReport, - Notification: createNotification, + SocialMedia: createSocialMedia, + Location: createLocation, } export const cleanDatabase = async (options = {}) => { @@ -81,6 +79,7 @@ export default function Factory(options = {}) { this.lastResponse = await factory({ args, neodeInstance, + factoryInstance: this, }) return this.lastResponse } else { diff --git a/backend/src/seed/factories/locations.js b/backend/src/seed/factories/locations.js new file mode 100644 index 000000000..99b666de8 --- /dev/null +++ b/backend/src/seed/factories/locations.js @@ -0,0 +1,24 @@ +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + const defaults = { + name: 'Germany', + namePT: 'Alemanha', + nameDE: 'Deutschland', + nameES: 'Alemania', + nameNL: 'Duitsland', + namePL: 'Niemcy', + nameFR: 'Allemagne', + nameIT: 'Germania', + nameEN: 'Germany', + id: 'country.10743216036480410', + type: 'country', + } + args = { + ...defaults, + ...args, + } + return neodeInstance.create('Location', args) + }, + } +} diff --git a/backend/src/seed/factories/notifications.js b/backend/src/seed/factories/notifications.js deleted file mode 100644 index d14d4294a..000000000 --- a/backend/src/seed/factories/notifications.js +++ /dev/null @@ -1,17 +0,0 @@ -import uuid from 'uuid/v4' - -export default function(params) { - const { id = uuid(), read = false } = params - - return { - mutation: ` - mutation($id: ID, $read: Boolean) { - CreateNotification(id: $id, read: $read) { - id - read - } - } - `, - variables: { id, read }, - } -} diff --git a/backend/src/seed/factories/organizations.js b/backend/src/seed/factories/organizations.js deleted file mode 100644 index 536de1597..000000000 --- a/backend/src/seed/factories/organizations.js +++ /dev/null @@ -1,21 +0,0 @@ -import faker from 'faker' -import uuid from 'uuid/v4' - -export default function create(params) { - const { - id = uuid(), - name = faker.company.companyName(), - description = faker.company.catchPhrase(), - } = params - - return { - mutation: ` - mutation($id: ID!, $name: String!, $description: String!) { - CreateOrganization(id: $id, name: $name, description: $description) { - name - } - } - `, - variables: { id, name, description }, - } -} diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index f2f1432dc..e81251c53 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -1,51 +1,60 @@ import faker from 'faker' +import slugify from 'slug' import uuid from 'uuid/v4' -export default function(params) { - const { - id = uuid(), - slug = '', - title = faker.lorem.sentence(), - content = [ - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - faker.lorem.sentence(), - ].join('. '), - image = faker.image.unsplash.imageUrl(), - visibility = 'public', - deleted = false, - categoryIds, - } = params - +export default function create() { return { - mutation: ` - mutation( - $id: ID! - $slug: String - $title: String! - $content: String! - $image: String - $visibility: Visibility - $deleted: Boolean - $categoryIds: [ID] - ) { - CreatePost( - id: $id - slug: $slug - title: $title - content: $content - image: $image - visibility: $visibility - deleted: $deleted - categoryIds: $categoryIds - ) { - title - content - } + factory: async ({ args, neodeInstance, factoryInstance }) => { + const defaults = { + id: uuid(), + title: faker.lorem.sentence(), + content: [ + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + faker.lorem.sentence(), + ].join('. '), + image: faker.image.unsplash.imageUrl(), + visibility: 'public', + deleted: false, + categoryIds: [], } - `, - variables: { id, slug, title, content, image, visibility, deleted, categoryIds }, + args = { + ...defaults, + ...args, + } + args.slug = args.slug || slugify(args.title, { lower: true }) + args.contentExcerpt = args.contentExcerpt || args.content + + let { categories, categoryIds } = args + delete args.categories + delete args.categoryIds + if (categories && categoryIds) throw new Error('You provided both category and categoryIds') + if (categoryIds) + categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id))) + categories = categories || (await Promise.all([factoryInstance.create('Category')])) + + const { tagIds = [] } = args + delete args.tags + const tags = await Promise.all( + tagIds.map(t => { + return neodeInstance.find('Tag', t) + }), + ) + + let { author, authorId } = args + delete args.author + delete args.authorId + if (author && authorId) throw new Error('You provided both author and authorId') + if (authorId) author = await neodeInstance.find('User', authorId) + author = author || (await factoryInstance.create('User')) + + const post = await neodeInstance.create('Post', args) + await post.relateTo(author, 'author') + await Promise.all(categories.map(c => c.relateTo(post, 'post'))) + await Promise.all(tags.map(t => t.relateTo(post, 'post'))) + return post + }, } } diff --git a/backend/src/seed/factories/reports.js b/backend/src/seed/factories/reports.js deleted file mode 100644 index 5bb6f6ba2..000000000 --- a/backend/src/seed/factories/reports.js +++ /dev/null @@ -1,19 +0,0 @@ -import faker from 'faker' - -export default function create(params) { - const { description = faker.lorem.sentence(), id } = params - - return { - mutation: ` - mutation($id: ID!, $description: String!) { - report(description: $description, id: $id) { - id - } - } - `, - variables: { - id, - description, - }, - } -} diff --git a/backend/src/seed/factories/socialMedia.js b/backend/src/seed/factories/socialMedia.js new file mode 100644 index 000000000..49a237cef --- /dev/null +++ b/backend/src/seed/factories/socialMedia.js @@ -0,0 +1,14 @@ +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + const defaults = { + url: 'https://mastodon.social/@Gargron', + } + args = { + ...defaults, + ...args, + } + return neodeInstance.create('SocialMedia', args) + }, + } +} diff --git a/backend/src/seed/factories/tags.js b/backend/src/seed/factories/tags.js index 4a135e051..9005d1406 100644 --- a/backend/src/seed/factories/tags.js +++ b/backend/src/seed/factories/tags.js @@ -1,16 +1,12 @@ -import uuid from 'uuid/v4' - -export default function(params) { - const { id = uuid(), name = '#human-connection' } = params - +export default function create() { return { - mutation: ` - mutation($id: ID!) { - CreateTag(id: $id) { - id - } + factory: async ({ args, neodeInstance }) => { + const defaults = { name: '#human-connection' } + args = { + ...defaults, + ...args, } - `, - variables: { id, name }, + return neodeInstance.create('Tag', args) + }, } } diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index bbacd2149..48a739529 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -1,10 +1,110 @@ import faker from 'faker' +import { createTestClient } from 'apollo-server-testing' +import createServer from '../server' import Factory from './factories' +import { neode as getNeode, getDriver } from '../bootstrap/neo4j' +import { gql } from '../jest/helpers' /* eslint-disable no-multi-spaces */ ;(async function() { + let authenticatedUser = null + const driver = getDriver() + const factory = Factory() + const neode = getNeode() + try { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + const { mutate } = createTestClient(server) + const f = Factory() + + const [Hamburg, Berlin, Germany, Paris, France] = await Promise.all([ + f.create('Location', { + id: 'region.5127278006398860', + name: 'Hamburg', + type: 'region', + lat: 10.0, + lng: 53.55, + nameES: 'Hamburgo', + nameFR: 'Hambourg', + nameIT: 'Amburgo', + nameEN: 'Hamburg', + namePT: 'Hamburgo', + nameDE: 'Hamburg', + nameNL: 'Hamburg', + namePL: 'Hamburg', + }), + f.create('Location', { + id: 'region.14880313158564380', + type: 'region', + name: 'Berlin', + lat: 13.38333, + lng: 52.51667, + nameES: 'Berlín', + nameFR: 'Berlin', + nameIT: 'Berlino', + nameEN: 'Berlin', + namePT: 'Berlim', + nameDE: 'Berlin', + nameNL: 'Berlijn', + namePL: 'Berlin', + }), + f.create('Location', { + id: 'country.10743216036480410', + name: 'Germany', + type: 'country', + namePT: 'Alemanha', + nameDE: 'Deutschland', + nameES: 'Alemania', + nameNL: 'Duitsland', + namePL: 'Niemcy', + nameFR: 'Allemagne', + nameIT: 'Germania', + nameEN: 'Germany', + }), + f.create('Location', { + id: 'region.9397217726497330', + name: 'Paris', + type: 'region', + lat: 2.35183, + lng: 48.85658, + nameES: 'París', + nameFR: 'Paris', + nameIT: 'Parigi', + nameEN: 'Paris', + namePT: 'Paris', + nameDE: 'Paris', + nameNL: 'Parijs', + namePL: 'Paryż', + }), + f.create('Location', { + id: 'country.9759535382641660', + name: 'France', + type: 'country', + namePT: 'França', + nameDE: 'Frankreich', + nameES: 'Francia', + nameNL: 'Frankrijk', + namePL: 'Francja', + nameFR: 'France', + nameIT: 'Francia', + nameEN: 'France', + }), + ]) + await Promise.all([ + Berlin.relateTo(Germany, 'isIn'), + Hamburg.relateTo(Germany, 'isIn'), + Paris.relateTo(France, 'isIn'), + ]) + const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([ f.create('Badge', { id: 'indiegogo_en_racoon', @@ -36,9 +136,9 @@ import Factory from './factories' peterLustig, bobDerBaumeister, jennyRostock, - tick, // eslint-disable-line no-unused-vars - trick, // eslint-disable-line no-unused-vars - track, // eslint-disable-line no-unused-vars + huey, + dewey, + louie, dagobert, ] = await Promise.all([ f.create('User', { @@ -64,22 +164,22 @@ import Factory from './factories' }), f.create('User', { id: 'u4', - name: 'Huey (Tick)', - slug: 'huey-tick', + name: 'Huey', + slug: 'huey', role: 'user', email: 'huey@example.org', }), f.create('User', { id: 'u5', - name: 'Dewey (Trick)', - slug: 'dewey-trick', + name: 'Dewey', + slug: 'dewey', role: 'user', email: 'dewey@example.org', }), f.create('User', { id: 'u6', - name: 'Louie (Track)', - slug: 'louie-track', + name: 'Louie', + slug: 'louie', role: 'user', email: 'louie@example.org', }), @@ -92,31 +192,11 @@ import Factory from './factories' }), ]) - const [asAdmin, asModerator, asUser, asTick, asTrick, asTrack] = await Promise.all([ - Factory().authenticateAs({ - email: 'admin@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'moderator@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'user@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'huey@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'dewey@example.org', - password: '1234', - }), - Factory().authenticateAs({ - email: 'louie@example.org', - password: '1234', - }), + await Promise.all([ + peterLustig.relateTo(Berlin, 'isIn'), + bobDerBaumeister.relateTo(Hamburg, 'isIn'), + jennyRostock.relateTo(Paris, 'isIn'), + huey.relateTo(Paris, 'isIn'), ]) await Promise.all([ @@ -133,16 +213,16 @@ import Factory from './factories' bobDerBaumeister.relateTo(jennyRostock, 'friends'), peterLustig.relateTo(jennyRostock, 'following'), - peterLustig.relateTo(tick, 'following'), - bobDerBaumeister.relateTo(tick, 'following'), - jennyRostock.relateTo(tick, 'following'), - tick.relateTo(track, 'following'), - trick.relateTo(tick, 'following'), - track.relateTo(jennyRostock, 'following'), + peterLustig.relateTo(huey, 'following'), + bobDerBaumeister.relateTo(huey, 'following'), + jennyRostock.relateTo(huey, 'following'), + huey.relateTo(dewey, 'following'), + dewey.relateTo(huey, 'following'), + louie.relateTo(jennyRostock, 'following'), - dagobert.relateTo(tick, 'blocked'), - dagobert.relateTo(trick, 'blocked'), - dagobert.relateTo(track, 'blocked'), + dagobert.relateTo(huey, 'blocked'), + dagobert.relateTo(dewey, 'blocked'), + dagobert.relateTo(louie, 'blocked'), ]) await Promise.all([ @@ -244,25 +324,90 @@ import Factory from './factories' }), ]) - await Promise.all([ + const [environment, nature, democracy, freedom] = await Promise.all([ f.create('Tag', { - id: 'Umwelt', - name: 'Umwelt', + id: 'Environment', }), f.create('Tag', { - id: 'Naturschutz', - name: 'Naturschutz', + id: 'Nature', }), f.create('Tag', { - id: 'Demokratie', - name: 'Demokratie', + id: 'Democracy', }), f.create('Tag', { - id: 'Freiheit', - name: 'Freiheit', + id: 'Freedom', }), ]) + const [p0, p1, p3, p4, p5, p6, p9, p10, p11, p13, p14, p15] = await Promise.all([ + factory.create('Post', { + author: peterLustig, + id: 'p0', + image: faker.image.unsplash.food(), + categoryIds: ['cat16'], + }), + factory.create('Post', { + author: bobDerBaumeister, + id: 'p1', + image: faker.image.unsplash.technology(), + categoryIds: ['cat1'], + }), + factory.create('Post', { + author: huey, + id: 'p3', + categoryIds: ['cat3'], + }), + factory.create('Post', { + author: dewey, + id: 'p4', + categoryIds: ['cat4'], + }), + factory.create('Post', { + author: louie, + id: 'p5', + categoryIds: ['cat5'], + }), + factory.create('Post', { + authorId: 'u1', + id: 'p6', + image: faker.image.unsplash.buildings(), + categoryIds: ['cat6'], + }), + factory.create('Post', { + author: huey, + id: 'p9', + categoryIds: ['cat9'], + }), + factory.create('Post', { + author: dewey, + id: 'p10', + categoryIds: ['cat10'], + }), + factory.create('Post', { + author: louie, + id: 'p11', + image: faker.image.unsplash.people(), + categoryIds: ['cat11'], + }), + factory.create('Post', { + author: bobDerBaumeister, + id: 'p13', + categoryIds: ['cat13'], + }), + factory.create('Post', { + author: jennyRostock, + id: 'p14', + image: faker.image.unsplash.objects(), + categoryIds: ['cat14'], + }), + factory.create('Post', { + author: huey, + id: 'p15', + categoryIds: ['cat15'], + }), + ]) + + authenticatedUser = await louie.toJson() const mention1 = 'Hey @jenny-rostock, what\'s up?' const mention2 = @@ -270,469 +415,272 @@ import Factory from './factories' const hashtag1 = 'See #NaturphilosophieYoga can really help you!' const hashtagAndMention1 = - 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' + 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' + const createPostMutation = gql` + mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + id + } + } + ` await Promise.all([ - asAdmin.create('Post', { - id: 'p0', - image: faker.image.unsplash.food(), - categoryIds: ['cat16'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p2', + title: `Nature Philosophy Yoga`, + content: hashtag1, + categoryIds: ['cat2'], + }, }), - asModerator.create('Post', { - id: 'p1', - image: faker.image.unsplash.technology(), - categoryIds: ['cat1'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p7', + title: 'This is post #7', + content: `${mention1} ${faker.lorem.paragraph()}`, + categoryIds: ['cat7'], + }, }), - asUser.create('Post', { - id: 'p2', - title: `Nature Philosophy Yoga`, - content: `${hashtag1}`, - categoryIds: ['cat2'], + mutate({ + mutation: createPostMutation, + variables: { + id: 'p8', + image: faker.image.unsplash.nature(), + title: `Quantum Flow Theory explains Quantum Gravity`, + content: hashtagAndMention1, + categoryIds: ['cat8'], + }, }), - asTick.create('Post', { - id: 'p3', - categoryIds: ['cat3'], - }), - asTrick.create('Post', { - id: 'p4', - categoryIds: ['cat4'], - }), - asTrack.create('Post', { - id: 'p5', - categoryIds: ['cat5'], - }), - asAdmin.create('Post', { - id: 'p6', - image: faker.image.unsplash.buildings(), - categoryIds: ['cat6'], - }), - asModerator.create('Post', { - id: 'p7', - content: `${mention1} ${faker.lorem.paragraph()}`, - categoryIds: ['cat7'], - }), - asUser.create('Post', { - id: 'p8', - image: faker.image.unsplash.nature(), - title: `Quantum Flow Theory explains Quantum Gravity`, - content: `${hashtagAndMention1}`, - categoryIds: ['cat8'], - }), - asTick.create('Post', { - id: 'p9', - categoryIds: ['cat9'], - }), - asTrick.create('Post', { - id: 'p10', - categoryIds: ['cat10'], - }), - asTrack.create('Post', { - id: 'p11', - image: faker.image.unsplash.people(), - categoryIds: ['cat11'], - }), - asAdmin.create('Post', { - id: 'p12', - content: `${mention2} ${faker.lorem.paragraph()}`, - categoryIds: ['cat12'], - }), - asModerator.create('Post', { - id: 'p13', - categoryIds: ['cat13'], - }), - asUser.create('Post', { - id: 'p14', - image: faker.image.unsplash.objects(), - categoryIds: ['cat14'], - }), - asTick.create('Post', { - id: 'p15', - categoryIds: ['cat15'], - }), - ]) - - await Promise.all([ - f.relate('Post', 'Tags', { - from: 'p0', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p1', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p2', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p3', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p4', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p5', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p6', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p7', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p8', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p9', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p10', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p11', - to: 'Demokratie', - }), - f.relate('Post', 'Tags', { - from: 'p12', - to: 'Freiheit', - }), - f.relate('Post', 'Tags', { - from: 'p13', - to: 'Umwelt', - }), - f.relate('Post', 'Tags', { - from: 'p14', - to: 'Naturschutz', - }), - f.relate('Post', 'Tags', { - from: 'p15', - to: 'Demokratie', - }), - f.emote({ - from: 'u1', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u2', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u3', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u4', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u5', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u7', - to: 'p15', - data: 'surprised', - }), - f.emote({ - from: 'u2', - to: 'p14', - data: 'cry', - }), - f.emote({ - from: 'u3', - to: 'p13', - data: 'angry', - }), - f.emote({ - from: 'u4', - to: 'p12', - data: 'funny', - }), - f.emote({ - from: 'u5', - to: 'p11', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p10', - data: 'cry', - }), - f.emote({ - from: 'u5', - to: 'p9', - data: 'happy', - }), - f.emote({ - from: 'u4', - to: 'p8', - data: 'angry', - }), - f.emote({ - from: 'u3', - to: 'p7', - data: 'funny', - }), - f.emote({ - from: 'u2', - to: 'p6', - data: 'surprised', - }), - f.emote({ - from: 'u1', - to: 'p5', - data: 'cry', - }), - f.emote({ - from: 'u2', - to: 'p4', - data: 'happy', - }), - f.emote({ - from: 'u3', - to: 'p3', - data: 'angry', - }), - f.emote({ - from: 'u4', - to: 'p2', - data: 'funny', - }), - f.emote({ - from: 'u5', - to: 'p1', - data: 'surprised', - }), - f.emote({ - from: 'u6', - to: 'p0', - data: 'cry', - }), - ]) - - await Promise.all([ - asAdmin.shout({ - id: 'p2', - type: 'Post', - }), - asAdmin.shout({ - id: 'p6', - type: 'Post', - }), - asModerator.shout({ - id: 'p0', - type: 'Post', - }), - asModerator.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p7', - type: 'Post', - }), - asTick.shout({ - id: 'p8', - type: 'Post', - }), - asTick.shout({ - id: 'p9', - type: 'Post', - }), - asTrack.shout({ - id: 'p10', - type: 'Post', - }), - ]) - await Promise.all([ - asAdmin.shout({ - id: 'p2', - type: 'Post', - }), - asAdmin.shout({ - id: 'p6', - type: 'Post', - }), - asModerator.shout({ - id: 'p0', - type: 'Post', - }), - asModerator.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p6', - type: 'Post', - }), - asUser.shout({ - id: 'p7', - type: 'Post', - }), - asTick.shout({ - id: 'p8', - type: 'Post', - }), - asTick.shout({ - id: 'p9', - type: 'Post', - }), - asTrack.shout({ - id: 'p10', - type: 'Post', + mutate({ + mutation: createPostMutation, + variables: { + id: 'p12', + title: 'This is post #12', + content: `${mention2} ${faker.lorem.paragraph()}`, + categoryIds: ['cat12'], + }, }), ]) + const [p2, p7, p8, p12] = await Promise.all( + ['p2', 'p7', 'p8', 'p12'].map(id => neode.find('Post', id)), + ) + authenticatedUser = null + authenticatedUser = await dewey.toJson() const mentionInComment1 = 'I heard @jenny-rostock, practice it since 3 years now.' const mentionInComment2 = 'Did @peter-lustig told you?' + const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + } + } + ` + await Promise.all([ + mutate({ + mutation: createCommentMutation, + variables: { + id: 'c4', + postId: 'p2', + content: mentionInComment1, + }, + }), + mutate({ + mutation: createCommentMutation, + variables: { + id: 'c4-1', + postId: 'p2', + content: mentionInComment2, + }, + }), + mutate({ + mutation: createCommentMutation, + variables: { + postId: 'p14', + content: faker.lorem.paragraph(), + }, + }), // should send a notification + ]) + authenticatedUser = null await Promise.all([ - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c1', postId: 'p1', }), - asTick.create('Comment', { + factory.create('Comment', { + author: huey, id: 'c2', postId: 'p1', }), - asTrack.create('Comment', { + factory.create('Comment', { + author: louie, id: 'c3', postId: 'p3', }), - asTrick.create('Comment', { - id: 'c4', - postId: 'p2', - content: `${mentionInComment1}`, - }), - asUser.create('Comment', { - id: 'c4-1', - postId: 'p2', - content: `${mentionInComment2}`, - }), - asModerator.create('Comment', { + factory.create('Comment', { + author: bobDerBaumeister, id: 'c5', postId: 'p3', }), - asAdmin.create('Comment', { + factory.create('Comment', { + author: peterLustig, id: 'c6', postId: 'p4', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c7', postId: 'p2', }), - asTick.create('Comment', { + factory.create('Comment', { + author: huey, id: 'c8', postId: 'p15', }), - asTrick.create('Comment', { + factory.create('Comment', { + author: dewey, id: 'c9', postId: 'p15', }), - asTrack.create('Comment', { + factory.create('Comment', { + author: louie, id: 'c10', postId: 'p15', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c11', postId: 'p15', }), - asUser.create('Comment', { + factory.create('Comment', { + author: jennyRostock, id: 'c12', postId: 'p15', }), ]) - const disableMutation = 'mutation($id: ID!) { disable(id: $id) }' await Promise.all([ - asModerator.mutate(disableMutation, { - id: 'p11', - }), - asModerator.mutate(disableMutation, { - id: 'c5', - }), + democracy.relateTo(p3, 'post'), + democracy.relateTo(p11, 'post'), + democracy.relateTo(p15, 'post'), + democracy.relateTo(p7, 'post'), + environment.relateTo(p1, 'post'), + environment.relateTo(p5, 'post'), + environment.relateTo(p9, 'post'), + environment.relateTo(p13, 'post'), + freedom.relateTo(p0, 'post'), + freedom.relateTo(p4, 'post'), + freedom.relateTo(p8, 'post'), + freedom.relateTo(p12, 'post'), + nature.relateTo(p2, 'post'), + nature.relateTo(p6, 'post'), + nature.relateTo(p10, 'post'), + nature.relateTo(p14, 'post'), + peterLustig.relateTo(p15, 'emoted', { emotion: 'surprised' }), + bobDerBaumeister.relateTo(p15, 'emoted', { emotion: 'surprised' }), + jennyRostock.relateTo(p15, 'emoted', { emotion: 'surprised' }), + huey.relateTo(p15, 'emoted', { emotion: 'surprised' }), + dewey.relateTo(p15, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p15, 'emoted', { emotion: 'surprised' }), + dagobert.relateTo(p15, 'emoted', { emotion: 'surprised' }), + bobDerBaumeister.relateTo(p14, 'emoted', { emotion: 'cry' }), + jennyRostock.relateTo(p13, 'emoted', { emotion: 'angry' }), + huey.relateTo(p12, 'emoted', { emotion: 'funny' }), + dewey.relateTo(p11, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p10, 'emoted', { emotion: 'cry' }), + dewey.relateTo(p9, 'emoted', { emotion: 'happy' }), + huey.relateTo(p8, 'emoted', { emotion: 'angry' }), + jennyRostock.relateTo(p7, 'emoted', { emotion: 'funny' }), + bobDerBaumeister.relateTo(p6, 'emoted', { emotion: 'surprised' }), + peterLustig.relateTo(p5, 'emoted', { emotion: 'cry' }), + bobDerBaumeister.relateTo(p4, 'emoted', { emotion: 'happy' }), + jennyRostock.relateTo(p3, 'emoted', { emotion: 'angry' }), + huey.relateTo(p2, 'emoted', { emotion: 'funny' }), + dewey.relateTo(p1, 'emoted', { emotion: 'surprised' }), + louie.relateTo(p0, 'emoted', { emotion: 'cry' }), ]) await Promise.all([ - asTick.create('Report', { - description: "I don't like this comment", - id: 'c1', - }), - asTrick.create('Report', { - description: "I don't like this post", - id: 'p1', - }), - asTrack.create('Report', { - description: "I don't like this user", - id: 'u1', - }), + peterLustig.relateTo(p1, 'shouted'), + peterLustig.relateTo(p6, 'shouted'), + bobDerBaumeister.relateTo(p0, 'shouted'), + bobDerBaumeister.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p7, 'shouted'), + huey.relateTo(p8, 'shouted'), + huey.relateTo(p9, 'shouted'), + dewey.relateTo(p10, 'shouted'), + peterLustig.relateTo(p2, 'shouted'), + peterLustig.relateTo(p6, 'shouted'), + bobDerBaumeister.relateTo(p0, 'shouted'), + bobDerBaumeister.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p6, 'shouted'), + jennyRostock.relateTo(p7, 'shouted'), + huey.relateTo(p8, 'shouted'), + huey.relateTo(p9, 'shouted'), + louie.relateTo(p10, 'shouted'), ]) + const disableMutation = gql` + mutation($id: ID!) { + disable(id: $id) + } + ` + authenticatedUser = await bobDerBaumeister.toJson() await Promise.all([ - f.create('Organization', { - id: 'o1', - name: 'Democracy Deutschland', - description: 'Description for democracy-deutschland.', + mutate({ + mutation: disableMutation, + variables: { + id: 'p11', + }, }), - f.create('Organization', { - id: 'o2', - name: 'Human-Connection', - description: 'Description for human-connection.', - }), - f.create('Organization', { - id: 'o3', - name: 'Pro Veg', - description: 'Description for pro-veg.', - }), - f.create('Organization', { - id: 'o4', - name: 'Greenpeace', - description: 'Description for greenpeace.', + mutate({ + mutation: disableMutation, + variables: { + id: 'c5', + }, }), ]) + authenticatedUser = null + const reportMutation = gql` + mutation($id: ID!, $description: String!) { + report(description: $description, id: $id) { + id + } + } + ` + authenticatedUser = await huey.toJson() await Promise.all([ - f.relate('Organization', 'CreatedBy', { - from: 'u1', - to: 'o1', + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this comment", + id: 'c1', + }, }), - f.relate('Organization', 'CreatedBy', { - from: 'u1', - to: 'o2', + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this post", + id: 'p1', + }, }), - f.relate('Organization', 'OwnedBy', { - from: 'u2', - to: 'o2', - }), - f.relate('Organization', 'OwnedBy', { - from: 'u2', - to: 'o3', + mutate({ + mutation: reportMutation, + variables: { + description: "I don't like this user", + id: 'u1', + }, }), ]) + authenticatedUser = null await Promise.all( [...Array(30).keys()].map(i => { diff --git a/backend/src/server.js b/backend/src/server.js index f92e77fed..70eae86f1 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -18,20 +18,22 @@ Object.entries(requiredConfigs).map(entry => { const driver = getDriver() const neode = getNeode() +export const context = async ({ req }) => { + const user = await decode(driver, req.headers.authorization) + return { + driver, + neode, + user, + req, + cypherParams: { + currentUserId: user ? user.id : null, + }, + } +} + const createServer = options => { const defaults = { - context: async ({ req }) => { - const user = await decode(driver, req.headers.authorization) - return { - driver, - neode, - user, - req, - cypherParams: { - currentUserId: user ? user.id : null, - }, - } - }, + context, schema: middleware(schema), debug: !!CONFIG.DEBUG, tracing: !!CONFIG.DEBUG, diff --git a/backend/yarn.lock b/backend/yarn.lock index 59a36de79..2829dc9d0 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -689,7 +689,7 @@ core-js "^2.6.5" regenerator-runtime "^0.13.2" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== @@ -1538,13 +1538,13 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -apollo-cache-control@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.8.2.tgz#0687e323053f907fd9bb601c1921de10799e24a0" - integrity sha512-rvx4DdoAAbWhm3L0IoWrxN+Zq2Xk4uAYbaiZk0Nhuc/y4AQUww3JV/z4EfCp3O5cy5/lNMW/tPOozcqi941awA== +apollo-cache-control@^0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.8.4.tgz#a3650d5e4173953e2a3af995bea62147f1ffe4d7" + integrity sha512-IZ1d3AXZtkZhLYo0kWqTbZ6nqLFaeUvLdMESs+9orMadBZ7mvzcAfBwrhKyCWPGeAAZ/jKv8FtYHybpchHgFAg== dependencies: - apollo-server-env "2.4.2" - graphql-extensions "0.10.1" + apollo-server-env "^2.4.3" + graphql-extensions "^0.10.3" apollo-cache-inmemory@~1.6.3: version "1.6.3" @@ -1579,33 +1579,33 @@ apollo-client@~2.6.4: tslib "^1.9.3" zen-observable "^0.8.0" -apollo-datasource@0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/apollo-datasource/-/apollo-datasource-0.6.2.tgz#3eeb8f9660304a223c3f7aecfe0274376c876307" - integrity sha512-GlqTfLjKFxNYxGGACDjDXUpm/vPfvXhUI/Qc/YdkY4ess/wn7EFdrmbZGIY56RJtXD5M7qjsQIH15t132KoPmQ== +apollo-datasource@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/apollo-datasource/-/apollo-datasource-0.6.3.tgz#b31e089e52adb92fabb536ab8501c502573ffe13" + integrity sha512-gRYyFVpJgHE2hhS+VxMeOerxXQ/QYxWG7T6QddfugJWYAG9DRCl65e2b7txcGq2NP3r+O1iCm4GNwhRBDJbd8A== dependencies: - apollo-server-caching "0.5.0" - apollo-server-env "2.4.2" + apollo-server-caching "^0.5.0" + apollo-server-env "^2.4.3" -apollo-engine-reporting-protobuf@0.4.0: +apollo-engine-reporting-protobuf@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.0.tgz#e34c192d86493b33a73181fd6be75721559111ec" integrity sha512-cXHZSienkis8v4RhqB3YG3DkaksqLpcxApRLTpRMs7IXNozgV7CUPYGFyFBEra1ZFgUyHXx4G9MpelV+n2cCfA== dependencies: protobufjs "^6.8.6" -apollo-engine-reporting@1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.4.4.tgz#ab232dcaa81fe9718fb23e9782457c66dc86e817" - integrity sha512-FOk/HooLMesoKHo/TGOPYZuc2t4q9YwoeM+z0AGRUY70hL2o5Ie3x0XiMb+I5IVibR+jBIRRKP2ngmSFJ+LqSg== +apollo-engine-reporting@^1.4.6: + version "1.4.6" + resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.4.6.tgz#83af6689c4ab82d1c62c3f5dde7651975508114f" + integrity sha512-acfb7oFnru/8YQdY4x6+7WJbZfzdVETI8Cl+9ImgUrvUnE8P+f2SsGTKXTC1RuUvve4c56PAvaPgE+z8X1a1Mw== dependencies: - apollo-engine-reporting-protobuf "0.4.0" + apollo-engine-reporting-protobuf "^0.4.0" apollo-graphql "^0.3.3" - apollo-server-caching "0.5.0" - apollo-server-env "2.4.2" - apollo-server-types "0.2.2" + apollo-server-caching "^0.5.0" + apollo-server-env "^2.4.3" + apollo-server-types "^0.2.4" async-retry "^1.2.1" - graphql-extensions "0.10.1" + graphql-extensions "^0.10.3" apollo-env@0.5.1: version "0.5.1" @@ -1668,33 +1668,33 @@ apollo-link@^1.0.0, apollo-link@^1.2.12, apollo-link@^1.2.3: tslib "^1.9.3" zen-observable-ts "^0.8.19" -apollo-server-caching@0.5.0: +apollo-server-caching@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/apollo-server-caching/-/apollo-server-caching-0.5.0.tgz#446a37ce2d4e24c81833e276638330a634f7bd46" integrity sha512-l7ieNCGxUaUAVAAp600HjbUJxVaxjJygtPV0tPTe1Q3HkPy6LEWoY6mNHV7T268g1hxtPTxcdRu7WLsJrg7ufw== dependencies: lru-cache "^5.0.0" -apollo-server-core@2.9.1: - version "2.9.1" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.1.tgz#ed876cd2f954dc3f4f1e735b997d4dbf29a629a5" - integrity sha512-ZWPGNdZv/SiPjfEU7Wwut9N9oAucGlbVT+XCnpUl93agvkg3fbeTCLYBbjAdSA0Q6opq0tvWVGzwXPLpx6jZcQ== +apollo-server-core@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.3.tgz#918f836c8215d371935c831c72d0840c7bf0250f" + integrity sha512-KQpOM3nAXdMqKVE0HHcOkH/EVhyDqFEKLNFlsyGHGOn9ujpI6RsltX+YpXRyAdbfQHpTk11v/IAo6XksWN+g1Q== dependencies: "@apollographql/apollo-tools" "^0.4.0" "@apollographql/graphql-playground-html" "1.6.24" "@types/graphql-upload" "^8.0.0" "@types/ws" "^6.0.0" - apollo-cache-control "0.8.2" - apollo-datasource "0.6.2" - apollo-engine-reporting "1.4.4" - apollo-server-caching "0.5.0" - apollo-server-env "2.4.2" - apollo-server-errors "2.3.2" - apollo-server-plugin-base "0.6.2" - apollo-server-types "0.2.2" - apollo-tracing "0.8.2" + apollo-cache-control "^0.8.4" + apollo-datasource "^0.6.3" + apollo-engine-reporting "^1.4.6" + apollo-server-caching "^0.5.0" + apollo-server-env "^2.4.3" + apollo-server-errors "^2.3.3" + apollo-server-plugin-base "^0.6.4" + apollo-server-types "^0.2.4" + apollo-tracing "^0.8.4" fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.10.1" + graphql-extensions "^0.10.3" graphql-tag "^2.9.2" graphql-tools "^4.0.0" graphql-upload "^8.0.2" @@ -1702,23 +1702,23 @@ apollo-server-core@2.9.1: subscriptions-transport-ws "^0.9.11" ws "^6.0.0" -apollo-server-env@2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-2.4.2.tgz#8549caa7c8f57af88aadad5c2a0bb7adbcc5f76e" - integrity sha512-Qyi8fP8CWsBRAKs0fawMFauJj03I6N3ncWcGaVTuDppYluo4zjV6LqHfZ+YPWOx6apBihFNZap19RAhSnSwJLg== +apollo-server-env@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-2.4.3.tgz#9bceedaae07eafb96becdfd478f8d92617d825d2" + integrity sha512-23R5Xo9OMYX0iyTu2/qT0EUb+AULCBriA9w8HDfMoChB8M+lFClqUkYtaTTHDfp6eoARLW8kDBhPOBavsvKAjA== dependencies: node-fetch "^2.1.2" util.promisify "^1.0.0" -apollo-server-errors@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.2.tgz#86bbd1ff8f0b5f16bfdcbb1760398928f9fce539" - integrity sha512-twVCP8tNHFzxOzU3jf84ppBFSvjvisZVWlgF82vwG+qEEUaAE5h5DVpeJbcI1vRW4VQPuFV+B+FIsnlweFKqtQ== +apollo-server-errors@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.3.tgz#83763b00352c10dc68fbb0d41744ade66de549ff" + integrity sha512-MO4oJ129vuCcbqwr5ZwgxqGGiLz3hCyowz0bstUF7MR+vNGe4oe3DWajC9lv4CxrhcqUHQOeOPViOdIo1IxE3g== -apollo-server-express@2.9.1, apollo-server-express@^2.9.0: - version "2.9.1" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.1.tgz#9a8cb7fba579e68ddfa1953dfd066b751bca32f0" - integrity sha512-3mmuojt9s9Gyqdf8fbdKtbw23UFYrtVQtTNASgVW8zCabZqs2WjYnijMRf1aL4u9VSl+BFMOZUPMYaeBX+u38w== +apollo-server-express@^2.9.0, apollo-server-express@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.3.tgz#67573404030c2676be49a7bf97d423b8462e295c" + integrity sha512-Hkfs+ce6GqaoSzDOJs8Pj7W3YUjH0BzGglo5HMsOXOnjPZ0pJE9v8fmK76rlkITLw7GjvIq5GKlafymC31FMBw== dependencies: "@apollographql/graphql-playground-html" "1.6.24" "@types/accepts" "^1.3.5" @@ -1726,57 +1726,58 @@ apollo-server-express@2.9.1, apollo-server-express@^2.9.0: "@types/cors" "^2.8.4" "@types/express" "4.17.1" accepts "^1.3.5" - apollo-server-core "2.9.1" - apollo-server-types "0.2.2" + apollo-server-core "^2.9.3" + apollo-server-types "^0.2.4" body-parser "^1.18.3" cors "^2.8.4" + express "^4.17.1" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" parseurl "^1.3.2" subscriptions-transport-ws "^0.9.16" type-is "^1.6.16" -apollo-server-plugin-base@0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.2.tgz#807e734e130c6750db680a58cd0e572cc0794184" - integrity sha512-f7grbfoI5fPxGJDmrvG0ulWq8vFHwvJSUrcEChhiUCSMFZlpBil/1TSaxJRESiQqnoZ9s5WrVhzuwejxODGMYw== +apollo-server-plugin-base@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.4.tgz#63ea4fd0bbb6c4510bc8d0d2ad0a0684c8d0da8c" + integrity sha512-4rY+cBAIpQomGWYBtk8hHkLQWHrh5hgIBPQqmhXh00YFdcY+Ob1/cU2/2iqTcIzhtcaezsc8OZ63au6ahSBQqg== dependencies: - apollo-server-types "0.2.2" + apollo-server-types "^0.2.4" -apollo-server-testing@~2.9.1: - version "2.9.1" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.1.tgz#29d2524e84722a1319d9c1524b4f9d44379d6a49" - integrity sha512-TzlHIYNZgF1OkGji/ew3zPxboifvA9aGXDwWJFu54o1400svH0Uh5L7TMhsTZ8F992syQUsUuI+KKMOFNg73+w== +apollo-server-testing@~2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.3.tgz#38a86b5fa0bce57f8ec4fb581e5419437178b3e2" + integrity sha512-n2bIcVXQNFzr84FZK1S0o4PFqwb1pPuIg/fymjPYjtFP2OHmLLvGRm+KaXhUjxEAUh+/9zAQLhmgx+p6GMUAhA== dependencies: - apollo-server-core "2.9.1" + apollo-server-core "^2.9.3" -apollo-server-types@0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.2.tgz#c26ff57ca0b45d67dfd72312094097e2b1c28980" - integrity sha512-/G4yXUF4Kc6PVCIF12r+oB8AXkE4UVnJoyZHeHiPeDpXklrjwIAtov2WM2mTcSZuZe1EuEkeDci4+tj5zFD39Q== +apollo-server-types@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.4.tgz#28864900ffc7f9711a859297c143a833fdb6aa43" + integrity sha512-G4FvBVgGQcTW6ZBS2+hvcDQkSfdOIKV+cHADduXA275v+5zl42g+bCaGd/hCCKTDRjmQvObLiMxH/BJ6pDMQgA== dependencies: - apollo-engine-reporting-protobuf "0.4.0" - apollo-server-caching "0.5.0" - apollo-server-env "2.4.2" + apollo-engine-reporting-protobuf "^0.4.0" + apollo-server-caching "^0.5.0" + apollo-server-env "^2.4.3" -apollo-server@~2.9.1: - version "2.9.1" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.1.tgz#16ff443d43ea38f72fe20adea0803c46037b2b3b" - integrity sha512-iCGoRBOvwTUkDz6Nq/rKguMyhDiQdL3VneF0GTjBGrelTIp3YTIxk/qBFkIr2Chtm9ZZYkS6o+ZldUnxYFKg7A== +apollo-server@~2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.9.3.tgz#2a79fcee25da0b0673eb70d73839c40c3c4b8cca" + integrity sha512-JQoeseSo3yOBu3WJzju0NTreoqYckNILybgXNUOhdurE55VFpZ8dsBEO6nMfdO2y1A70W14mnnVWCBEm+1rE8w== dependencies: - apollo-server-core "2.9.1" - apollo-server-express "2.9.1" + apollo-server-core "^2.9.3" + apollo-server-express "^2.9.3" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.8.2.tgz#2d1ebef434c4e2803f9a3adfc7d2409690b3c378" - integrity sha512-4SVxHZkKZX/7E6/4hAvEJXdHm+1BjQqtgEkv3ywyiVXoaKn0YNJL8BVIOI4GAt0qoc3KzT9MDJ1nf+SurUFjLQ== +apollo-tracing@^0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.8.4.tgz#0117820c3f0ad3aa6daf7bf13ddbb923cbefa6de" + integrity sha512-DjbFW0IvHicSlTVG+vK+1WINfBMRCdPPHJSW/j65JMir9Oe56WGeqL8qz8hptdUUmLYEb+azvcyyGsJsiR3zpQ== dependencies: - apollo-server-env "2.4.2" - graphql-extensions "0.10.1" + apollo-server-env "^2.4.3" + graphql-extensions "^0.10.3" apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2: version "1.3.2" @@ -2687,13 +2688,12 @@ create-error-class@^3.0.0: dependencies: capture-stack-trace "^1.0.0" -cross-env@~5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2" - integrity sha512-jtdNFfFW1hB7sMhr/H6rW1Z45LFqyI431m3qU6bFXcQ3Eh7LtBuG3h74o7ohHZ3crrRkkqHlo4jYHFPcjroANg== +cross-env@~5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.1.tgz#b2c76c1ca7add66dc874d11798466094f551b34d" + integrity sha512-1yHhtcfAd1r4nwQgknowuUNfIT9E8dOMMspC36g45dN+iD1blloi7xp8X/xAIDnjHWyt1uQ8PHk2fkNaym7soQ== dependencies: cross-spawn "^6.0.5" - is-windows "^1.0.0" cross-fetch@2.2.2: version "2.2.2" @@ -3311,12 +3311,12 @@ eslint-module-utils@^2.4.0: debug "^2.6.8" pkg-dir "^2.0.0" -eslint-plugin-es@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-1.4.0.tgz#475f65bb20c993fc10e8c8fe77d1d60068072da6" - integrity sha512-XfFmgFdIUDgvaRAlaXUkxrRg5JSADoRC8IkKLc/cISeR3yHVMefFHQZpcyXXEUUPHfy5DwviBcrfqlyqEwlQVw== +eslint-plugin-es@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-1.4.1.tgz#12acae0f4953e76ba444bfd1b2271081ac620998" + integrity sha512-5fa/gR2yR3NxQf+UXkeLeP8FBBl6tSgdrAz1+cF84v1FMM4twGwQoqTnn+QxFLcPOrF4pdKEJKDB/q9GoyJrCA== dependencies: - eslint-utils "^1.3.0" + eslint-utils "^1.4.2" regexpp "^2.0.1" eslint-plugin-import@~2.18.2: @@ -3336,20 +3336,20 @@ eslint-plugin-import@~2.18.2: read-pkg-up "^2.0.0" resolve "^1.11.0" -eslint-plugin-jest@~22.15.2: - version "22.15.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.15.2.tgz#e3c10d9391f787744e31566f69ebb70c3a98e398" - integrity sha512-p4NME9TgXIt+KgpxcXyNBvO30ZKxwFAO1dJZBc2OGfDnXVEtPwEyNs95GSr6RIE3xLHdjd8ngDdE2icRRXrbxg== +eslint-plugin-jest@~22.16.0: + version "22.16.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.16.0.tgz#30c4e0e9dc331beb2e7369b70dd1363690c1ce05" + integrity sha512-eBtSCDhO1k7g3sULX/fuRK+upFQ7s548rrBtxDyM1fSoY7dTWp/wICjrJcDZKVsW7tsFfH22SG+ZaxG5BZodIg== dependencies: "@typescript-eslint/experimental-utils" "^1.13.0" -eslint-plugin-node@~9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-9.1.0.tgz#f2fd88509a31ec69db6e9606d76dabc5adc1b91a" - integrity sha512-ZwQYGm6EoV2cfLpE1wxJWsfnKUIXfM/KM09/TlorkukgCAwmkgajEJnPCmyzoFPQQkmvo5DrW/nyKutNIw36Mw== +eslint-plugin-node@~9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-9.2.0.tgz#b1911f111002d366c5954a6d96d3cd5bf2a3036a" + integrity sha512-2abNmzAH/JpxI4gEOwd6K8wZIodK3BmHbTxz4s79OIYwwIt2gkpEXlAouJXu4H1c9ySTnRso0tsuthSOZbUMlA== dependencies: - eslint-plugin-es "^1.4.0" - eslint-utils "^1.3.1" + eslint-plugin-es "^1.4.1" + eslint-utils "^1.4.2" ignore "^5.1.1" minimatch "^3.0.4" resolve "^1.10.1" @@ -3388,7 +3388,7 @@ eslint-scope@^5.0.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-utils@^1.3.0, eslint-utils@^1.3.1, eslint-utils@^1.4.2: +eslint-utils@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab" integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q== @@ -3400,10 +3400,10 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== -eslint@~6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.2.2.tgz#03298280e7750d81fcd31431f3d333e43d93f24f" - integrity sha512-mf0elOkxHbdyGX1IJEUsNBzCDdyoUgljF3rRlgfyYh0pwGnreLc0jjD6ZuleOibjmnUWZLY2eXwSooeOgGJ2jw== +eslint@~6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.3.0.tgz#1f1a902f67bfd4c354e7288b81e40654d927eb6a" + integrity sha512-ZvZTKaqDue+N8Y9g0kp6UPZtS4FSY3qARxBs7p4f0H0iof381XHduqVerFWtK8DPtKmemqbqCFENWSQgPR/Gow== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" @@ -4077,14 +4077,14 @@ graphql-custom-directives@~0.2.14: moment "^2.22.2" numeral "^2.0.6" -graphql-extensions@0.10.1: - version "0.10.1" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.1.tgz#9e1abd502f3f802a7ab60c3a28d2fe705e53d4cb" - integrity sha512-RIlC/jgBKZ/qyrb+cAu7oJVYLC0dJh6al35tNy8dnqE9JImNucy/gFWVOPW7q3fAaXqCHzbBEtdb+ws1L43LgQ== +graphql-extensions@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.3.tgz#9e37f3bd26309c40b03a0be0e63e02b3f99d52ea" + integrity sha512-kwU0gUe+Qdfr8iZYT91qrPSwQNgPhB/ClF1m1LEPdxlptk5FhFmjpxAcbMZ8q7j0kjfnbp2IeV1OhRDCEPqz2w== dependencies: "@apollographql/apollo-tools" "^0.4.0" - apollo-server-env "2.4.2" - apollo-server-types "0.2.2" + apollo-server-env "^2.4.3" + apollo-server-types "^0.2.4" graphql-import@0.7.1: version "0.7.1" @@ -4180,10 +4180,10 @@ graphql-upload@^8.0.2: http-errors "^1.7.2" object-path "^0.11.4" -graphql@^14.2.1, graphql@^14.5.3: - version "14.5.3" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0" - integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg== +graphql@^14.2.1, graphql@^14.5.4: + version "14.5.4" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.4.tgz#b33fe957854e90c10d4c07c7d26b6c8e9f159a13" + integrity sha512-dPLvHoxy5m9FrkqWczPPRnH0X80CyvRE6e7Fa5AWEqEAzg9LpxHvKh24po/482E6VWHigOkAmb4xCp6P9yT9gw== dependencies: iterall "^1.2.2" @@ -4875,7 +4875,7 @@ is-valid-path@0.1.1: dependencies: is-invalid-path "^0.1.0" -is-windows@^1.0.0, is-windows@^1.0.2: +is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== @@ -6175,12 +6175,12 @@ neo-async@^2.6.0: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== -neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.5: - version "1.7.5" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4" - integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw== +neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.6.tgz#eccb135a71eba9048c68717444593a6424cffc49" + integrity sha512-6c3ALO3vYDfUqNoCy8OFzq+fQ7q/ab3LCuJrmm8P04M7RmyRCCnUtJ8IzSTGbiZvyhcehGK+azNDAEJhxPV/hA== dependencies: - "@babel/runtime" "^7.4.4" + "@babel/runtime" "^7.5.5" text-encoding-utf-8 "^1.0.2" uri-js "^4.2.2" diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 2c8b848b4..180353328 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -121,7 +121,11 @@ Given('somebody reported the following posts:', table => { cy.factory() .create('User', submitter) .authenticateAs(submitter) - .create('Report', { + .mutate(`mutation($id: ID!, $description: String!) { + report(description: $description, id: $id) { + id + } + }`, { id, description: 'Offensive content' }) diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 4387feeaf..f7ab18707 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -1,6 +1,5 @@ import { Given, When, Then } from "cypress-cucumber-preprocessor/steps"; import helpers from "../../support/helpers"; -import slugify from "slug"; /* global cy */ @@ -11,6 +10,7 @@ let loginCredentials = { password: "1234" }; const narratorParams = { + id: 'id-of-peter-pan', name: "Peter Pan", slug: "peter-pan", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg", @@ -28,40 +28,20 @@ Given("we have a selection of categories", () => { Given("we have a selection of tags and categories as well as posts", () => { cy.createCategories("cat12") .factory() - .authenticateAs(loginCredentials) .create("Tag", { id: "Ecology" }) .create("Tag", { id: "Nature" }) .create("Tag", { id: "Democracy" }); - const someAuthor = { - id: "authorId", - email: "author@example.org", - password: "1234" - }; - const yetAnotherAuthor = { - id: "yetAnotherAuthor", - email: "yet-another-author@example.org", - password: "1234" - }; + cy.factory() - .create("User", someAuthor) - .authenticateAs(someAuthor) - .create("Post", { id: "p0", categoryIds: ["cat12"] }) - .create("Post", { id: "p1", categoryIds: ["cat121"] }); + .create("User", { id: 'a1' }) + .create("Post", {authorId: 'a1', tagIds: [ "Ecology", "Nature", "Democracy" ], categoryIds: ["cat12"] }) + .create("Post", {authorId: 'a1', tagIds: [ "Nature", "Democracy" ], categoryIds: ["cat121"] }); + cy.factory() - .create("User", yetAnotherAuthor) - .authenticateAs(yetAnotherAuthor) - .create("Post", { id: "p2", categoryIds: ["cat12"] }); + .create("User", { id: 'a2'}) + .create("Post", { authorId: 'a2', tagIds: ['Nature', 'Democracy'], categoryIds: ["cat12"] }); cy.factory() - .authenticateAs(loginCredentials) - .create("Post", { id: "p3", categoryIds: ["cat122"] }) - .relate("Post", "Tags", { from: "p0", to: "Ecology" }) - .relate("Post", "Tags", { from: "p0", to: "Nature" }) - .relate("Post", "Tags", { from: "p0", to: "Democracy" }) - .relate("Post", "Tags", { from: "p1", to: "Nature" }) - .relate("Post", "Tags", { from: "p1", to: "Democracy" }) - .relate("Post", "Tags", { from: "p2", to: "Nature" }) - .relate("Post", "Tags", { from: "p2", to: "Democracy" }) - .relate("Post", "Tags", { from: "p3", to: "Democracy" }); + .create("Post", { authorId: narratorParams.id, tagIds: ['Democracy'], categoryIds: ["cat122"] }) }); Given("we have the following user accounts:", table => { @@ -158,40 +138,28 @@ When("I press {string}", label => { cy.contains(label).click(); }); +Given("we have this user in our database:", table => { + const [firstRow] = table.hashes() + cy.factory().create('User', firstRow) +}) + Given("we have the following posts in our database:", table => { - table.hashes().forEach(({ Author, ...postAttributes }, i) => { - Author = Author || `author-${i}`; - const userAttributes = { - name: Author, - email: `${slugify(Author, { lower: true })}@example.org`, - password: "1234" - }; - postAttributes.deleted = Boolean(postAttributes.deleted); - const disabled = Boolean(postAttributes.disabled); - postAttributes.categoryIds = [`cat${i}`]; - postAttributes; - cy.factory() - .create("User", userAttributes) - .authenticateAs(userAttributes) - .create("Category", { - id: `cat${i}`, - name: "Just For Fun", - slug: `just-for-fun-${i}`, - icon: "smile" - }) - .create("Post", postAttributes); - if (disabled) { - const moderatorParams = { - email: "moderator@example.org", - role: "moderator", - password: "1234" - }; - cy.factory() - .create("User", moderatorParams) - .authenticateAs(moderatorParams) - .mutate("mutation($id: ID!) { disable(id: $id) }", postAttributes); + cy.factory().create('Category', { + id: `cat-456`, + name: "Just For Fun", + slug: `just-for-fun`, + icon: "smile" + }) + + table.hashes().forEach(({ ...postAttributes }, i) => { + postAttributes = { + ...postAttributes, + deleted: Boolean(postAttributes.deleted), + disabled: Boolean(postAttributes.disabled), + categoryIds: ['cat-456'] } - }); + cy.factory().create("Post", postAttributes); + }) }); Then("I see a success message:", message => { @@ -210,11 +178,11 @@ When( ); Given("I previously created a post", () => { + lastPost.authorId = narratorParams.id lastPost.title = "previously created post"; lastPost.content = "with some content"; - lastPost.categoryIds = "cat0"; + lastPost.categoryIds = ["cat0"]; cy.factory() - .authenticateAs(loginCredentials) .create("Post", lastPost); }); @@ -357,7 +325,7 @@ When("mention {string} in the text", mention => { }); Then("the notification gets marked as read", () => { - cy.get(".post.createdAt") + cy.get(".notifications-menu-popover .notification") .first() .should("have.class", "read"); }); @@ -422,11 +390,7 @@ Given("I follow the user {string}", name => { Given('"Spammy Spammer" wrote a post {string}', title => { cy.createCategories("cat21") .factory() - .authenticateAs({ - email: "spammy-spammer@example.org", - password: "1234" - }) - .create("Post", { title, categoryIds: ["cat21"] }); + .create("Post", { authorId: 'annoying-user', title, categoryIds: ["cat21"] }); }); Then("the list of posts of this user is empty", () => { @@ -445,8 +409,7 @@ Then("nobody is following the user profile anymore", () => { Given("I wrote a post {string}", title => { cy.createCategories(`cat213`, title) .factory() - .authenticateAs(loginCredentials) - .create("Post", { title, categoryIds: ["cat213"] }); + .create("Post", { authorId: narratorParams.id, title, categoryIds: ["cat213"] }); }); When("I block the user {string}", name => { diff --git a/cypress/integration/moderation/ReportContent.feature b/cypress/integration/moderation/ReportContent.feature index 62fb4f421..0181dc7a6 100644 --- a/cypress/integration/moderation/ReportContent.feature +++ b/cypress/integration/moderation/ReportContent.feature @@ -8,10 +8,12 @@ Feature: Report and Moderate So I can look into it and decide what to do Background: + Given we have this user in our database: + | id | name | + | u67 | David Irving| Given we have the following posts in our database: - | Author | id | title | content | - | David Irving | p1 | The Truth about the Holocaust | It never existed! | - + | authorId | id | title | content | + | u67 | p1 | The Truth about the Holocaust | It never existed! | Scenario Outline: Report a post from various pages Given I am logged in with a "user" role diff --git a/cypress/integration/user_profile/blocked-users/Blocking.feature b/cypress/integration/user_profile/blocked-users/Blocking.feature index 3ce4fd6c4..9b27f82a3 100644 --- a/cypress/integration/user_profile/blocked-users/Blocking.feature +++ b/cypress/integration/user_profile/blocked-users/Blocking.feature @@ -7,7 +7,6 @@ Feature: Block a User Given I have a user account And there is an annoying user called "Spammy Spammer" And I am logged in - And we have a selection of categories Scenario: Block a user Given I am on the profile page of the annoying user @@ -26,6 +25,9 @@ Feature: Block a User And nobody is following the user profile anymore Scenario: Posts of blocked users are filtered from search results + Given we have the following posts in our database: + | id | title | content | + | im-not-blocked | Post that should be seen | cause I'm not blocked | Given "Spammy Spammer" wrote a post "Spam Spam Spam" When I search for "Spam" Then I should see the following posts in the select dropdown: @@ -35,3 +37,7 @@ Feature: Block a User And I refresh the page And I search for "Spam" Then the search has no results + But I search for "not blocked" + Then I should see the following posts in the select dropdown: + | title | + | Post that should be seen | diff --git a/deployment/human-connection/deployment-neo4j.yaml b/deployment/human-connection/deployment-neo4j.yaml index 297f4b551..593f87d2b 100644 --- a/deployment/human-connection/deployment-neo4j.yaml +++ b/deployment/human-connection/deployment-neo4j.yaml @@ -30,17 +30,6 @@ memory: "1G" limits: memory: "2G" - env: - - name: NEO4J_apoc_import_file_enabled - value: "true" - - name: NEO4J_dbms_memory_pagecache_size - value: "490M" - - name: NEO4J_dbms_memory_heap_max__size - value: "500M" - - name: NEO4J_dbms_memory_heap_initial__size - value: "500M" - - name: NEO4J_dbms_security_procedures_unrestricted - value: "algo.*,apoc.*" envFrom: - configMapRef: name: configmap diff --git a/deployment/human-connection/templates/configmap.template.yaml b/deployment/human-connection/templates/configmap.template.yaml index 1bd227af0..07c0bb53f 100644 --- a/deployment/human-connection/templates/configmap.template.yaml +++ b/deployment/human-connection/templates/configmap.template.yaml @@ -9,6 +9,11 @@ NEO4J_URI: "bolt://nitro-neo4j.human-connection:7687" NEO4J_AUTH: "none" CLIENT_URI: "https://nitro-staging.human-connection.org" + NEO4J_apoc_import_file_enabled: "true" + NEO4J_dbms_memory_pagecache_size: "490M" + NEO4J_dbms_memory_heap_max__size: "500M" + NEO4J_dbms_memory_heap_initial__size: "500M" + NEO4J_dbms_security_procedures_unrestricted: "algo.*,apoc.*" SENTRY_DSN_WEBAPP: "" SENTRY_DSN_BACKEND: "" COMMIT: "" diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions/contributions.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions/contributions.cql index 2c87210af..8195f9ddd 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions/contributions.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions/contributions.cql @@ -137,8 +137,8 @@ p.contentExcerpt = post.contentExcerpt, p.visibility = toLower(post.visibility), p.createdAt = post.createdAt.`$date`, p.updatedAt = post.updatedAt.`$date`, -p.deleted = COALESCE(post.deleted,false), -p.disabled = NOT post.isEnabled +p.deleted = COALESCE(post.deleted, false), +p.disabled = COALESCE(NOT post.isEnabled, false) WITH p, post MATCH (u:User {id: post.userId}) MERGE (u)-[:WROTE]->(p) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 41a88970f..32ed3ab92 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -54,6 +54,7 @@ services: - SMTP_HOST=mailserver - SMTP_PORT=25 - SMTP_IGNORE_TLS=true + - "DEBUG=${DEBUG}" neo4j: environment: - NEO4J_AUTH=none diff --git a/package.json b/package.json index f9e564e6b..ccaa3b594 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "devDependencies": { "bcryptjs": "^2.4.3", "codecov": "^3.5.0", - "cross-env": "^5.2.0", + "cross-env": "^5.2.1", "cypress": "^3.4.1", "cypress-cucumber-preprocessor": "^1.16.0", "cypress-file-upload": "^3.3.3", @@ -29,7 +29,7 @@ "dotenv": "^8.1.0", "faker": "Marak/faker.js#master", "graphql-request": "^1.8.2", - "neo4j-driver": "^1.7.5", + "neo4j-driver": "^1.7.6", "neode": "^0.3.2", "npm-run-all": "^4.1.5", "slug": "^1.1.0" diff --git a/webapp/assets/styles/imports/_tooltip.scss b/webapp/assets/styles/imports/_tooltip.scss index e3032cb56..f3f51f576 100644 --- a/webapp/assets/styles/imports/_tooltip.scss +++ b/webapp/assets/styles/imports/_tooltip.scss @@ -83,6 +83,8 @@ border-radius: $border-radius-base; padding: $space-x-small $space-small; box-shadow: $box-shadow-large; + overflow: auto; + max-height: 73.5vh; // magic! fully visible on mobile, no scrolling on wide screen } @include arrow(5px, "tooltip", $background-color-inverse-soft); diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index 11652fad0..5c4964688 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -50,6 +50,11 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1); background: #fff; } +body.dropdown-open { + height: 100vh; + overflow: hidden; +} + blockquote { display: block; padding: 15px 20px 15px 45px; @@ -164,3 +169,9 @@ hr { .v-popover.open .trigger a { color: $text-color-link-active; } + +.hyphenate-text { + hyphens: auto; + overflow-wrap: break-word; + word-wrap: break-word; +} diff --git a/webapp/components/Comment.spec.js b/webapp/components/Comment.spec.js index b9be448e4..23e72f9d6 100644 --- a/webapp/components/Comment.spec.js +++ b/webapp/components/Comment.spec.js @@ -25,11 +25,16 @@ describe('Comment.vue', () => { success: jest.fn(), error: jest.fn(), }, + $i18n: { + locale: () => 'en', + }, $filters: { truncate: a => a, }, $apollo: { - mutate: jest.fn().mockResolvedValue(), + mutate: jest.fn().mockResolvedValue({ + data: { DeleteComment: { id: 'it-is-the-deleted-comment' } }, + }), }, } getters = { @@ -113,24 +118,22 @@ describe('Comment.vue', () => { }) describe('deletion of Comment from List by invoking "deleteCommentCallback()"', () => { - beforeEach(() => { - wrapper.vm.deleteCommentCallback() + beforeEach(async () => { + await wrapper.vm.deleteCommentCallback() }) - describe('after timeout', () => { - beforeEach(jest.runAllTimers) + it('emits "deleteComment"', () => { + expect(wrapper.emitted('deleteComment')).toEqual([ + [{ id: 'it-is-the-deleted-comment' }], + ]) + }) - it('emits "deleteComment"', () => { - expect(wrapper.emitted().deleteComment.length).toBe(1) - }) + it('does call mutation', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) + }) - it('does call mutation', () => { - expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) - }) - - it('mutation is successful', () => { - expect(mocks.$toast.success).toHaveBeenCalledTimes(1) - }) + it('mutation is successful', () => { + expect(mocks.$toast.success).toHaveBeenCalledTimes(1) }) }) }) diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 74b3f893c..e4df37693 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -68,7 +68,6 @@ import ContentMenu from '~/components/ContentMenu' import ContentViewer from '~/components/Editor/ContentViewer' import HcEditCommentForm from '~/components/EditCommentForm/EditCommentForm' import CommentMutations from '~/graphql/CommentMutations' -import PostQuery from '~/graphql/PostQuery' export default { data: function() { @@ -143,26 +142,14 @@ export default { }, async deleteCommentCallback() { try { - await this.$apollo.mutate({ + const { + data: { DeleteComment }, + } = await this.$apollo.mutate({ mutation: CommentMutations(this.$i18n).DeleteComment, variables: { id: this.comment.id }, - update: async store => { - const data = await store.readQuery({ - query: PostQuery(this.$i18n), - variables: { id: this.post.id }, - }) - - const index = data.Post[0].comments.findIndex( - deletedComment => deletedComment.id === this.comment.id, - ) - if (index !== -1) { - data.Post[0].comments.splice(index, 1) - } - await store.writeQuery({ query: PostQuery(this.$i18n), data }) - }, }) this.$toast.success(this.$t(`delete.comment.success`)) - this.$emit('deleteComment') + this.$emit('deleteComment', DeleteComment) } catch (err) { this.$toast.error(err.message) } diff --git a/webapp/components/CommentForm/CommentForm.spec.js b/webapp/components/CommentForm/CommentForm.spec.js index 07069e2d5..8aab9bf65 100644 --- a/webapp/components/CommentForm/CommentForm.spec.js +++ b/webapp/components/CommentForm/CommentForm.spec.js @@ -20,6 +20,9 @@ describe('CommentForm.vue', () => { beforeEach(() => { mocks = { $t: jest.fn(), + $i18n: { + locale: () => 'en', + }, $apollo: { mutate: jest .fn() diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index 2ae670bf4..710607b94 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -18,11 +18,11 @@