diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js index f05970650..91605ec9f 100644 --- a/backend/src/db/graphql/authentications.js +++ b/backend/src/db/graphql/authentications.js @@ -19,6 +19,7 @@ export const signupVerificationMutation = gql` nonce: $nonce termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion ) { + id slug } } diff --git a/backend/src/db/graphql/posts.js b/backend/src/db/graphql/posts.js index 237446d41..2669d6f24 100644 --- a/backend/src/db/graphql/posts.js +++ b/backend/src/db/graphql/posts.js @@ -2,15 +2,87 @@ import gql from 'graphql-tag' // ------ mutations -export const createPostMutation = gql` - mutation ($id: ID, $title: String!, $slug: String, $content: String!, $categoryIds: [ID]!) { - CreatePost(id: $id, title: $title, slug: $slug, content: $content, categoryIds: $categoryIds) { - id - slug +export const createPostMutation = () => { + return gql` + mutation ( + $id: ID + $title: String! + $slug: String + $content: String! + $categoryIds: [ID] + $groupId: ID + ) { + CreatePost( + id: $id + title: $title + slug: $slug + content: $content + categoryIds: $categoryIds + groupId: $groupId + ) { + id + slug + title + content + } } - } -` + ` +} // ------ queries -// fill queries in here +export const postQuery = () => { + return gql` + query Post($id: ID!) { + Post(id: $id) { + id + title + content + } + } + ` +} + +export const filterPosts = () => { + return gql` + query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) { + Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + id + title + content + } + } + ` +} + +export const profilePagePosts = () => { + return gql` + query profilePagePosts( + $filter: _PostFilter + $first: Int + $offset: Int + $orderBy: [_PostOrdering] + ) { + profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { + id + title + content + } + } + ` +} + +export const searchPosts = () => { + return gql` + query ($query: String!, $firstPosts: Int, $postsOffset: Int) { + searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) { + postCount + posts { + id + title + content + } + } + } + ` +} diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index d693f9905..242b3a856 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -709,7 +709,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] await Promise.all([ mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p2', title: `Nature Philosophy Yoga`, @@ -718,7 +718,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p7', title: 'This is post #7', @@ -727,7 +727,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p8', image: faker.image.unsplash.nature(), @@ -737,7 +737,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { id: 'p12', title: 'This is post #12', diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index be03e979e..d77363c29 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -1,4 +1,4 @@ -import { rule, shield, deny, allow, or } from 'graphql-shield' +import { rule, shield, deny, allow, or, and } from 'graphql-shield' import { getNeode } from '../db/neo4j' import CONFIG from '../config' import { validateInviteCode } from '../schema/resolvers/transactions/inviteCodes' @@ -221,6 +221,34 @@ const isAllowedToLeaveGroup = rule({ } }) +const isMemberOfGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId } = args + if (!groupId) return true + const userId = user.id + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (User {id: $userId})-[membership:MEMBER_OF]->(Group {id: $groupId}) + RETURN membership.role AS role + `, + { groupId, userId }, + ) + return transactionResponse.records.map((record) => record.get('role'))[0] + }) + try { + const role = await readTxPromise + return ['usual', 'admin', 'owner'].includes(role) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -271,8 +299,6 @@ export default shield( { Query: { '*': deny, - findPosts: allow, - findUsers: allow, searchResults: allow, searchPosts: allow, searchUsers: allow, @@ -316,7 +342,7 @@ export default shield( JoinGroup: isAllowedToJoinGroup, LeaveGroup: isAllowedToLeaveGroup, ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, - CreatePost: isAuthenticated, + CreatePost: and(isAuthenticated, isMemberOfGroup), UpdatePost: isAuthor, DeletePost: isAuthor, fileReport: isAuthenticated, diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index c600d0e52..0b022fb53 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -366,7 +366,7 @@ describe('slugifyMiddleware', () => { it('generates a slug based on title', async () => { await expect( mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables, }), ).resolves.toMatchObject({ @@ -382,7 +382,7 @@ describe('slugifyMiddleware', () => { it('generates a slug based on given slug', async () => { await expect( mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { ...variables, slug: 'the-post', @@ -417,7 +417,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { ...variables, title: 'Pre-existing post', @@ -440,7 +440,7 @@ describe('slugifyMiddleware', () => { try { await expect( mutate({ - mutation: createPostMutation, + mutation: createPostMutation(), variables: { ...variables, title: 'Pre-existing post', diff --git a/backend/src/middleware/userInteractions.js b/backend/src/middleware/userInteractions.js index 553aefe78..62e8e47f7 100644 --- a/backend/src/middleware/userInteractions.js +++ b/backend/src/middleware/userInteractions.js @@ -31,7 +31,7 @@ const setPostCounter = async (postId, relation, context) => { } const userClickedPost = async (resolve, root, args, context, info) => { - if (args.id) { + if (args.id && context.user) { await setPostCounter(args.id, 'CLICKED', context) } return resolve(root, args, context, info) diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 2617bd43e..5e22bd743 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -261,8 +261,15 @@ export default { const leaveGroupCypher = ` MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) DELETE membership + WITH member, group + OPTIONAL MATCH (p:Post)-[:IN]->(group) + WHERE NOT group.groupType = 'public' + WITH member, group, collect(p) AS posts + FOREACH (post IN posts | + MERGE (member)-[:CANNOT_SEE]->(post)) RETURN member {.*, myRoleInGroup: NULL} ` + const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId }) const [member] = await transactionResponse.records.map((record) => record.get('member')) return member @@ -279,8 +286,22 @@ export default { const { groupId, userId, roleInGroup } = params const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let postRestrictionCypher = '' + if (['usual', 'admin', 'owner'].includes(roleInGroup)) { + postRestrictionCypher = ` + WITH group, member, membership + FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] | + DELETE restriction)` + } else { + postRestrictionCypher = ` + WITH group, member, membership + FOREACH (post IN [(p:Post)-[:IN]->(group) | p] | + MERGE (member)-[:CANNOT_SEE]->(post))` + } + const joinGroupCypher = ` - MATCH (member:User {id: $userId}), (group:Group {id: $groupId}) + MATCH (member:User {id: $userId}) + MATCH (group:Group {id: $groupId}) MERGE (member)-[membership:MEMBER_OF]->(group) ON CREATE SET membership.createdAt = toString(datetime()), @@ -289,8 +310,10 @@ export default { ON MATCH SET membership.updatedAt = toString(datetime()), membership.role = $roleInGroup + ${postRestrictionCypher} RETURN member {.*, myRoleInGroup: membership.role} ` + const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId, @@ -313,6 +336,7 @@ export default { undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'], hasMany: { categories: '-[:CATEGORIZED]->(related:Category)', + posts: '<-[:IN]-(related:Post)', }, hasOne: { avatar: '-[:AVATAR_IMAGE]->(related:Image)', diff --git a/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js new file mode 100644 index 000000000..73dfaad91 --- /dev/null +++ b/backend/src/schema/resolvers/helpers/filterInvisiblePosts.js @@ -0,0 +1,47 @@ +import { mergeWith, isArray } from 'lodash' + +const getInvisiblePosts = async (context) => { + const session = context.driver.session() + const readTxResultPromise = await session.readTransaction(async (transaction) => { + let cypher = '' + const { user } = context + if (user && user.id) { + cypher = ` + MATCH (post:Post)<-[:CANNOT_SEE]-(user:User { id: $userId }) + RETURN collect(post.id) AS invisiblePostIds` + } else { + cypher = ` + MATCH (post:Post)-[:IN]->(group:Group) + WHERE NOT group.groupType = 'public' + RETURN collect(post.id) AS invisiblePostIds` + } + const invisiblePostIdsResponse = await transaction.run(cypher, { + userId: user ? user.id : null, + }) + return invisiblePostIdsResponse.records.map((record) => record.get('invisiblePostIds')) + }) + try { + const [invisiblePostIds] = readTxResultPromise + return invisiblePostIds + } finally { + session.close() + } +} + +export const filterInvisiblePosts = async (params, context) => { + const invisiblePostIds = await getInvisiblePosts(context) + if (!invisiblePostIds.length) return params + + params.filter = mergeWith( + params.filter, + { + id_not_in: invisiblePostIds, + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue) + } + }, + ) + return params +} diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 97230715f..78515e641 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -5,6 +5,7 @@ import { UserInputError } from 'apollo-server' import { mergeImage, deleteImage } from './images/images' import Resolver from './helpers/Resolver' import { filterForMutedUsers } from './helpers/filterForMutedUsers' +import { filterInvisiblePosts } from './helpers/filterInvisiblePosts' import CONFIG from '../../config' const maintainPinnedPosts = (params) => { @@ -20,15 +21,13 @@ const maintainPinnedPosts = (params) => { export default { Query: { Post: async (object, params, context, resolveInfo) => { + params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) params = await maintainPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo) }, - findPosts: async (object, params, context, resolveInfo) => { - params = await filterForMutedUsers(params, context) - return neo4jgraphql(object, params, context, resolveInfo) - }, profilePagePosts: async (object, params, context, resolveInfo) => { + params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) return neo4jgraphql(object, params, context, resolveInfo) }, @@ -77,13 +76,37 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds } = params + const { categoryIds, groupId } = params const { image: imageInput } = params delete params.categoryIds delete params.image + delete params.groupId params.id = params.id || uuid() const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let groupCypher = '' + if (groupId) { + groupCypher = ` + WITH post MATCH (group:Group { id: $groupId }) + MERGE (post)-[:IN]->(group)` + const groupTypeResponse = await transaction.run( + ` + MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`, + { groupId }, + ) + const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType')) + if (groupType !== 'public') + groupCypher += ` + WITH post, group + MATCH (user:User)-[membership:MEMBER_OF]->(group) + WHERE group.groupType IN ['closed', 'hidden'] + AND membership.role IN ['usual', 'admin', 'owner'] + WITH post, collect(user.id) AS userIds + OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds + FOREACH (user IN nodes(path) | + MERGE (user)-[:CANNOT_SEE]->(post) + )` + } const categoriesCypher = CONFIG.CATEGORIES_ACTIVE && categoryIds ? `WITH post @@ -103,9 +126,10 @@ export default { MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) ${categoriesCypher} + ${groupCypher} RETURN post {.*} `, - { userId: context.user.id, params, categoryIds }, + { userId: context.user.id, categoryIds, groupId, params }, ) const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) if (imageInput) { @@ -367,6 +391,7 @@ export default { author: '<-[:WROTE]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)', image: '-[:HERO_IMAGE]->(related:Image)', + group: '-[:IN]->(related:Group)', }, count: { commentsCount: diff --git a/backend/src/schema/resolvers/postsInGroups.spec.js b/backend/src/schema/resolvers/postsInGroups.spec.js new file mode 100644 index 000000000..d17c928ec --- /dev/null +++ b/backend/src/schema/resolvers/postsInGroups.spec.js @@ -0,0 +1,1516 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { + createGroupMutation, + changeGroupMemberRoleMutation, + leaveGroupMutation, +} from '../../db/graphql/groups' +import { + createPostMutation, + postQuery, + filterPosts, + profilePagePosts, + searchPosts, +} from '../../db/graphql/posts' +// eslint-disable-next-line no-unused-vars +import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' +import CONFIG from '../../config' +import { signupVerificationMutation } from '../../db/graphql/authentications' + +CONFIG.CATEGORIES_ACTIVE = false + +jest.mock('../../constants/groups', () => { + return { + __esModule: true, + DESCRIPTION_WITHOUT_HTML_LENGTH_MIN: 5, + } +}) + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let anyUser +let allGroupsUser +let pendingUser +let publicUser +let closedUser +let hiddenUser +let authenticatedUser +let newUser + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +describe('Posts in Groups', () => { + beforeAll(async () => { + anyUser = await Factory.build('user', { + id: 'any-user', + name: 'Any User', + about: 'I am just an ordinary user and do not belong to any group.', + }) + + allGroupsUser = await Factory.build('user', { + id: 'all-groups-user', + name: 'All Groups User', + about: 'I am a member of all groups.', + }) + pendingUser = await Factory.build('user', { + id: 'pending-user', + name: 'Pending User', + about: 'I am a pending member of all groups.', + }) + publicUser = await Factory.build('user', { + id: 'public-user', + name: 'Public User', + about: 'I am the owner of the public group.', + }) + + closedUser = await Factory.build('user', { + id: 'closed-user', + name: 'Private User', + about: 'I am the owner of the closed group.', + }) + + hiddenUser = await Factory.build('user', { + id: 'hidden-user', + name: 'Secret User', + about: 'I am the owner of the hidden group.', + }) + + authenticatedUser = await publicUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'public-group', + name: 'The Public Group', + about: 'The public group!', + description: 'Anyone can see the posts of this group.', + groupType: 'public', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'public-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'public-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'closed-group', + name: 'The Closed Group', + about: 'The closed group!', + description: 'Only members of this group can see the posts of this group.', + groupType: 'closed', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: createGroupMutation(), + variables: { + id: 'hidden-group', + name: 'The Hidden Group', + about: 'The hidden group!', + description: 'Only members of this group can see the posts of this group.', + groupType: 'hidden', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await anyUser.toJson() + await mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + }) + }) + + describe('creating posts in groups', () => { + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('throws an error for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a pubic group', + content: 'I am posting into a public group without being a member of the group', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a closed group without being a member of the group', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a hidden group without being a member of the group', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + }) + + describe('as a pending member of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('throws an error for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a pubic group', + content: 'I am posting into a public group with a pending membership', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a closed group with a pending membership', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + + it('throws an error for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post to a closed group', + content: 'I am posting into a hidden group with a pending membership', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + errors: expect.arrayContaining([expect.objectContaining({ message: 'Not Authorized!' })]), + }) + }) + }) + + describe('as a member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('creates a post for public groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + groupId: 'public-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + }, + errors: undefined, + }) + }) + + it('creates a post for closed groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + groupId: 'closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + }, + errors: undefined, + }) + }) + + it('creates a post for hidden groups', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + groupId: 'hidden-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('visibility of posts', () => { + describe('query post by ID', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + await Factory.build('emailAddress', { + email: 'new-user@example.org', + nonce: '12345', + verifiedAt: null, + }) + const result = await mutate({ + mutation: signupVerificationMutation, + variables: { + name: 'New User', + slug: 'new-user', + nonce: '12345', + password: '1234', + about: 'I am a new user!', + email: 'new-user@example.org', + termsAndConditionsAgreedVersion: '0.0.1', + }, + }) + newUser = result.data.SignupVerification + authenticatedUser = newUser + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows a post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('does not show a post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + + it('does not show a post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: [], + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows post of the public group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-public-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('shows post of a closed group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-closed-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + + it('shows post of a hidden group', async () => { + await expect( + query({ query: postQuery(), variables: { id: 'post-to-hidden-group' } }), + ).resolves.toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('filter posts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('profile page posts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the post of the public group and the post without group', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: profilePagePosts(), variables: {} }) + expect(result.data.profilePagePosts).toHaveLength(4) + expect(result).toMatchObject({ + data: { + profilePagePosts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('searchPosts', () => { + describe('without authentication', () => { + beforeAll(async () => { + authenticatedUser = null + }) + + it('finds nothing', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(0) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 0, + posts: [], + }, + }, + }) + }) + }) + + describe('as new user', () => { + beforeAll(async () => { + authenticatedUser = newUser + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('without membership of group', () => { + beforeAll(async () => { + authenticatedUser = await anyUser.toJson() + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('with pending membership of group', () => { + beforeAll(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('finds the post of the public group and the post without group', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(2) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 2, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + }, + }) + }) + }) + + describe('as member of group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + }) + + it('finds all posts', async () => { + const result = await query({ + query: searchPosts(), + variables: { + query: 'post', + postsOffset: 0, + firstPosts: 25, + }, + }) + expect(result.data.searchPosts.posts).toHaveLength(4) + expect(result).toMatchObject({ + data: { + searchPosts: { + postCount: 4, + posts: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + }, + }) + }) + }) + }) + }) + + describe('changes of group membership', () => { + describe('pending member becomes usual member', () => { + describe('of closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows the posts of the closed group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('of hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows all the posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('usual member becomes pending', () => { + describe('of closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('does not show the posts of the closed group anymore', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('of hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + authenticatedUser = await pendingUser.toJson() + }) + + it('shows only the public posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('usual member leaves', () => { + describe('public group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'public-group', + userId: 'all-groups-user', + }, + }) + }) + + it('still shows the posts of the public group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('closed group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + }, + }) + }) + + it('does not show the posts of the closed group anymore', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('hidden group', () => { + beforeAll(async () => { + authenticatedUser = await allGroupsUser.toJson() + await mutate({ + mutation: leaveGroupMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + }, + }) + }) + + it('does only show the public posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(2) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('any user joins', () => { + describe('closed group', () => { + beforeAll(async () => { + authenticatedUser = await closedUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await allGroupsUser.toJson() + }) + + it('does not show the posts of the closed group', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(3) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + + describe('hidden group', () => { + beforeAll(async () => { + authenticatedUser = await hiddenUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation(), + variables: { + groupId: 'hidden-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await allGroupsUser.toJson() + }) + + it('shows all posts', async () => { + const result = await query({ query: filterPosts(), variables: {} }) + expect(result.data.Post).toHaveLength(4) + expect(result).toMatchObject({ + data: { + Post: expect.arrayContaining([ + { + id: 'post-to-public-group', + title: 'A post to a public group', + content: 'I am posting into a public group as a member of the group', + }, + { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + { + id: 'post-to-closed-group', + title: 'A post to a closed group', + content: 'I am posting into a closed group as a member of the group', + }, + { + id: 'post-to-hidden-group', + title: 'A post to a hidden group', + content: 'I am posting into a hidden group as a member of the group', + }, + ]), + }, + errors: undefined, + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index ea420bc2a..52c92b033 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -72,19 +72,19 @@ const signupCypher = (inviteCode) => { (inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User) ` optionalMerge = ` - MERGE(user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode) - MERGE(host)-[:INVITED { createdAt: toString(datetime()) }]->(user) - MERGE(user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host) - MERGE(host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user) + MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode) + MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user) + MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host) + MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user) ` } const cypher = ` - MATCH(email:EmailAddress {nonce: $nonce, email: $email}) + MATCH (email:EmailAddress {nonce: $nonce, email: $email}) WHERE NOT (email)-[:BELONGS_TO]->() ${optionalMatch} CREATE (user:User) - MERGE(user)-[:PRIMARY_EMAIL]->(email) - MERGE(user)<-[:BELONGS_TO]-(email) + MERGE (user)-[:PRIMARY_EMAIL]->(email) + MERGE (user)<-[:BELONGS_TO]-(email) ${optionalMerge} SET user += $args SET user.id = randomUUID() @@ -95,6 +95,13 @@ const signupCypher = (inviteCode) => { SET user.showShoutsPublicly = false SET user.sendNotificationEmails = true SET email.verifiedAt = toString(datetime()) + WITH user + OPTIONAL MATCH (post:Post)-[:IN]->(group:Group) + WHERE NOT group.groupType = 'public' + WITH user, collect(post) AS invisiblePosts + FOREACH (invisiblePost IN invisiblePosts | + MERGE (user)-[:CANNOT_SEE]->(invisiblePost) + ) RETURN user {.*} ` return cypher diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 60fd4318f..63279b4bf 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -23,12 +23,15 @@ const postWhereClause = `WHERE score >= 0.0 AND NOT ( author.deleted = true OR author.disabled = true OR resource.deleted = true OR resource.disabled = true - OR (:User {id: $userId})-[:MUTED]->(author) - )` + ) AND block IS NULL AND restriction IS NULL` const searchPostsSetup = { fulltextIndex: 'post_fulltext_search', - match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)', + match: `MATCH (resource:Post)<-[:WROTE]-(author:User) + MATCH (user:User {id: $userId}) + OPTIONAL MATCH (user)-[block:MUTED]->(author) + OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource) + WITH user, resource, author, block, restriction`, whereClause: postWhereClause, withClause: `WITH resource, author, [(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments, @@ -116,8 +119,8 @@ export default { Query: { searchPosts: async (_parent, args, context, _resolveInfo) => { const { query, postsOffset, firstPosts } = args - const { id: userId } = context.user - + let userId = null + if (context.user) userId = context.user.id return { postCount: getSearchResults( context, @@ -177,7 +180,8 @@ export default { }, searchResults: async (_parent, args, context, _resolveInfo) => { const { query, limit } = args - const { id: userId } = context.user + let userId = null + if (context.user) userId = context.user.id const searchType = query.replace(/^([!@#]?).*$/, '$1') const searchString = query.replace(/^([!@#])/, '') diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 9f9b18da5..c4890fdce 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -39,6 +39,8 @@ type Group { categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") myRole: GroupMemberRole # if 'null' then the current user is no member + + posts: [Post] @relation(name: "IN", direction: "IN") } diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 2f9221dd1..9eac00b0b 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -81,6 +81,7 @@ input _PostFilter { emotions_none: _PostEMOTEDFilter emotions_single: _PostEMOTEDFilter emotions_every: _PostEMOTEDFilter + group: _GroupFilter } enum _PostOrdering { @@ -167,6 +168,8 @@ type Post { emotions: [EMOTED] emotionsCount: Int! @cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)") + + group: Group @relation(name: "IN", direction: "OUT") } input _PostInput { @@ -184,6 +187,7 @@ type Mutation { language: String categoryIds: [ID] contentExcerpt: String + groupId: ID ): Post UpdatePost( id: ID! @@ -225,18 +229,4 @@ type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] - findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]! - @cypher( - statement: """ - CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) - YIELD node as post, score - MATCH (post)<-[:WROTE]-(user:User) - WHERE score >= 0.2 - AND NOT user.deleted = true AND NOT user.disabled = true - AND NOT post.deleted = true AND NOT post.disabled = true - AND NOT user.id in COALESCE($filter.author_not.id_in, []) - RETURN post - LIMIT $limit - """ - ) } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index fdab73d17..fe1ff43f0 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -186,18 +186,6 @@ type Query { blockedUsers: [User] isLoggedIn: Boolean! currentUser: User - findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]! - @cypher( - statement: """ - CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) - YIELD node as post, score - MATCH (user) - WHERE score >= 0.2 - AND NOT user.deleted = true AND NOT user.disabled = true - RETURN user - LIMIT $limit - """ - ) } enum Deletable {