diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js index 64ceb9021..16df63a48 100644 --- a/backend/src/constants/categories.js +++ b/backend/src/constants/categories.js @@ -1,3 +1,102 @@ // this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` export const CATEGORIES_MIN = 1 export const CATEGORIES_MAX = 3 + +export const categories = [ + { + icon: 'users', + name: 'networking', + description: 'Kooperation, Aktionsbündnisse, Solidarität, Hilfe', + }, + { + icon: 'home', + name: 'home', + description: 'Bauen, Lebensgemeinschaften, Tiny Houses, Gemüsegarten', + }, + { + icon: 'lightbulb', + name: 'energy', + description: 'Öl, Gas, Kohle, Wind, Wasserkraft, Biogas, Atomenergie, ...', + }, + { + icon: 'smile', + name: 'psyche', + description: 'Seele, Gefühle, Glück', + }, + { + icon: 'movement', + name: 'body-and-excercise', + description: 'Sport, Yoga, Massage, Tanzen, Entspannung', + }, + { + icon: 'balance-scale', + name: 'law', + description: 'Menschenrechte, Gesetze, Verordnungen', + }, + { + icon: 'money', + name: 'finance', + description: 'Geld, Finanzsystem, Alternativwährungen, ...', + }, + { + icon: 'child', + name: 'children', + description: 'Familie, Pädagogik, Schule, Prägung', + }, + { + icon: 'suitcase', + name: 'mobility', + description: 'Reise, Verkehr, Elektromobilität', + }, + { + icon: 'shopping-cart', + name: 'economy', + description: 'Handel, Konsum, Marketing, Lebensmittel, Lieferketten, ...', + }, + { + icon: 'angellist', + name: 'peace', + description: 'Krieg, Militär, soziale Verteidigung, Waffen, Cyberattacken', + }, + { + icon: 'university', + name: 'politics', + description: 'Demokratie, Mitbestimmung, Wahlen, Korruption, Parteien', + }, + { + icon: 'tree', + name: 'nature', + description: 'Tiere, Pflanzen, Landwirtschaft, Ökologie, Artenvielfalt', + }, + { + icon: 'graduation-cap', + name: 'science', + description: 'Bildung, Hochschule, Publikationen, ...', + }, + { + icon: 'medkit', + name: 'health', + description: 'Medizin, Ernährung, WHO, Impfungen, Schadstoffe, ...', + }, + { + icon: 'desktop', + name: 'it-and-media', + description: + 'Nachrichten, Manipulation, Datenschutz, Überwachung, Datenkraken, AI, Software, Apps', + }, + { + icon: 'heart-o', + name: 'spirituality', + description: 'Religion, Werte, Ethik', + }, + { + icon: 'music', + name: 'culture', + description: 'Kunst, Theater, Musik, Fotografie, Film', + }, + { + icon: 'ellipsis-h', + name: 'miscellaneous', + description: '', + }, +] diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js index b4a6063f1..64ffeaa59 100644 --- a/backend/src/constants/groups.js +++ b/backend/src/constants/groups.js @@ -1,2 +1,3 @@ // this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags +export const DESCRIPTION_EXCERPT_HTML_LENGTH = 120 // with removed HTML tags diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index 2a611f324..150bb5e9a 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -12,6 +12,7 @@ export const createGroupMutation = gql` $groupType: GroupType! $actionRadius: GroupActionRadius! $categoryIds: [ID] + $locationName: String ) { CreateGroup( id: $id @@ -22,6 +23,7 @@ export const createGroupMutation = gql` groupType: $groupType actionRadius: $actionRadius categoryIds: $categoryIds + locationName: $locationName ) { id name @@ -34,11 +36,87 @@ export const createGroupMutation = gql` description groupType actionRadius + categories { + id + slug + name + icon + } + # locationName # test this as result myRole } } ` +export const updateGroupMutation = gql` + mutation ( + $id: ID! + $name: String + $slug: String + $about: String + $description: String + $actionRadius: GroupActionRadius + $categoryIds: [ID] + $avatar: ImageInput + $locationName: String + ) { + UpdateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + actionRadius: $actionRadius + categoryIds: $categoryIds + avatar: $avatar + locationName: $locationName + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + categories { + id + slug + name + icon + } + # avatar # test this as result + # locationName # test this as result + myRole + } + } +` + +export const joinGroupMutation = gql` + mutation ($groupId: ID!, $userId: ID!) { + JoinGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } +` + +export const changeGroupMemberRoleMutation = gql` + mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) { + ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) { + id + name + slug + myRoleInGroup + } + } +` + // ------ queries export const groupQuery = gql` @@ -90,6 +168,19 @@ export const groupQuery = gql` name icon } + # avatar # test this as result + # locationName # test this as result + } + } +` + +export const groupMembersQuery = gql` + query ($id: ID!, $first: Int, $offset: Int, $orderBy: [_UserOrdering], $filter: _UserFilter) { + GroupMembers(id: $id, first: $first, offset: $offset, orderBy: $orderBy, filter: $filter) { + id + name + slug + myRoleInGroup } } ` diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 938ebef02..57a317b47 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -1,6 +1,8 @@ import { getDriver, getNeode } from '../../db/neo4j' import { hashSync } from 'bcryptjs' import { v4 as uuid } from 'uuid' +import { categories } from '../../constants/categories' +import CONFIG from '../../config' const defaultAdmin = { email: 'admin@example.org', @@ -10,6 +12,29 @@ const defaultAdmin = { slug: 'admin', } +const createCategories = async (session) => { + const createCategoriesTxResultPromise = session.writeTransaction(async (txc) => { + categories.forEach(({ icon, name }, index) => { + const id = `cat${index + 1}` + txc.run( + `MERGE (c:Category { + icon: "${icon}", + slug: "${name}", + name: "${name}", + id: "${id}", + createdAt: toString(datetime()) + })`, + ) + }) + }) + try { + await createCategoriesTxResultPromise + console.log('Successfully created categories!') // eslint-disable-line no-console + } catch (error) { + console.log(`Error creating categories: ${error}`) // eslint-disable-line no-console + } +} + const createDefaultAdminUser = async (session) => { const readTxResultPromise = session.readTransaction(async (txc) => { const result = await txc.run('MATCH (user:User) RETURN count(user) AS userCount') @@ -45,7 +70,7 @@ const createDefaultAdminUser = async (session) => { }) try { await createAdminTxResultPromise - console.log('Successfully created default admin user') // eslint-disable-line no-console + console.log('Successfully created default admin user!') // eslint-disable-line no-console } catch (error) { console.log(error) // eslint-disable-line no-console } @@ -58,6 +83,7 @@ class Store { const { driver } = neode const session = driver.session() await createDefaultAdminUser(session) + if (CONFIG.CATEGORIES_ACTIVE) await createCategories(session) const writeTxResultPromise = session.writeTransaction(async (txc) => { await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints return Promise.all( diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index e41ef1abc..b516ca529 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -5,9 +5,14 @@ import createServer from '../server' import faker from '@faker-js/faker' import Factory from '../db/factories' import { getNeode, getDriver } from '../db/neo4j' -import { createGroupMutation } from './graphql/groups' +import { + createGroupMutation, + joinGroupMutation, + changeGroupMemberRoleMutation, +} from './graphql/groups' import { createPostMutation } from './graphql/posts' import { createCommentMutation } from './graphql/comments' +import { categories } from '../constants/categories' if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) { throw new Error(`You cannot seed the database in a non-staging and real production environment!`) @@ -269,104 +274,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] dagobert.relateTo(louie, 'blocked'), ]) - await Promise.all([ - Factory.build('category', { - id: 'cat1', - name: 'Just For Fun', - slug: 'just-for-fun', - icon: 'smile', + await Promise.all( + categories.map(({ icon, name }, index) => { + Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) }), - Factory.build('category', { - id: 'cat2', - name: 'Happiness & Values', - slug: 'happiness-values', - icon: 'heart-o', - }), - Factory.build('category', { - id: 'cat3', - name: 'Health & Wellbeing', - slug: 'health-wellbeing', - icon: 'medkit', - }), - Factory.build('category', { - id: 'cat4', - name: 'Environment & Nature', - slug: 'environment-nature', - icon: 'tree', - }), - Factory.build('category', { - id: 'cat5', - name: 'Animal Protection', - slug: 'animal-protection', - icon: 'paw', - }), - Factory.build('category', { - id: 'cat6', - name: 'Human Rights & Justice', - slug: 'human-rights-justice', - icon: 'balance-scale', - }), - Factory.build('category', { - id: 'cat7', - name: 'Education & Sciences', - slug: 'education-sciences', - icon: 'graduation-cap', - }), - Factory.build('category', { - id: 'cat8', - name: 'Cooperation & Development', - slug: 'cooperation-development', - icon: 'users', - }), - Factory.build('category', { - id: 'cat9', - name: 'Democracy & Politics', - slug: 'democracy-politics', - icon: 'university', - }), - Factory.build('category', { - id: 'cat10', - name: 'Economy & Finances', - slug: 'economy-finances', - icon: 'money', - }), - Factory.build('category', { - id: 'cat11', - name: 'Energy & Technology', - slug: 'energy-technology', - icon: 'flash', - }), - Factory.build('category', { - id: 'cat12', - name: 'IT, Internet & Data Privacy', - slug: 'it-internet-data-privacy', - icon: 'mouse-pointer', - }), - Factory.build('category', { - id: 'cat13', - name: 'Art, Culture & Sport', - slug: 'art-culture-sport', - icon: 'paint-brush', - }), - Factory.build('category', { - id: 'cat14', - name: 'Freedom of Speech', - slug: 'freedom-of-speech', - icon: 'bullhorn', - }), - Factory.build('category', { - id: 'cat15', - name: 'Consumption & Sustainability', - slug: 'consumption-sustainability', - icon: 'shopping-cart', - }), - Factory.build('category', { - id: 'cat16', - name: 'Global Peace & Nonviolence', - slug: 'global-peace-nonviolence', - icon: 'angellist', - }), - ]) + ) const [environment, nature, democracy, freedom] = await Promise.all([ Factory.build('tag', { @@ -400,6 +317,62 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g0', + userId: 'u2', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g0', + userId: 'u3', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g0', + userId: 'u4', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g0', + userId: 'u6', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u2', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u4', + roleInGroup: 'admin', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u3', + roleInGroup: 'owner', + }, + }), + ]) authenticatedUser = await jennyRostock.toJson() await Promise.all([ @@ -416,6 +389,77 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g1', + userId: 'u1', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g1', + userId: 'u2', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g1', + userId: 'u5', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g1', + userId: 'u6', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g1', + userId: 'u7', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u1', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u2', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u5', + roleInGroup: 'admin', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u6', + roleInGroup: 'owner', + }, + }), + ]) authenticatedUser = await bobDerBaumeister.toJson() await Promise.all([ @@ -432,6 +476,62 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }), ]) + await Promise.all([ + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g2', + userId: 'u4', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g2', + userId: 'u5', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g2', + userId: 'u6', + }, + }), + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'g2', + userId: 'u7', + }, + }), + ]) + await Promise.all([ + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u4', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u5', + roleInGroup: 'usual', + }, + }), + mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'g0', + userId: 'u6', + roleInGroup: 'usual', + }, + }), + ]) // Create Posts diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index ecfc1a042..e3f6a3c84 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -7,3 +7,12 @@ export function gql(strings) { return strings.join('') } + +// sometime we have to wait to check a db state by having a look into the db in a certain moment +// or we wait a bit to check if we missed to set an await somewhere +// see: https://www.sitepoint.com/delay-sleep-pause-wait/ +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} +// usage – 4 seconds for example +// await sleep(4 * 1000) diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index ca061609a..68eea9a74 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -1,9 +1,14 @@ import trunc from 'trunc-html' +import { DESCRIPTION_EXCERPT_HTML_LENGTH } from '../constants/groups' export default { Mutation: { CreateGroup: async (resolve, root, args, context, info) => { - args.descriptionExcerpt = trunc(args.description, 120).html + args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html + return resolve(root, args, context, info) + }, + UpdateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, DESCRIPTION_EXCERPT_HTML_LENGTH).html return resolve(root, args, context, info) }, CreatePost: async (resolve, root, args, context, info) => { diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index c81b069d2..f6f675008 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -52,6 +52,145 @@ const isMySocialMedia = rule({ return socialMedia.ownedBy.node.id === user.id }) +const isAllowedToChangeGroupSettings = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const ownerId = user.id + const { id: groupId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (owner:User {id: $ownerId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + RETURN group {.*}, owner {.*, myRoleInGroup: membership.role} + `, + { groupId, ownerId }, + ) + return { + owner: transactionResponse.records.map((record) => record.get('owner'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + } + }) + try { + const { owner, group } = await readTxPromise + return !!group && !!owner && ['owner'].includes(owner.myRoleInGroup) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedSeeingMembersOfGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { id: groupId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group {id: $groupId}) + OPTIONAL MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId: user.id }, + ) + return { + member: transactionResponse.records.map((record) => record.get('member'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + } + }) + try { + const { member, group } = await readTxPromise + return ( + !!group && + (group.groupType === 'public' || + (['closed', 'hidden'].includes(group.groupType) && + !!member && + ['usual', 'admin', 'owner'].includes(member.myRoleInGroup))) + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToChangeGroupMemberRole = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const adminId = user.id + const { groupId, userId, roleInGroup } = args + if (adminId === userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (admin:User {id: $adminId})-[adminMembership:MEMBER_OF]->(group:Group {id: $groupId}) + OPTIONAL MATCH (group)<-[userMembership:MEMBER_OF]-(member:User {id: $userId}) + RETURN group {.*}, admin {.*, myRoleInGroup: adminMembership.role}, member {.*, myRoleInGroup: userMembership.role} + `, + { groupId, adminId, userId }, + ) + return { + admin: transactionResponse.records.map((record) => record.get('admin'))[0], + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { admin, group, member } = await readTxPromise + return ( + !!group && + !!admin && + (!member || + (!!member && + (member.myRoleInGroup === roleInGroup || !['owner'].includes(member.myRoleInGroup)))) && + ((['admin'].includes(admin.myRoleInGroup) && + ['pending', 'usual', 'admin'].includes(roleInGroup)) || + (['owner'].includes(admin.myRoleInGroup) && + ['pending', 'usual', 'admin', 'owner'].includes(roleInGroup))) + ) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + +const isAllowedToJoinGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (group:Group {id: $groupId}) + OPTIONAL MATCH (group)<-[membership:MEMBER_OF]-(member:User {id: $userId}) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { group, member } = await readTxPromise + return !!group && (group.groupType !== 'hidden' || (!!member && !!member.myRoleInGroup)) + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -78,7 +217,7 @@ const isAuthor = rule({ const isDeletingOwnAccount = rule({ cache: 'no_cache', -})(async (parent, args, context, info) => { +})(async (parent, args, context, _info) => { return context.user.id === args.id }) @@ -115,6 +254,7 @@ export default shield( statistics: allow, currentUser: allow, Group: isAuthenticated, + GroupMembers: isAllowedSeeingMembersOfGroup, Post: allow, profilePagePosts: allow, Comment: allow, @@ -142,6 +282,9 @@ export default shield( SignupVerification: allow, UpdateUser: onlyYourself, CreateGroup: isAuthenticated, + UpdateGroup: isAllowedToChangeGroupSettings, + JoinGroup: isAllowedToJoinGroup, + ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 2a965c87f..8fd200e8f 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -30,6 +30,10 @@ export default { args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group'))) return resolve(root, args, context, info) }, + UpdateGroup: async (resolve, root, args, context, info) => { + args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group'))) + return resolve(root, args, context, info) + }, CreatePost: async (resolve, root, args, context, info) => { args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) return resolve(root, args, context, info) diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 3fea526ee..edb6b64eb 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -2,12 +2,13 @@ import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../db/factories' -import { createGroupMutation } from '../db/graphql/groups' +import { createGroupMutation, updateGroupMutation } from '../db/graphql/groups' import { createPostMutation } from '../db/graphql/posts' import { signupVerificationMutation } from '../db/graphql/authentications' let authenticatedUser let variables +const categoryIds = ['cat9'] const driver = getDriver() const neode = getNeode() @@ -62,8 +63,6 @@ afterEach(async () => { describe('slugifyMiddleware', () => { describe('CreateGroup', () => { - const categoryIds = ['cat9'] - beforeEach(() => { variables = { ...variables, @@ -130,15 +129,14 @@ describe('slugifyMiddleware', () => { }) it('chooses another slug', async () => { - variables = { - ...variables, - name: 'Pre-Existing Group', - about: 'As an about', - } await expect( mutate({ mutation: createGroupMutation, - variables, + variables: { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + }, }), ).resolves.toMatchObject({ data: { @@ -151,15 +149,17 @@ describe('slugifyMiddleware', () => { describe('but if the client specifies a slug', () => { it('rejects CreateGroup', async (done) => { - variables = { - ...variables, - name: 'Pre-Existing Group', - about: 'As an about', - slug: 'pre-existing-group', - } try { await expect( - mutate({ mutation: createGroupMutation, variables }), + mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + slug: 'pre-existing-group', + }, + }), ).resolves.toMatchObject({ errors: [ { @@ -189,9 +189,163 @@ describe('slugifyMiddleware', () => { }) }) - describe('CreatePost', () => { - const categoryIds = ['cat9'] + describe('UpdateGroup', () => { + let createGroupResult + beforeEach(async () => { + createGroupResult = await mutate({ + mutation: createGroupMutation, + variables: { + name: 'The Best Group', + slug: 'the-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + }) + + describe('if group exists', () => { + describe('if new slug not(!) exists', () => { + describe('setting slug by group name', () => { + it('has the new slug', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: createGroupResult.data.CreateGroup.id, + name: 'My Best Group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'My Best Group', + slug: 'my-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + }) + }) + }) + + describe('setting slug explicitly', () => { + it('has the new slug', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: createGroupResult.data.CreateGroup.id, + slug: 'my-best-group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'The Best Group', + slug: 'my-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + }) + }) + }) + }) + + describe('if new slug exists in another group', () => { + beforeEach(async () => { + await mutate({ + mutation: createGroupMutation, + variables: { + name: 'Pre-Existing Group', + slug: 'pre-existing-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + }) + + describe('setting slug by group name', () => { + it('has unique slug "*-1"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: createGroupResult.data.CreateGroup.id, + name: 'Pre-Existing Group', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + name: 'Pre-Existing Group', + slug: 'pre-existing-group-1', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + myRole: 'owner', + }, + }, + }) + }) + }) + + describe('setting slug explicitly', () => { + it('rejects UpdateGroup', async (done) => { + try { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: createGroupResult.data.CreateGroup.id, + slug: 'pre-existing-group', + }, + }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Group with this slug already exists!', + }, + ], + }) + done() + } catch (error) { + throw new Error(` + ${error} + + Probably your database has no unique constraints! + + To see all constraints go to http://localhost:7474/browser/ and + paste the following: + \`\`\` + CALL db.constraints(); + \`\`\` + + Learn how to setup the database here: + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints + `) + } + }) + }) + }) + }) + }) + + describe('CreatePost', () => { beforeEach(() => { variables = { ...variables, @@ -252,16 +406,15 @@ describe('slugifyMiddleware', () => { }) it('chooses another slug', async () => { - variables = { - ...variables, - title: 'Pre-existing post', - content: 'Some content', - categoryIds, - } await expect( mutate({ mutation: createPostMutation, - variables, + variables: { + ...variables, + title: 'Pre-existing post', + content: 'Some content', + categoryIds, + }, }), ).resolves.toMatchObject({ data: { @@ -274,16 +427,18 @@ describe('slugifyMiddleware', () => { describe('but if the client specifies a slug', () => { it('rejects CreatePost', async (done) => { - variables = { - ...variables, - title: 'Pre-existing post', - content: 'Some content', - slug: 'pre-existing-post', - categoryIds, - } try { await expect( - mutate({ mutation: createPostMutation, variables }), + mutate({ + mutation: createPostMutation, + variables: { + ...variables, + title: 'Pre-existing post', + content: 'Some content', + slug: 'pre-existing-post', + categoryIds, + }, + }), ).resolves.toMatchObject({ errors: [ { @@ -313,6 +468,8 @@ describe('slugifyMiddleware', () => { }) }) + it.todo('UpdatePost') + describe('SignupVerification', () => { beforeEach(() => { variables = { diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js index ea617adc8..9a3f47fd0 100644 --- a/backend/src/models/Category.js +++ b/backend/src/models/Category.js @@ -9,8 +9,7 @@ export default { updatedAt: { type: 'string', isoDate: true, - required: true, - default: () => new Date().toISOString(), + required: false, }, post: { type: 'relationship', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 2d9dde1ff..2111aa54a 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -5,6 +5,7 @@ import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' import Resolver from './helpers/Resolver' +import { mergeImage } from './images/images' export default { Query: { @@ -34,10 +35,31 @@ export default { ` } } - const result = await txc.run(groupCypher, { + const transactionResponse = await txc.run(groupCypher, { userId: context.user.id, }) - return result.records.map((record) => record.get('group')) + return transactionResponse.records.map((record) => record.get('group')) + }) + try { + return await readTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + GroupMembers: async (_object, params, context, _resolveInfo) => { + const { id: groupId } = params + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const groupMemberCypher = ` + MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId}) + RETURN user {.*, myRoleInGroup: membership.role} + ` + const transactionResponse = await txc.run(groupMemberCypher, { + groupId, + }) + return transactionResponse.records.map((record) => record.get('user')) }) try { return await readTxResultPromise @@ -87,9 +109,10 @@ export default { MATCH (owner:User {id: $userId}) MERGE (owner)-[:CREATED]->(group) MERGE (owner)-[membership:MEMBER_OF]->(group) - SET membership.createdAt = toString(datetime()) - SET membership.updatedAt = toString(datetime()) - SET membership.role = 'owner' + SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = 'owner' ${categoriesCypher} RETURN group {.*, myRole: membership.role} `, @@ -101,8 +124,7 @@ export default { return group }) try { - const group = await writeTxResultPromise - return group + return await writeTxResultPromise } catch (error) { if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') throw new UserInputError('Group with this slug already exists!') @@ -111,6 +133,137 @@ export default { session.close() } }, + UpdateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + const { id: groupId, avatar: avatarInput } = params + delete params.categoryIds + if (CONFIG.CATEGORIES_ACTIVE && categoryIds) { + if (categoryIds.length < CATEGORIES_MIN) { + throw new UserInputError('Too view categories!') + } + if (categoryIds.length > CATEGORIES_MAX) { + throw new UserInputError('Too many categories!') + } + } + if ( + params.description && + removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN + ) { + throw new UserInputError('Description too short!') + } + const session = context.driver.session() + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { + const cypherDeletePreviousRelations = ` + MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(category:Category) + DELETE previousRelations + RETURN group, category + ` + await session.writeTransaction((transaction) => { + return transaction.run(cypherDeletePreviousRelations, { groupId }) + }) + } + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + let updateGroupCypher = ` + MATCH (group:Group {id: $groupId}) + SET group += $params + SET group.updatedAt = toString(datetime()) + WITH group + ` + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { + updateGroupCypher += ` + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category) + WITH group + ` + } + updateGroupCypher += ` + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*, myRole: membership.role} + ` + const transactionResponse = await transaction.run(updateGroupCypher, { + groupId, + userId: context.user.id, + categoryIds, + params, + }) + const [group] = await transactionResponse.records.map((record) => record.get('group')) + if (avatarInput) { + await mergeImage(group, 'AVATAR_IMAGE', avatarInput, { transaction }) + } + return group + }) + try { + return await writeTxResultPromise + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(error) + } finally { + session.close() + } + }, + JoinGroup: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const joinGroupCypher = ` + MATCH (member:User {id: $userId}), (group:Group {id: $groupId}) + MERGE (member)-[membership:MEMBER_OF]->(group) + ON CREATE SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = + CASE WHEN group.groupType = 'public' + THEN 'usual' + ELSE 'pending' + END + RETURN member {.*, myRoleInGroup: membership.role} + ` + const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId, roleInGroup } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const joinGroupCypher = ` + MATCH (member:User {id: $userId}), (group:Group {id: $groupId}) + MERGE (member)-[membership:MEMBER_OF]->(group) + ON CREATE SET + membership.createdAt = toString(datetime()), + membership.updatedAt = null, + membership.role = $roleInGroup + ON MATCH SET + membership.updatedAt = toString(datetime()), + membership.role = $roleInGroup + RETURN member {.*, myRoleInGroup: membership.role} + ` + const transactionResponse = await transaction.run(joinGroupCypher, { + groupId, + userId, + roleInGroup, + }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, }, Group: { ...Resolver('Group', { diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 6890e9147..7a588dd8b 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -1,6 +1,13 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' -import { createGroupMutation, groupQuery } from '../../db/graphql/groups' +import { + createGroupMutation, + updateGroupMutation, + joinGroupMutation, + changeGroupMemberRoleMutation, + groupMembersQuery, + groupQuery, +} from '../../db/graphql/groups' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import CONFIG from '../../config' @@ -8,8 +15,6 @@ import CONFIG from '../../config' const driver = getDriver() const neode = getNeode() -let query -let mutate let authenticatedUser let user @@ -18,27 +23,18 @@ const descriptionAdditional100 = ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' let variables = {} -beforeAll(async () => { - await cleanDatabase() - - const { server } = createServer({ - context: () => { - return { - driver, - neode, - user: authenticatedUser, - } - }, - }) - query = createTestClient(server).query - mutate = createTestClient(server).mutate +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, }) +const { mutate, query } = createTestClient(server) -afterAll(async () => { - await cleanDatabase() -}) - -beforeEach(async () => { +const seedBasicsAndClearAuthentication = async () => { variables = {} user = await Factory.build( 'user', @@ -78,241 +74,2275 @@ beforeEach(async () => { }), ]) authenticatedUser = null -}) +} -// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543 -afterEach(async () => { +beforeAll(async () => { await cleanDatabase() }) -describe('Group', () => { - describe('unauthenticated', () => { - it('throws authorization error', async () => { - const { errors } = await query({ query: groupQuery, variables: {} }) - expect(errors[0]).toHaveProperty('message', 'Not Authorized!') - }) - }) +afterAll(async () => { + await cleanDatabase() +}) - describe('authenticated', () => { +describe('in mode', () => { + describe('clean db after each single test', () => { beforeEach(async () => { - authenticatedUser = await user.toJson() + await seedBasicsAndClearAuthentication() }) - let otherUser + // TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543 + afterEach(async () => { + await cleanDatabase() + }) - beforeEach(async () => { - otherUser = await Factory.build( - 'user', - { - id: 'other-user', - name: 'Other TestUser', - }, - { - email: 'test2@example.org', - password: '1234', - }, - ) - authenticatedUser = await otherUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'others-group', - name: 'Uninteresting Group', - about: 'We will change nothing!', - description: 'We love it like it is!?' + descriptionAdditional100, - groupType: 'closed', - actionRadius: 'global', - categoryIds, - }, - }) - authenticatedUser = await user.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'my-group', + describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', name: 'The Best Group', + slug: 'the-group', about: 'We will change the world!', description: 'Some description' + descriptionAdditional100, groupType: 'public', actionRadius: 'regional', categoryIds, - }, + // locationName, // test this as result + } }) - }) - describe('query groups', () => { - describe('without any filters', () => { - it('finds all groups', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }), - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: createGroupMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) - describe('isMember = true', () => { - it('finds only groups where user is member', async () => { - const expected = { - data: { - Group: [ - { - id: 'my-group', - slug: 'the-best-group', + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('creates a group', async () => { + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + { + data: { + CreateGroup: { + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + }, + }, + errors: undefined, + }, + ) + }) + + it('assigns the authenticated user as owner', async () => { + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + { + data: { + CreateGroup: { + name: 'The Best Group', myRole: 'owner', }, - ], - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: true } }), - ).resolves.toMatchObject(expected) - }) - }) - - describe('isMember = false', () => { - it('finds only groups where user is not(!) member', async () => { - const expected = { - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - } - await expect( - query({ query: groupQuery, variables: { isMember: false } }), - ).resolves.toMatchObject(expected) - }) - }) - }) - }) -}) - -describe('CreateGroup', () => { - beforeEach(() => { - variables = { - ...variables, - id: 'g589', - name: 'The Best Group', - slug: 'the-group', - about: 'We will change the world!', - description: 'Some description' + descriptionAdditional100, - groupType: 'public', - actionRadius: 'regional', - categoryIds, - } - }) - - describe('unauthenticated', () => { - it('throws authorization error', async () => { - const { errors } = await mutate({ mutation: createGroupMutation, variables }) - expect(errors[0]).toHaveProperty('message', 'Not Authorized!') - }) - }) - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = await user.toJson() - }) - - it('creates a group', async () => { - const expected = { - data: { - CreateGroup: { - name: 'The Best Group', - slug: 'the-group', - about: 'We will change the world!', - }, - }, - errors: undefined, - } - await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('assigns the authenticated user as owner', async () => { - const expected = { - data: { - CreateGroup: { - name: 'The Best Group', - myRole: 'owner', - }, - }, - errors: undefined, - } - await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('has "disabled" and "deleted" default to "false"', async () => { - const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } - await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - describe('description', () => { - describe('length without HTML', () => { - describe('less then 100 chars', () => { - it('throws error: "Too view categories!"', async () => { - const { errors } = await mutate({ - mutation: createGroupMutation, - variables: { - ...variables, - description: - '0123456789' + - '0123456789', }, + errors: undefined, + }, + ) + }) + + it('has "disabled" and "deleted" default to "false"', async () => { + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + { + data: { CreateGroup: { disabled: false, deleted: false } }, + }, + ) + }) + + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "Description too short!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Description too short!') + }) + }) + }) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + describe('with matching amount of categories', () => { + it('has new categories', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + categoryIds: ['cat4', 'cat27'], + }, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat27' }), + ]), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('not even one', () => { + describe('by "categoryIds: null"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: null }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + + describe('by "categoryIds: []"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: [] }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + }) + + describe('four', () => { + it('throws error: "Too many categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, + }) + expect(errors[0]).toHaveProperty('message', 'Too many categories!') + }) + }) + }) + }) + }) + }) + + describe('building up – clean db after each resolver', () => { + describe('Group', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await query({ query: groupQuery, variables: {} }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let otherUser + + beforeAll(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + authenticatedUser = await otherUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'others-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'my-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('query groups', () => { + describe('without any filters', () => { + it('finds all groups', async () => { + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + }) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + it('has set categories', async () => { + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: null, + }), + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe("id = 'my-group'", () => { + it('finds only the group with this id', async () => { + await expect( + query({ query: groupQuery, variables: { id: 'my-group' } }), + ).resolves.toMatchObject({ + data: { + Group: [ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + ], + }, + errors: undefined, + }) + }) + }) + + describe('isMember = true', () => { + it('finds only groups where user is member', async () => { + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject({ + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('isMember = false', () => { + it('finds only groups where user is not(!) member', async () => { + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + }) }) - expect(errors[0]).toHaveProperty('message', 'Description too short!') }) }) }) }) - describe('categories', () => { - beforeEach(() => { - CONFIG.CATEGORIES_ACTIVE = true + describe('JoinGroup', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() }) - describe('not even one', () => { - it('throws error: "Too view categories!"', async () => { + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { const { errors } = await mutate({ - mutation: createGroupMutation, - variables: { ...variables, categoryIds: null }, + mutation: joinGroupMutation, + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + }, }) - expect(errors[0]).toHaveProperty('message', 'Too view categories!') + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') }) }) - describe('four', () => { - it('throws error: "Too many categories!"', async () => { - const { errors } = await mutate({ + describe('authenticated', () => { + let ownerOfClosedGroupUser + let ownerOfHiddenGroupUser + + beforeAll(async () => { + // create users + ownerOfClosedGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-closed-group', + name: 'Owner Of Closed Group', + }, + { + email: 'owner-of-closed-group@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await ownerOfClosedGroupUser.toJson() + await mutate({ mutation: createGroupMutation, - variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('public group', () => { + describe('joined by "owner-of-closed-group"', () => { + it('has "usual" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'current-user', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'current-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + + describe('closed group', () => { + describe('joined by "current-user"', () => { + it('has "pending" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'closed-group', + userId: 'current-user', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'current-user', + myRoleInGroup: 'pending', + }, + }, + errors: undefined, + }) + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'closed-group', + userId: 'owner-of-closed-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + + describe('hidden group', () => { + describe('joined by "owner-of-closed-group"', () => { + it('throws authorization error', async () => { + const { errors } = await query({ + query: joinGroupMutation, + variables: { + groupId: 'hidden-group', + userId: 'owner-of-closed-group', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('joined by its owner', () => { + describe('does not create additional "MEMBER_OF" relation and therefore', () => { + it('has still "owner" as membership role', async () => { + await expect( + mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'hidden-group', + userId: 'owner-of-hidden-group', + }, + }), + ).resolves.toMatchObject({ + data: { + JoinGroup: { + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + }) + }) + + describe('GroupMembers', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + variables = { + id: 'not-existing-group', + } + const { errors } = await query({ query: groupMembersQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let otherUser + let pendingUser + let ownerOfClosedGroupUser + let ownerOfHiddenGroupUser + + beforeAll(async () => { + // create users + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'other-user@example.org', + password: '1234', + }, + ) + pendingUser = await Factory.build( + 'user', + { + id: 'pending-user', + name: 'Pending TestUser', + }, + { + email: 'pending@example.org', + password: '1234', + }, + ) + ownerOfClosedGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-closed-group', + name: 'Owner Of Closed Group', + }, + { + email: 'owner-of-closed-group@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerOfClosedGroupUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'closed-group', + userId: 'current-user', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'owner-of-hidden-group', + roleInGroup: 'usual', + }, + }) + // hidden-group + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'pending-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'current-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'owner-of-closed-group', + roleInGroup: 'admin', + }, + }) + + authenticatedUser = null + }) + + describe('public group', () => { + beforeEach(async () => { + variables = { + id: 'public-group', + } + }) + + describe('query group members', () => { + describe('by owner "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by usual member "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + }) + }) + + describe('closed group', () => { + beforeEach(async () => { + variables = { + id: 'closed-group', + } + }) + + describe('query group members', () => { + describe('by owner "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by usual member "owner-of-hidden-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'owner', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(3) + }) + }) + + describe('by pending member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('hidden group', () => { + beforeEach(async () => { + variables = { + id: 'hidden-group', + } + }) + + describe('query group members', () => { + describe('by owner "owner-of-hidden-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfHiddenGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by usual member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by admin member "owner-of-closed-group"', () => { + beforeEach(async () => { + authenticatedUser = await ownerOfClosedGroupUser.toJson() + }) + + it('finds all members', async () => { + const result = await mutate({ + mutation: groupMembersQuery, + variables, + }) + expect(result).toMatchObject({ + data: { + GroupMembers: expect.arrayContaining([ + expect.objectContaining({ + id: 'pending-user', + myRoleInGroup: 'pending', + }), + expect.objectContaining({ + id: 'current-user', + myRoleInGroup: 'usual', + }), + expect.objectContaining({ + id: 'owner-of-closed-group', + myRoleInGroup: 'admin', + }), + expect.objectContaining({ + id: 'owner-of-hidden-group', + myRoleInGroup: 'owner', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.GroupMembers.length).toBe(4) + }) + }) + + describe('by pending member "pending-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('by none member "other-user"', () => { + beforeEach(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('throws authorization error', async () => { + const { errors } = await query({ query: groupMembersQuery, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + + describe('ChangeGroupMemberRole', () => { + let pendingMemberUser + let usualMemberUser + let adminMemberUser + let ownerMemberUser + let secondOwnerMemberUser + + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + // create users + pendingMemberUser = await Factory.build( + 'user', + { + id: 'pending-member-user', + name: 'Pending Member TestUser', + }, + { + email: 'pending-member-user@example.org', + password: '1234', + }, + ) + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + adminMemberUser = await Factory.build( + 'user', + { + id: 'admin-member-user', + name: 'Admin Member TestUser', + }, + { + email: 'admin-member-user@example.org', + password: '1234', + }, + ) + ownerMemberUser = await Factory.build( + 'user', + { + id: 'owner-member-user', + name: 'Owner Member TestUser', + }, + { + email: 'owner-member-user@example.org', + password: '1234', + }, + ) + secondOwnerMemberUser = await Factory.build( + 'user', + { + id: 'second-owner-member-user', + name: 'Second Owner Member TestUser', + }, + { + email: 'second-owner-member-user@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + // hidden-group + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + + authenticatedUser = null + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + roleInGroup: 'pending', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + describe('in all group types – here "closed-group" for example', () => { + beforeEach(async () => { + variables = { + groupId: 'closed-group', + } + }) + + describe('join the members and give them their prospective roles', () => { + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('for "usual-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'usual-member-user', + } + }) + + describe('as usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + + // the GQL mutation needs this fields in the result for testing + it.todo('has "updatedAt" newer as "createdAt"') + }) + }) + + describe('for "admin-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'admin-member-user', + } + }) + + describe('as admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('has role admin', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'admin-member-user', + myRoleInGroup: 'admin', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('for "second-owner-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'second-owner-member-user', + } + }) + + describe('as owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'second-owner-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + }) + }) + + describe('switch role', () => { + describe('of owner "owner-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'owner-member-user', + } + }) + + describe('by owner themself "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + // shall this be possible in the future? + // or shall only an owner who gave the second owner the owner role downgrade themself for savety? + // otherwise the first owner who downgrades the other one has the victory over the group! + describe('by second owner "second-owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await secondOwnerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('to same role owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner still', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'owner-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by admin "admin-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await adminMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of admin "admin-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'admin-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('has role owner', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'admin-member-user', + myRoleInGroup: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to owner', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'owner', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending again', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of usual member "usual-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'usual-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('has role admin', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'admin', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual again', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'usual-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to admin', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'admin', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('degrade to pending again', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + + describe('of still pending member "pending-member-user"', () => { + beforeEach(async () => { + variables = { + ...variables, + userId: 'pending-member-user', + } + }) + + describe('by owner "owner-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await ownerMemberUser.toJson() + }) + + describe('to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('has role usual', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'pending-member-user', + myRoleInGroup: 'usual', + }, + }, + errors: undefined, + }) + }) + }) + + describe('back to pending', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'pending', + } + }) + + it('has role usual again', async () => { + await expect( + mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + ChangeGroupMemberRole: { + id: 'pending-member-user', + myRoleInGroup: 'pending', + }, + }, + errors: undefined, + }) + }) + }) + }) + + describe('by usual member "usual-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await usualMemberUser.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by still pending member "pending-member-user"', () => { + beforeEach(async () => { + authenticatedUser = await pendingMemberUser.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + + describe('by none member "current-user"', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('upgrade to usual', () => { + beforeEach(async () => { + variables = { + ...variables, + roleInGroup: 'usual', + } + }) + + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: changeGroupMemberRoleMutation, + variables, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + }) + }) + + describe('UpdateGroup', () => { + beforeAll(async () => { + await seedBasicsAndClearAuthentication() + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + query: updateGroupMutation, + variables: { + id: 'my-group', + slug: 'my-best-group', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + let otherUser + + beforeAll(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + authenticatedUser = await otherUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'others-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'my-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('change group settings', () => { + describe('as owner', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('has set the settings', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + name: 'The New Group For Our Coutry', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + // locationName, // test this as result + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Coutry', + slug: 'the-new-group-for-our-coutry', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + // locationName, // test this as result + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "Description too short!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Description too short!') + }) + }) + }) + }) + + describe('categories', () => { + beforeEach(async () => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + describe('with matching amount of categories', () => { + it('has new categories', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + categoryIds: ['cat4', 'cat27'], + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat27' }), + ]), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('not even one', () => { + describe('by "categoryIds: []"', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + categoryIds: [], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + }) + + describe('four', () => { + it('throws error: "Too many categories!"', async () => { + const { errors } = await mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Too many categories!') + }) + }) + }) + }) + + describe('as no(!) owner', () => { + it('throws authorization error', async () => { + authenticatedUser = await otherUser.toJson() + const { errors } = await mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + name: 'The New Group For Our Coutry', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + categoryIds: ['cat4', 'cat27'], // test this as result + // avatar, // test this as result + // locationName, // test this as result + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) }) - expect(errors[0]).toHaveProperty('message', 'Too many categories!') }) }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index b09bb3edd..97230715f 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -131,11 +131,11 @@ export default { delete params.image const session = context.driver.session() let updatePostCypher = ` - MATCH (post:Post {id: $params.id}) - SET post += $params - SET post.updatedAt = toString(datetime()) - WITH post - ` + MATCH (post:Post {id: $params.id}) + SET post += $params + SET post.updatedAt = toString(datetime()) + WITH post + ` if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) { const cypherDeletePreviousRelations = ` @@ -358,7 +358,7 @@ export default { undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], hasMany: { tags: '-[:TAGGED]->(related:Tag)', - // categories: '-[:CATEGORIZED]->(related:Category)', + categories: '-[:CATEGORIZED]->(related:Category)', comments: '<-[:COMMENTS]-(related:Comment)', shoutedBy: '<-[:SHOUTED]-(related:User)', emotions: '<-[related:EMOTED]', diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 52bd8fcd0..6fc9b5722 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -368,7 +368,7 @@ describe('UpdatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null - expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ + await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ errors: [{ message: 'Not Authorized!' }], data: { UpdatePost: null }, }) diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 3165b4a44..c1b097857 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -59,7 +59,7 @@ input _GroupFilter { type Query { Group( - isMember: Boolean # if 'undefined' or 'null' then all groups + isMember: Boolean # if 'undefined' or 'null' then get all groups id: ID name: String slug: String @@ -67,18 +67,28 @@ type Query { updatedAt: String about: String description: String + # groupType: GroupType # test this + # actionRadius: GroupActionRadius # test this + # avatar: ImageInput # test this locationName: String first: Int offset: Int orderBy: [_GroupOrdering] - filter: _GroupFilter ): [Group] - AvailableGroupTypes: [GroupType]! + GroupMembers( + id: ID! + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter + ): [User] - AvailableGroupActionRadii: [GroupActionRadius]! + # AvailableGroupTypes: [GroupType]! - AvailableGroupMemberRoles: [GroupMemberRole]! + # AvailableGroupActionRadii: [GroupActionRadius]! + + # AvailableGroupMemberRoles: [GroupMemberRole]! } type Mutation { @@ -86,24 +96,38 @@ type Mutation { id: ID name: String! slug: String - avatar: ImageInput about: String description: String! groupType: GroupType! actionRadius: GroupActionRadius! categoryIds: [ID] - locationName: String + # avatar: ImageInput # a group can not be created with an avatar + locationName: String # test this as result ): Group UpdateGroup( id: ID! name: String slug: String - avatar: ImageInput - locationName: String about: String description: String + # groupType: GroupType # is not possible at the moment and has to be discussed. may be in the stronger direction: public → closed → hidden + actionRadius: GroupActionRadius + categoryIds: [ID] + avatar: ImageInput # test this as result + locationName: String # test this as result ): Group - DeleteGroup(id: ID!): Group + # DeleteGroup(id: ID!): Group + + JoinGroup( + groupId: ID! + userId: ID! + ): User + + ChangeGroupMemberRole( + groupId: ID! + userId: ID! + roleInGroup: GroupMemberRole! + ): User } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index a25e51079..4219cd00e 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -114,6 +114,8 @@ type User { badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") emotions: [EMOTED] + + myRoleInGroup: GroupMemberRole } diff --git a/webapp/assets/_new/icons/svgs/child.svg b/webapp/assets/_new/icons/svgs/child.svg new file mode 100644 index 000000000..fcb5651f0 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/child.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/desktop.svg b/webapp/assets/_new/icons/svgs/desktop.svg new file mode 100644 index 000000000..ba1ef8431 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/desktop.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/ellipsis-h.svg b/webapp/assets/_new/icons/svgs/ellipsis-h.svg new file mode 100644 index 000000000..eb7deeab0 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/ellipsis-h.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/home.svg b/webapp/assets/_new/icons/svgs/home.svg new file mode 100644 index 000000000..b1a13b06f --- /dev/null +++ b/webapp/assets/_new/icons/svgs/home.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/lightbulb.svg b/webapp/assets/_new/icons/svgs/lightbulb.svg new file mode 100644 index 000000000..1c19c81b1 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/lightbulb.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/movement.svg b/webapp/assets/_new/icons/svgs/movement.svg new file mode 100644 index 000000000..ac5cd9cc0 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/movement.svg @@ -0,0 +1,20 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/music.svg b/webapp/assets/_new/icons/svgs/music.svg new file mode 100644 index 000000000..b84b87800 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/music.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/assets/_new/icons/svgs/suitcase.svg b/webapp/assets/_new/icons/svgs/suitcase.svg new file mode 100644 index 000000000..ceca5cbad --- /dev/null +++ b/webapp/assets/_new/icons/svgs/suitcase.svg @@ -0,0 +1,5 @@ + + diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index 9e7ebfec0..6e9741e79 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -8,6 +8,9 @@ let wrapper describe('FilterMenu.vue', () => { const mocks = { $t: jest.fn((string) => string), + $env: { + CATEGORIES_ACTIVE: true, + }, } const getters = { diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue index 9e211ccf9..84c7c1f67 100644 --- a/webapp/components/FilterMenu/FilterMenu.vue +++ b/webapp/components/FilterMenu/FilterMenu.vue @@ -14,6 +14,7 @@