diff --git a/.travis.yml b/.travis.yml index 42b427a11..f48b0bb36 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,12 +21,15 @@ install: - wait-on http://localhost:7474 script: + - export CYPRESS_RETRIES=1 + - export BRANCH=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then echo $TRAVIS_BRANCH; else echo $TRAVIS_PULL_REQUEST_BRANCH; fi) + - echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH" # Backend - docker-compose exec backend yarn run lint - docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage - docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:seed - - docker-compose exec backend yarn run test:cucumber + - docker-compose exec backend yarn run test:cucumber --tags "not @wip" - docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:seed # Frontend @@ -34,7 +37,7 @@ script: - docker-compose exec webapp yarn run test --ci --verbose=false --coverage - docker-compose exec -d backend yarn run test:before:seeder # Fullstack - - CYPRESS_RETRIES=1 yarn run cypress:run + - yarn run cypress:run # Coverage - codecov diff --git a/.vscode/settings.json b/.vscode/settings.json index 908252f41..e2a727871 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,4 +9,4 @@ ], "editor.formatOnSave": true, "eslint.autoFixOnSave": true -} \ No newline at end of file +} diff --git a/backend/Dockerfile b/backend/Dockerfile index d24f2747e..f0251bddc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,4 +24,5 @@ RUN yarn run build FROM base as production ENV NODE_ENV=production COPY --from=builder /nitro-backend/dist ./dist +COPY ./public/img/ ./public/img/ RUN yarn install --frozen-lockfile --non-interactive diff --git a/backend/package.json b/backend/package.json index be5fb086d..c8537ae0b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,16 +43,16 @@ }, "dependencies": { "activitystrea.ms": "~2.1.3", - "apollo-cache-inmemory": "~1.6.1", - "apollo-client": "~2.6.1", + "apollo-cache-inmemory": "~1.6.2", + "apollo-client": "~2.6.2", "apollo-link-context": "~1.0.14", "apollo-link-http": "~1.5.14", - "apollo-server": "~2.6.1", + "apollo-server": "~2.6.3", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.3", "cors": "~2.8.5", "cross-env": "~5.2.0", - "date-fns": "2.0.0-alpha.29", + "date-fns": "2.0.0-alpha.33", "debug": "~4.1.1", "dotenv": "~8.0.0", "express": "~4.17.1", @@ -61,7 +61,7 @@ "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.2", - "graphql-shield": "~5.3.6", + "graphql-shield": "~5.3.8", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.17.4", "helmet": "~3.18.0", @@ -69,7 +69,6 @@ "linkifyjs": "~2.1.8", "lodash": "~4.17.11", "merge-graphql-schemas": "^1.5.8", - "ms": "~2.1.1", "neo4j-driver": "~1.7.4", "neo4j-graphql-js": "git+https://github.com/Human-Connection/neo4j-graphql-js.git#temporary_fixes", "node-fetch": "~2.6.0", @@ -88,7 +87,7 @@ "@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/preset-env": "~7.4.5", "@babel/register": "~7.4.4", - "apollo-server-testing": "~2.6.1", + "apollo-server-testing": "~2.6.3", "babel-core": "~7.0.0-0", "babel-eslint": "~10.0.1", "babel-jest": "~24.8.0", @@ -106,7 +105,7 @@ "graphql-request": "~1.8.2", "jest": "~24.8.0", "nodemon": "~1.19.1", - "prettier": "~1.17.1", + "prettier": "~1.18.2", "supertest": "~4.0.2" } } diff --git a/backend/src/activitypub/NitroDataSource.js b/backend/src/activitypub/NitroDataSource.js index eea37337a..0900bed6c 100644 --- a/backend/src/activitypub/NitroDataSource.js +++ b/backend/src/activitypub/NitroDataSource.js @@ -505,9 +505,7 @@ export default class NitroDataSource { const result2 = await this.client.mutate({ mutation: gql` mutation { - AddCommentAuthor(from: {id: "${ - result.data.CreateComment.id - }"}, to: {id: "${toUserId}"}) { + AddCommentAuthor(from: {id: "${result.data.CreateComment.id}"}, to: {id: "${toUserId}"}) { id } } @@ -519,9 +517,7 @@ export default class NitroDataSource { result = await this.client.mutate({ mutation: gql` mutation { - AddCommentPost(from: { id: "${ - result.data.CreateComment.id - }", to: { id: "${postId}" }}) { + AddCommentPost(from: { id: "${result.data.CreateComment.id}", to: { id: "${postId}" }}) { id } } diff --git a/backend/src/jwt/encode.js b/backend/src/jwt/encode.js index 97c6dcd66..1552804cc 100644 --- a/backend/src/jwt/encode.js +++ b/backend/src/jwt/encode.js @@ -1,11 +1,10 @@ import jwt from 'jsonwebtoken' -import ms from 'ms' import CONFIG from './../config' // Generate an Access Token for the given User ID export default function encode(user) { const token = jwt.sign(user, CONFIG.JWT_SECRET, { - expiresIn: ms('1d'), + expiresIn: 24 * 60 * 60 * 1000, // one day issuer: CONFIG.GRAPHQL_URI, audience: CONFIG.CLIENT_URI, subject: user.id.toString(), diff --git a/backend/src/middleware/filterBubble/filterBubble.js b/backend/src/middleware/filterBubble/filterBubble.js deleted file mode 100644 index bfdad5e2c..000000000 --- a/backend/src/middleware/filterBubble/filterBubble.js +++ /dev/null @@ -1,12 +0,0 @@ -import replaceParams from './replaceParams' - -const replaceFilterBubbleParams = async (resolve, root, args, context, resolveInfo) => { - args = await replaceParams(args, context) - return resolve(root, args, context, resolveInfo) -} - -export default { - Query: { - Post: replaceFilterBubbleParams, - }, -} diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js index afe1df1c9..62addeece 100644 --- a/backend/src/middleware/filterBubble/filterBubble.spec.js +++ b/backend/src/middleware/filterBubble/filterBubble.spec.js @@ -5,6 +5,7 @@ import Factory from '../../seed/factories' const factory = Factory() const currentUserParams = { + id: 'u1', email: 'you@example.org', name: 'This is you', password: '1234', @@ -41,7 +42,7 @@ afterEach(async () => { await factory.cleanDatabase() }) -describe('FilterBubble middleware', () => { +describe('Filter posts by author is followed by sb.', () => { describe('given an authenticated user', () => { let authenticatedClient @@ -52,7 +53,7 @@ describe('FilterBubble middleware', () => { describe('no filter bubble', () => { it('returns all posts', async () => { - const query = '{ Post( filterBubble: {}) { title } }' + const query = '{ Post(filter: { }) { title } }' const expected = { Post: [ { title: 'This is some random post' }, @@ -65,7 +66,7 @@ describe('FilterBubble middleware', () => { describe('filtering for posts of followed users only', () => { it('returns only posts authored by followed users', async () => { - const query = '{ Post( filterBubble: { author: following }) { title } }' + const query = '{ Post( filter: { author: { followedBy_some: { id: "u1" } } }) { title } }' const expected = { Post: [{ title: 'This is the post of a followed user' }], } diff --git a/backend/src/middleware/filterBubble/replaceParams.js b/backend/src/middleware/filterBubble/replaceParams.js deleted file mode 100644 index a10b6c29d..000000000 --- a/backend/src/middleware/filterBubble/replaceParams.js +++ /dev/null @@ -1,31 +0,0 @@ -import { UserInputError } from 'apollo-server' - -export default async function replaceParams(args, context) { - const { author = 'all' } = args.filterBubble || {} - const { user } = context - - if (author === 'following') { - if (!user) - throw new UserInputError( - "You are unauthenticated - I don't know any users you are following.", - ) - - const session = context.driver.session() - let { records } = await session.run( - 'MATCH(followed:User)<-[:FOLLOWS]-(u {id: $userId}) RETURN followed.id', - { userId: context.user.id }, - ) - const followedIds = records.map(record => record.get('followed.id')) - - // carefully override `id_in` - args.filter = args.filter || {} - args.filter.author = args.filter.author || {} - args.filter.author.id_in = followedIds - - session.close() - } - - delete args.filterBubble - - return args -} diff --git a/backend/src/middleware/filterBubble/replaceParams.spec.js b/backend/src/middleware/filterBubble/replaceParams.spec.js deleted file mode 100644 index e14fda416..000000000 --- a/backend/src/middleware/filterBubble/replaceParams.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -import replaceParams from './replaceParams.js' - -describe('replaceParams', () => { - let args - let context - let run - - let action = () => { - return replaceParams(args, context) - } - - beforeEach(() => { - args = {} - run = jest.fn().mockResolvedValue({ - records: [{ get: () => 1 }, { get: () => 2 }, { get: () => 3 }], - }) - context = { - driver: { - session: () => { - return { - run, - close: () => {}, - } - }, - }, - } - }) - - describe('args == ', () => { - describe('{}', () => { - it('does not crash', async () => { - await expect(action()).resolves.toEqual({}) - }) - }) - - describe('unauthenticated user', () => { - beforeEach(() => { - context.user = null - }) - - describe('{ filterBubble: { author: following } }', () => { - it('throws error', async () => { - args = { filterBubble: { author: 'following' } } - await expect(action()).rejects.toThrow('You are unauthenticated') - }) - }) - - describe('{ filterBubble: { author: all } }', () => { - it('removes filterBubble param', async () => { - const expected = {} - await expect(action()).resolves.toEqual(expected) - }) - - it('does not make database calls', async () => { - await action() - expect(run).not.toHaveBeenCalled() - }) - }) - }) - - describe('authenticated user', () => { - beforeEach(() => { - context.user = { id: 'u4711' } - }) - - describe('{ filterBubble: { author: following } }', () => { - beforeEach(() => { - args = { filterBubble: { author: 'following' } } - }) - - it('returns args object with resolved ids of followed users', async () => { - const expected = { filter: { author: { id_in: [1, 2, 3] } } } - await expect(action()).resolves.toEqual(expected) - }) - - it('makes database calls', async () => { - await action() - expect(run).toHaveBeenCalledTimes(1) - }) - - describe('given any additional filter args', () => { - describe('merges', () => { - it('empty filter object', async () => { - args.filter = {} - const expected = { filter: { author: { id_in: [1, 2, 3] } } } - await expect(action()).resolves.toEqual(expected) - }) - - it('filter.title', async () => { - args.filter = { title: 'bla' } - const expected = { filter: { title: 'bla', author: { id_in: [1, 2, 3] } } } - await expect(action()).resolves.toEqual(expected) - }) - - it('filter.author', async () => { - args.filter = { author: { name: 'bla' } } - const expected = { filter: { author: { name: 'bla', id_in: [1, 2, 3] } } } - await expect(action()).resolves.toEqual(expected) - }) - }) - }) - }) - - describe('{ filterBubble: { } }', () => { - it('removes filterBubble param', async () => { - const expected = {} - await expect(action()).resolves.toEqual(expected) - }) - - it('does not make database calls', async () => { - await action() - expect(run).not.toHaveBeenCalled() - }) - }) - - describe('{ filterBubble: { author: all } }', () => { - it('removes filterBubble param', async () => { - const expected = {} - await expect(action()).resolves.toEqual(expected) - }) - - it('does not make database calls', async () => { - await action() - expect(run).not.toHaveBeenCalled() - }) - }) - }) - }) -}) diff --git a/backend/src/middleware/fixImageUrlsMiddleware.js b/backend/src/middleware/fixImageUrlsMiddleware.js index c930915bf..3bfa8537a 100644 --- a/backend/src/middleware/fixImageUrlsMiddleware.js +++ b/backend/src/middleware/fixImageUrlsMiddleware.js @@ -6,8 +6,11 @@ const legacyUrls = [ export const fixUrl = url => { legacyUrls.forEach(legacyUrl => { - url = url.replace(legacyUrl, '/api') + url = url.replace(legacyUrl, '') }) + if (!url.startsWith('/')) { + url = `/${url}` + } return url } diff --git a/backend/src/middleware/fixImageUrlsMiddleware.spec.js b/backend/src/middleware/fixImageUrlsMiddleware.spec.js index b2d808dd9..0da66811a 100644 --- a/backend/src/middleware/fixImageUrlsMiddleware.spec.js +++ b/backend/src/middleware/fixImageUrlsMiddleware.spec.js @@ -1,12 +1,19 @@ import { fixImageURLs } from './fixImageUrlsMiddleware' describe('fixImageURLs', () => { + describe('edge case: image url is exact match of legacy url', () => { + it('replaces it with `/`', () => { + const url = 'https://api-alpha.human-connection.org' + expect(fixImageURLs(url)).toEqual('/') + }) + }) + describe('image url of legacy alpha', () => { it('removes domain', () => { const url = 'https://api-alpha.human-connection.org/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png' expect(fixImageURLs(url)).toEqual( - '/api/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png', + '/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png', ) }) }) @@ -16,7 +23,7 @@ describe('fixImageURLs', () => { const url = 'https://staging-api.human-connection.org/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg' expect(fixImageURLs(url)).toEqual( - '/api/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg', + '/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg', ) }) }) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 6bc7be000..75314abc0 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -13,7 +13,6 @@ import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation' import notifications from './notifications' -import filterBubble from './filterBubble/filterBubble' export default schema => { const middlewares = { @@ -31,13 +30,11 @@ export default schema => { user: user, includedFields: includedFields, orderBy: orderBy, - filterBubble: filterBubble, } let order = [ 'permissions', 'activityPub', - 'filterBubble', 'password', 'dateTime', 'validation', diff --git a/backend/src/middleware/notifications/spec.js b/backend/src/middleware/notifications/spec.js index 65212e544..985654b0f 100644 --- a/backend/src/middleware/notifications/spec.js +++ b/backend/src/middleware/notifications/spec.js @@ -87,9 +87,7 @@ describe('currentUser { notifications }', () => { describe('who mentions me again', () => { beforeEach(async () => { - const updatedContent = `${ - post.content - } One more mention to @al-capone` + const updatedContent = `${post.content} One more mention to @al-capone` // The response `post.content` contains a link but the XSSmiddleware // should have the `mention` CSS class removed. I discovered this // during development and thought: A feature not a bug! This way we diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index bc9b4c525..10b777748 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -1,4 +1,4 @@ -import { rule, shield, allow, or } from 'graphql-shield' +import { rule, shield, deny, allow, or } from 'graphql-shield' /* * TODO: implement @@ -16,6 +16,12 @@ const isAdmin = rule()(async (parent, args, { user }, info) => { return user && user.role === 'admin' }) +const onlyYourself = rule({ + cache: 'no_cache', +})(async (parent, args, context, info) => { + return context.user.id === args.id +}) + const isMyOwn = rule({ cache: 'no_cache', })(async (parent, args, context, info) => { @@ -48,6 +54,13 @@ const belongsToMe = rule({ return Boolean(notification) }) +/* TODO: decide if we want to remove this check: the check + * `onlyEnabledContent` throws authorization errors only if you have + * arguments for `disabled` or `deleted` assuming these are filter + * parameters. Soft-delete middleware obfuscates data on its way out + * anyways. Furthermore, `neo4j-graphql-js` offers many ways to filter for + * data so I believe, this is not a good check anyways. + */ const onlyEnabledContent = rule({ cache: 'strict', })(async (parent, args, ctx, info) => { @@ -80,47 +93,70 @@ const isAuthor = rule({ return authorId === user.id }) -// Permissions -const permissions = shield({ - Query: { - Notification: isAdmin, - statistics: allow, - currentUser: allow, - Post: or(onlyEnabledContent, isModerator), - }, - Mutation: { - UpdateNotification: belongsToMe, - CreatePost: isAuthenticated, - UpdatePost: isAuthor, - DeletePost: isAuthor, - report: isAuthenticated, - CreateBadge: isAdmin, - UpdateBadge: isAdmin, - DeleteBadge: isAdmin, - AddUserBadges: isAdmin, - CreateSocialMedia: isAuthenticated, - DeleteSocialMedia: isAuthenticated, - // AddBadgeRewarded: isAdmin, - // RemoveBadgeRewarded: isAdmin, - reward: isAdmin, - unreward: isAdmin, - // addFruitToBasket: isAuthenticated - follow: isAuthenticated, - unfollow: isAuthenticated, - shout: isAuthenticated, - unshout: isAuthenticated, - changePassword: isAuthenticated, - enable: isModerator, - disable: isModerator, - CreateComment: isAuthenticated, - DeleteComment: isAuthor, - // CreateUser: allow, - }, - User: { - email: isMyOwn, - password: isMyOwn, - privateKey: isMyOwn, - }, +const isDeletingOwnAccount = rule({ + cache: 'no_cache', +})(async (parent, args, context, info) => { + return context.user.id === args.id }) +// Permissions +const permissions = shield( + { + Query: { + '*': deny, + findPosts: allow, + Category: isAdmin, + Tag: isAdmin, + Report: isModerator, + Notification: isAdmin, + statistics: allow, + currentUser: allow, + Post: or(onlyEnabledContent, isModerator), + Comment: allow, + User: allow, + isLoggedIn: allow, + }, + Mutation: { + '*': deny, + login: allow, + UpdateNotification: belongsToMe, + CreateUser: isAdmin, + UpdateUser: onlyYourself, + CreatePost: isAuthenticated, + UpdatePost: isAuthor, + DeletePost: isAuthor, + report: isAuthenticated, + CreateBadge: isAdmin, + UpdateBadge: isAdmin, + DeleteBadge: isAdmin, + AddUserBadges: isAdmin, + CreateSocialMedia: isAuthenticated, + DeleteSocialMedia: isAuthenticated, + // AddBadgeRewarded: isAdmin, + // RemoveBadgeRewarded: isAdmin, + reward: isAdmin, + unreward: isAdmin, + // addFruitToBasket: isAuthenticated + follow: isAuthenticated, + unfollow: isAuthenticated, + shout: isAuthenticated, + unshout: isAuthenticated, + changePassword: isAuthenticated, + enable: isModerator, + disable: isModerator, + CreateComment: isAuthenticated, + DeleteComment: isAuthor, + DeleteUser: isDeletingOwnAccount, + }, + User: { + email: isMyOwn, + password: isMyOwn, + privateKey: isMyOwn, + }, + }, + { + fallbackRule: allow, + }, +) + export default permissions diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 79bba0a5d..4e060dc90 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -7,12 +7,14 @@ let headers const factory = Factory() beforeEach(async () => { - await factory.create('User', { email: 'user@example.org', password: '1234' }) + const adminParams = { role: 'admin', email: 'admin@example.org', password: '1234' } + await factory.create('User', adminParams) await factory.create('User', { email: 'someone@example.org', password: '1234', }) - headers = await login({ email: 'user@example.org', password: '1234' }) + // we need to be an admin, otherwise we're not authorized to create a user + headers = await login(adminParams) authenticatedClient = new GraphQLClient(host, { headers }) }) diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 9e2ec70a2..3bff53ddb 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -74,6 +74,22 @@ describe('CreatePost', () => { await expect(client.request(mutation)).resolves.toMatchObject(expected) }) }) + + describe('language', () => { + it('allows a user to set the language of the post', async () => { + const createPostWithLanguageMutation = ` + mutation { + CreatePost(title: "I am a title", content: "Some content", language: "en") { + language + } + } + ` + const expected = { CreatePost: { language: 'en' } } + await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual( + expect.objectContaining(expected), + ) + }) + }) }) }) diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index cf648a6bd..463c5ea6d 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -315,6 +315,8 @@ describe('change password', () => { describe('do not expose private RSA key', () => { let headers let client + let authenticatedClient + const queryUserPuplicKey = gql` query($queriedUserSlug: String) { User(slug: $queriedUserSlug) { @@ -332,7 +334,7 @@ describe('do not expose private RSA key', () => { } ` - const actionGenUserWithKeys = async () => { + const generateUserWithKeys = async authenticatedClient => { // Generate user with "privateKey" via 'CreateUser' mutation instead of using the factories "factory.create('User', {...})", see above. const variables = { id: 'bcb2d923-f3af-479e-9f00-61b12e864667', @@ -341,7 +343,7 @@ describe('do not expose private RSA key', () => { name: 'Apfel Strudel', email: 'apfel-strudel@test.org', } - await client.request( + await authenticatedClient.request( gql` mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String!) { CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) { @@ -353,14 +355,23 @@ describe('do not expose private RSA key', () => { ) } - // not authenticate beforeEach(async () => { + const adminParams = { + role: 'admin', + email: 'admin@example.org', + password: '1234', + } + // create an admin user who has enough permissions to create other users + await factory.create('User', adminParams) + const headers = await login(adminParams) + authenticatedClient = new GraphQLClient(host, { headers }) + // but also create an unauthenticated client to issue the `User` query client = new GraphQLClient(host) }) describe('unauthenticated query of "publicKey" (does the RSA key pair get generated at all?)', () => { it('returns publicKey', async () => { - await actionGenUserWithKeys() + await generateUserWithKeys(authenticatedClient) await expect( await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }), ).toEqual( @@ -378,7 +389,7 @@ describe('do not expose private RSA key', () => { describe('unauthenticated query of "privateKey"', () => { it('throws "Not Authorised!"', async () => { - await actionGenUserWithKeys() + await generateUserWithKeys(authenticatedClient) await expect( client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }), ).rejects.toThrow('Not Authorised') @@ -393,7 +404,7 @@ describe('do not expose private RSA key', () => { describe('authenticated query of "publicKey"', () => { it('returns publicKey', async () => { - await actionGenUserWithKeys() + await generateUserWithKeys(authenticatedClient) await expect( await client.request(queryUserPuplicKey, { queriedUserSlug: 'apfel-strudel' }), ).toEqual( @@ -411,7 +422,7 @@ describe('do not expose private RSA key', () => { describe('authenticated query of "privateKey"', () => { it('throws "Not Authorised!"', async () => { - await actionGenUserWithKeys() + await generateUserWithKeys(authenticatedClient) await expect( client.request(queryUserPrivateKey, { queriedUserSlug: 'apfel-strudel' }), ).rejects.toThrow('Not Authorised') diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 53bf0967e..c5c3701b5 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -11,5 +11,27 @@ export default { params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' }) return neo4jgraphql(object, params, context, resolveInfo, false) }, + DeleteUser: async (object, params, context, resolveInfo) => { + const { resource } = params + const session = context.driver.session() + + if (resource && resource.length) { + await Promise.all( + resource.map(async node => { + await session.run( + ` + MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) + SET resource.deleted = true + RETURN author`, + { + userId: context.user.id, + }, + ) + }), + ) + session.close() + } + return neo4jgraphql(object, params, context, resolveInfo, false) + }, }, } diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index a5c50f4f9..352d38eaa 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -1,6 +1,7 @@ import { GraphQLClient } from 'graphql-request' -import { host } from '../../jest/helpers' +import { login, host } from '../../jest/helpers' import Factory from '../../seed/factories' +import gql from 'graphql-tag' const factory = Factory() let client @@ -18,27 +19,58 @@ describe('users', () => { } } ` - client = new GraphQLClient(host) - - it('with password and email', async () => { + describe('given valid password and email', () => { const variables = { name: 'John Doe', password: '123', email: '123@123.de', } - const expected = { - CreateUser: { - id: expect.any(String), - }, - } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + + describe('unauthenticated', () => { + beforeEach(async () => { + client = new GraphQLClient(host) + }) + + it('is not allowed to create users', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + beforeEach(async () => { + const adminParams = { + role: 'admin', + email: 'admin@example.org', + password: '1234', + } + await factory.create('User', adminParams) + const headers = await login(adminParams) + client = new GraphQLClient(host, { headers }) + }) + + it('is allowed to create new users', async () => { + const expected = { + CreateUser: { + id: expect.any(String), + }, + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) }) }) describe('UpdateUser', () => { - beforeEach(async () => { - await factory.create('User', { id: 'u47', name: 'John Doe' }) - }) + const userParams = { + email: 'user@example.org', + password: '1234', + id: 'u47', + name: 'John Doe', + } + const variables = { + id: 'u47', + name: 'John Doughnut', + } const mutation = ` mutation($id: ID!, $name: String) { @@ -48,38 +80,199 @@ describe('users', () => { } } ` - client = new GraphQLClient(host) - it('name within specifications', async () => { - const variables = { - id: 'u47', - name: 'James Doe', - } - const expected = { - UpdateUser: { - id: 'u47', + beforeEach(async () => { + await factory.create('User', userParams) + }) + + describe('as another user', () => { + beforeEach(async () => { + const someoneElseParams = { + email: 'someoneElse@example.org', + password: '1234', name: 'James Doe', - }, - } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + } + + await factory.create('User', someoneElseParams) + const headers = await login(someoneElseParams) + client = new GraphQLClient(host, { headers }) + }) + + it('is not allowed to change other user accounts', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) }) - it('with no name', async () => { - const variables = { - id: 'u47', - name: null, + describe('as the same user', () => { + beforeEach(async () => { + const headers = await login(userParams) + client = new GraphQLClient(host, { headers }) + }) + + it('name within specifications', async () => { + const expected = { + UpdateUser: { + id: 'u47', + name: 'John Doughnut', + }, + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + + it('with no name', async () => { + const variables = { + id: 'u47', + name: null, + } + const expected = 'Username must be at least 3 characters long!' + await expect(client.request(mutation, variables)).rejects.toThrow(expected) + }) + + it('with too short name', async () => { + const variables = { + id: 'u47', + name: ' ', + } + const expected = 'Username must be at least 3 characters long!' + await expect(client.request(mutation, variables)).rejects.toThrow(expected) + }) + }) + }) + + describe('DeleteUser', () => { + let deleteUserVariables + let asAuthor + const deleteUserMutation = gql` + mutation($id: ID!, $resource: [String]) { + DeleteUser(id: $id, resource: $resource) { + id + contributions { + id + deleted + } + comments { + id + deleted + } + } } - const expected = 'Username must be at least 3 characters long!' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) + ` + beforeEach(async () => { + asAuthor = await factory.create('User', { + email: 'test@example.org', + password: '1234', + id: 'u343', + }) + await factory.create('User', { + email: 'friendsAccount@example.org', + password: '1234', + id: 'u565', + }) + deleteUserVariables = { id: 'u343', resource: [] } }) - it('with too short name', async () => { - const variables = { - id: 'u47', - name: ' ', - } - const expected = 'Username must be at least 3 characters long!' - await expect(client.request(mutation, variables)).rejects.toThrow(expected) + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( + 'Not Authorised', + ) + }) + }) + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { headers }) + }) + + describe("attempting to delete another user's account", () => { + it('throws an authorization error', async () => { + deleteUserVariables = { id: 'u565' } + await expect(client.request(deleteUserMutation, deleteUserVariables)).rejects.toThrow( + 'Not Authorised', + ) + }) + }) + + describe('attempting to delete my own account', () => { + let expectedResponse + beforeEach(async () => { + await asAuthor.authenticateAs({ + email: 'test@example.org', + password: '1234', + }) + await asAuthor.create('Post', { + id: 'p139', + content: 'Post by user u343', + }) + await asAuthor.create('Comment', { + id: 'c155', + postId: 'p139', + content: 'Comment by user u343', + }) + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: false }], + comments: [{ id: 'c155', deleted: false }], + }, + } + }) + it("deletes my account, but doesn't delete posts or comments by default", async () => { + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + describe("deletes a user's", () => { + it('posts on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Post'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: true }], + comments: [{ id: 'c155', deleted: false }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + it('comments on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Comment'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: false }], + comments: [{ id: 'c155', deleted: true }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + + it('posts and comments on request', async () => { + deleteUserVariables = { id: 'u343', resource: ['Post', 'Comment'] } + expectedResponse = { + DeleteUser: { + id: 'u343', + contributions: [{ id: 'p139', deleted: true }], + comments: [{ id: 'c155', deleted: true }], + }, + } + await expect(client.request(deleteUserMutation, deleteUserVariables)).resolves.toEqual( + expectedResponse, + ) + }) + }) + }) }) }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index ab8b25399..2a8be9e09 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -4,8 +4,9 @@ type Query { currentUser: User # Get the latest Network Statistics statistics: Statistics! - findPosts(filter: String!, limit: Int = 10): [Post]! @cypher( - statement: """ + findPosts(filter: String!, limit: Int = 10): [Post]! + @cypher( + statement: """ CALL db.index.fulltext.queryNodes('full_text_search', $filter) YIELD node as post, score MATCH (post)<-[:WROTE]-(user:User) @@ -14,8 +15,8 @@ type Query { AND NOT post.deleted = true AND NOT post.disabled = true RETURN post LIMIT $limit - """ - ) + """ + ) CommentByPost(postId: ID!): [Comment]! } @@ -23,7 +24,7 @@ type Mutation { # Get a JWT Token for the given Email and password login(email: String!, password: String!): String! signup(email: String!, password: String!): Boolean! - changePassword(oldPassword:String!, newPassword: String!): String! + changePassword(oldPassword: String!, newPassword: String!): String! report(id: ID!, description: String): Report disable(id: ID!): ID enable(id: ID!): ID @@ -37,6 +38,7 @@ type Mutation { follow(id: ID!, type: FollowTypeEnum): Boolean! # Unfollow the given Type and ID unfollow(id: ID!, type: FollowTypeEnum): Boolean! + DeleteUser(id: ID!, resource: [String]): User } type Statistics { @@ -53,7 +55,7 @@ type Statistics { type Notification { id: ID! - read: Boolean, + read: Boolean user: User @relation(name: "NOTIFIED", direction: "OUT") post: Post @relation(name: "NOTIFIED", direction: "IN") createdAt: String @@ -80,7 +82,8 @@ type Report { id: ID! submitter: User @relation(name: "REPORTED", direction: "IN") description: String - type: String! @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]") + type: String! + @cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]") createdAt: String comment: Comment @relation(name: "REPORTED", direction: "OUT") post: Post @relation(name: "REPORTED", direction: "OUT") @@ -131,4 +134,3 @@ type SocialMedia { url: String ownedBy: [User]! @relation(name: "OWNED", direction: "IN") } - diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 1179c3e20..271d92750 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -1,40 +1,3 @@ -enum FilterBubbleAuthorEnum { - following - all -} - -input FilterBubble { - author: FilterBubbleAuthorEnum -} - -type Query { - Post( - id: ID - activityId: String - objectId: String - title: String - slug: String - content: String - contentExcerpt: String - image: String - imageUpload: Upload - visibility: Visibility - deleted: Boolean - disabled: Boolean - createdAt: String - updatedAt: String - commentsCount: Int - shoutedCount: Int - shoutedByCurrentUser: Boolean - _id: String - first: Int - offset: Int - orderBy: [_PostOrdering] - filter: _PostFilter - filterBubble: FilterBubble - ): [Post] -} - type Post { id: ID! activityId: String @@ -52,29 +15,37 @@ type Post { disabledBy: User @relation(name: "DISABLED", direction: "IN") createdAt: String updatedAt: String - - relatedContributions: [Post]! @cypher( - statement: """ + language: String + relatedContributions: [Post]! + @cypher( + statement: """ MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post) RETURN DISTINCT post LIMIT 10 - """ - ) + """ + ) tags: [Tag]! @relation(name: "TAGGED", direction: "OUT") categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") comments: [Comment]! @relation(name: "COMMENTS", direction: "IN") - commentsCount: Int! @cypher(statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)") + commentsCount: Int! + @cypher( + statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)" + ) shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN") - shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") + shoutedCount: Int! + @cypher( + statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" + ) # Has the currently logged in user shouted that post? - shoutedByCurrentUser: Boolean! @cypher( - statement: """ + shoutedByCurrentUser: Boolean! + @cypher( + statement: """ MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1 - """ - ) + """ + ) } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 1287aa45f..6836f16fe 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -56,14 +56,14 @@ type User { contributionsCount: Int! @cypher( statement: """ MATCH (this)-[:WROTE]->(r:Post) - WHERE (NOT exists(r.deleted) OR r.deleted = false) - AND (NOT exists(r.disabled) OR r.disabled = false) + WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r) """ ) comments: [Comment]! @relation(name: "WROTE", direction: "OUT") commentsCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)") + commentedCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment)-[:COMMENTS]->(p:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true AND NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))") shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") @@ -77,4 +77,4 @@ type User { badges: [Badge]! @relation(name: "REWARDED", direction: "IN") badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") -} \ No newline at end of file +} diff --git a/backend/test/features/activity-follow.feature b/backend/test/features/activity-follow.feature index 3cfe73340..7aa0c447d 100644 --- a/backend/test/features/activity-follow.feature +++ b/backend/test/features/activity-follow.feature @@ -10,6 +10,7 @@ Feature: Follow a user | stuart-little | | tero-vota | + @wip Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection When I send a POST request with the following activity to "/activitypub/users/tero-vota/inbox": """ diff --git a/backend/test/features/activity-like.feature b/backend/test/features/activity-like.feature index ec8c99110..26ef9c857 100644 --- a/backend/test/features/activity-like.feature +++ b/backend/test/features/activity-like.feature @@ -27,6 +27,7 @@ Feature: Like an object like an article or note } """ + @wip Scenario: Send a like of a person to an users inbox and make sure it's added to the likes collection When I send a POST request with the following activity to "/activitypub/users/karl-heinz/inbox": """ diff --git a/backend/yarn.lock b/backend/yarn.lock index 9c8fdc3c5..d2c5da176 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1029,10 +1029,10 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" -"@types/express@4.16.1": - version "4.16.1" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.16.1.tgz#d756bd1a85c34d87eaf44c888bad27ba8a4b7cf0" - integrity sha512-V0clmJow23WeyblmACoxbHBu2JKlE5TiIme6Lem14FnPW9gsttyHtk6wq7njcdIWH1njAaFgR8gW09lgY98gQg== +"@types/express@4.17.0": + version "4.17.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.0.tgz#49eaedb209582a86f12ed9b725160f12d04ef287" + integrity sha512-CjaMu57cjgjuZbh9DpkloeGxV45CnMGlVd+XpG7Gm9QgVrd7KFq+X4HY0vM+2v0bczS48Wg7bvnMY5TN+Xmcfw== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "*" @@ -1119,10 +1119,10 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0" integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA== -"@types/yup@0.26.14": - version "0.26.14" - resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.14.tgz#d31f3b9a04039cca70ebb4db4d6c7fc3f694e80b" - integrity sha512-OcBtVLHvYULVSltpuBdhFiVOKoSsOS58D872HydO93oBf3OdGq5zb+LnqGo18TNNSV2aW8hjIdS6H+wp68zFtQ== +"@types/yup@0.26.16": + version "0.26.16" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.16.tgz#75c428236207c48d9f8062dd1495cda8c5485a15" + integrity sha512-E2RNc7DSeQ+2EIJ1H3+yFjYu6YiyQBUJ7yNpIxomrYJ3oFizLZ5yDS3T1JTUNBC2OCRkgnhLS0smob5UuCHfNA== "@types/zen-observable@^0.5.3": version "0.5.4" @@ -1141,6 +1141,13 @@ dependencies: tslib "^1.9.3" +"@wry/equality@^0.1.2": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.7.tgz#512234d078341c32cabda66b89b5dddb5741d9b9" + integrity sha512-p1rhJ6PQzpsBr9cMJMHvvx3LQEA28HFX7fAQx6khAX+1lufFeBuk+iRCAyHwj3v6JbpGKvHNa66f+9cpU8c7ew== + dependencies: + tslib "^1.9.3" + abab@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" @@ -1281,13 +1288,13 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -apollo-cache-control@0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.7.1.tgz#3d4fba232f561f096f61051e103bf58ee4bf8b54" - integrity sha512-3h1TEoMnzex6IIiFb5Ja3owTyLwT5YzK69cRgrSpSscdpYc3ID4KVs0Ht9cbOUmb/L/UKtYVkRC8KeVAYmHEjQ== +apollo-cache-control@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.7.2.tgz#b8852422d973c582493e85c776abc9c660090162" + integrity sha512-7prjFN8H9lRE0npqGG8kM3XICvNCcgQt6eCy8kkcPOIZwM+F8m8ShjEfNF9UWW32i+poOk3G67HegPRyjCc6/Q== dependencies: apollo-server-env "2.4.0" - graphql-extensions "0.7.1" + graphql-extensions "0.7.2" apollo-cache-control@^0.1.0: version "0.1.1" @@ -1296,34 +1303,34 @@ apollo-cache-control@^0.1.0: dependencies: graphql-extensions "^0.0.x" -apollo-cache-inmemory@~1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.1.tgz#536b6f366461f6264250041f9146363e2faa1d4c" - integrity sha512-c/WJjh9MTWcdussCTjLKufpPjTx3qOFkBPHIDOOpQ+U0B7K1PczPl9N0LaC4ir3wAWL7s4A0t2EKtoR+6UP92g== +apollo-cache-inmemory@~1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.2.tgz#bbf2e4e1eacdf82b2d526f5c2f3b37e5acee3c5e" + integrity sha512-AyCl3PGFv5Qv1w4N9vlg63GBPHXgMCekZy5mhlS042ji0GW84uTySX+r3F61ZX3+KM1vA4m9hQyctrEGiv5XjQ== dependencies: - apollo-cache "^1.3.1" - apollo-utilities "^1.3.1" + apollo-cache "^1.3.2" + apollo-utilities "^1.3.2" optimism "^0.9.0" ts-invariant "^0.4.0" tslib "^1.9.3" -apollo-cache@1.3.1, apollo-cache@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.1.tgz#c015f93a9a7f32b3eeea0c471addd6e854da754c" - integrity sha512-BJ/Mehr3u6XCaHYSmgZ6DM71Fh30OkW6aEr828WjHvs+7i0RUuP51/PM7K6T0jPXtuw7UbArFFPZZsNgXnyyJA== +apollo-cache@1.3.2, apollo-cache@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a" + integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg== dependencies: - apollo-utilities "^1.3.1" + apollo-utilities "^1.3.2" tslib "^1.9.3" -apollo-client@~2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.1.tgz#fcf328618d6ad82b750a988bec113fe6edc8ba94" - integrity sha512-Tb6ZthPZUHlGqeoH1WC8Qg/tLnkk9H5+xj4e5nzOAC6dCOW3pVU9tYXscrWdmZ65UDUg1khvTNjrQgPhdf4aTQ== +apollo-client@~2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.2.tgz#03b6af651e09b6e413e486ddc87464c85bd6e514" + integrity sha512-oks1MaT5x7gHcPeC8vPC1UzzsKaEIC0tye+jg72eMDt5OKc7BobStTeS/o2Ib3e0ii40nKxGBnMdl/Xa/p56Yg== dependencies: "@types/zen-observable" "^0.8.0" - apollo-cache "1.3.1" + apollo-cache "1.3.2" apollo-link "^1.0.0" - apollo-utilities "1.3.1" + apollo-utilities "1.3.2" symbol-observable "^1.0.2" ts-invariant "^0.4.0" tslib "^1.9.3" @@ -1337,24 +1344,24 @@ apollo-datasource@0.5.0: apollo-server-caching "0.4.0" apollo-server-env "2.4.0" -apollo-engine-reporting-protobuf@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.3.0.tgz#2c764c054ff9968387cf16115546e0d5b04ee9f1" - integrity sha512-PYowpx/E+TJT/8nKpp3JmJuKh3x1SZcxDF6Cquj0soV205TUpFFCZQMi91i5ACiEp2AkYvM/GDBIrw+rfIwzTg== +apollo-engine-reporting-protobuf@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.3.1.tgz#a581257fa8e3bb115ce38bf1b22e052d1475ad69" + integrity sha512-Ui3nPG6BSZF8BEqxFs6EkX6mj2OnFLMejxEHSOdM82bakyeouCGd7J0fiy8AD6liJoIyc4X7XfH4ZGGMvMh11A== dependencies: protobufjs "^6.8.6" -apollo-engine-reporting@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.2.1.tgz#0b77fad2e9221d62f4a29b8b4fab8f7f47dcc1d6" - integrity sha512-DVXZhz/nSZR4lphakjb1guAD0qJ7Wm1PVtZEBjN097cnOor4XSOzQlPfTaYtVuhlxUKUuCx1syiBbOuV8sKqXg== +apollo-engine-reporting@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.1.tgz#f2c2c63f865871a57c15cdbb2a3bcd4b4af28115" + integrity sha512-e0Xp+0yite8DH/xm9fnJt42CxfWAcY6waiq3icCMAgO9T7saXzVOPpl84SkuA+hIJUBtfaKrTnC+7Jxi/I7OrQ== dependencies: - apollo-engine-reporting-protobuf "0.3.0" + apollo-engine-reporting-protobuf "0.3.1" apollo-graphql "^0.3.0" - apollo-server-core "2.6.1" + apollo-server-core "2.6.3" apollo-server-env "2.4.0" async-retry "^1.2.1" - graphql-extensions "0.7.1" + graphql-extensions "0.7.2" apollo-env@0.5.1: version "0.5.1" @@ -1424,24 +1431,24 @@ apollo-server-caching@0.4.0: dependencies: lru-cache "^5.0.0" -apollo-server-core@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.1.tgz#d0d878b0a4959b6c661fc43300ce45b29996176a" - integrity sha512-jO2BtcP7ozSSK5qtw1gGDwO66WSNtzhvpDJD7erkA9byv8Z0jB2QIPNWN6iaj311LaPahM05k+8hMIhFy9oHWg== +apollo-server-core@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.3.tgz#786c8251c82cf29acb5cae9635a321f0644332ae" + integrity sha512-tfC0QO1NbJW3ShkB5pRCnUaYEkW2AwnswaTeedkfv//EO3yiC/9LeouCK5F22T8stQG+vGjvCqf0C8ldI/XsIA== dependencies: "@apollographql/apollo-tools" "^0.3.6" "@apollographql/graphql-playground-html" "1.6.20" "@types/ws" "^6.0.0" - apollo-cache-control "0.7.1" + apollo-cache-control "0.7.2" apollo-datasource "0.5.0" - apollo-engine-reporting "1.2.1" + apollo-engine-reporting "1.3.1" apollo-server-caching "0.4.0" apollo-server-env "2.4.0" apollo-server-errors "2.3.0" - apollo-server-plugin-base "0.5.1" - apollo-tracing "0.7.1" + apollo-server-plugin-base "0.5.2" + apollo-tracing "0.7.2" fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.7.1" + graphql-extensions "0.7.2" graphql-subscriptions "^1.0.0" graphql-tag "^2.9.2" graphql-tools "^4.0.0" @@ -1472,18 +1479,18 @@ apollo-server-errors@2.3.0: resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.0.tgz#700622b66a16dffcad3b017e4796749814edc061" integrity sha512-rUvzwMo2ZQgzzPh2kcJyfbRSfVKRMhfIlhY7BzUfM4x6ZT0aijlgsf714Ll3Mbf5Fxii32kD0A/DmKsTecpccw== -apollo-server-express@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.1.tgz#1e2649d3fd38c0c0a2c830090fd41e086b259c9f" - integrity sha512-TVu68LVp+COMGOXuxc0OFeCUQiPApxy7Isv2Vk85nikZV4t4FXlODB6PrRKf5rfvP31dvGsfE6GHPJTLLbKfyg== +apollo-server-express@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.3.tgz#62034c978f84207615c0430fb37ab006f71146fe" + integrity sha512-8ca+VpKArgNzFar0D3DesWnn0g9YDtFLhO56TQprHh2Spxu9WxTnYNjsYs2MCCNf+iV/uy7vTvEknErvnIcZaQ== dependencies: "@apollographql/graphql-playground-html" "1.6.20" "@types/accepts" "^1.3.5" "@types/body-parser" "1.17.0" "@types/cors" "^2.8.4" - "@types/express" "4.16.1" + "@types/express" "4.17.0" accepts "^1.3.5" - apollo-server-core "2.6.1" + apollo-server-core "2.6.3" body-parser "^1.18.3" cors "^2.8.4" graphql-subscriptions "^1.0.0" @@ -1511,36 +1518,36 @@ apollo-server-module-graphiql@^1.3.4, apollo-server-module-graphiql@^1.4.0: resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec" integrity sha512-GmkOcb5he2x5gat+TuiTvabnBf1m4jzdecal3XbXBh/Jg+kx4hcvO3TTDFQ9CuTprtzdcVyA11iqG7iOMOt7vA== -apollo-server-plugin-base@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.1.tgz#b81056666763879bdc98d8d58f3c4c43cbb30da6" - integrity sha512-UejnBk6XDqYQ+Ydkbm+gvlOzP+doQA8glVUULs8rCi0/MshsFSsBVl6rtzruELDBVgZhJgGsd4pUexcvNc3aZA== +apollo-server-plugin-base@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.2.tgz#f97ba983f1e825fec49cba8ff6a23d00e1901819" + integrity sha512-j81CpadRLhxikBYHMh91X4aTxfzFnmmebEiIR9rruS6dywWCxV2aLW87l9ocD1MiueNam0ysdwZkX4F3D4csNw== -apollo-server-testing@~2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.6.1.tgz#447f34980819fa52b120f26c632fab4efc55435b" - integrity sha512-Qq0u79uKw3g14bq0nGxtUUiueFOv2ETkAax2mum+3f9Lm85VXELkY6c9bCWDVGjkUtt9Aog5qwSzWELb1KiUug== +apollo-server-testing@~2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.6.3.tgz#a0199a5d42000e60ecf0dea44b851f5f581e280e" + integrity sha512-LTkegcGVSkM+pA0FINDSYVl3TiFYKZyfjlKrEr/LN6wLiL6gbRgy6LMtk2j+qli/bnTDqqQREX8OEqmV8FKUoQ== dependencies: - apollo-server-core "2.6.1" + apollo-server-core "2.6.3" -apollo-server@~2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.1.tgz#1b1fc6020b75c0913550da5fa0f2005c62f1bc53" - integrity sha512-Ed0zZjluRYPMC3Yr6oXQjcR11izu86nkjiS2MhjJA1mF8IXJfxbPp2hnX4Jf4vXPSkOP2e5ZHw0cdaIcu9GnRw== +apollo-server@~2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.3.tgz#71235325449c6d3881a5143975ca44c07a07d2d7" + integrity sha512-pTIXE5xEMAikKLTIBIqLNvimMETiZbzmiqDb6BGzIUicAz4Rxa1/+bDi1ZeJWrZQjE/TfBLd2Si3qam7dZGrjw== dependencies: - apollo-server-core "2.6.1" - apollo-server-express "2.6.1" + apollo-server-core "2.6.3" + apollo-server-express "2.6.3" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.7.1.tgz#6a7356b619f3aa0ca22c623b5d8bb1af5ca1c74c" - integrity sha512-1BYQua+jCWFkZZJP0/rSpzY4XbLLbCrRHCYu8sJn0RCH/hs34BMdFXscS9uSglgIpXwUAIafgsU0hAVCrJjbTw== +apollo-tracing@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.7.2.tgz#7730159a4670bca465ac1bfa01f9902610a7aba4" + integrity sha512-bT4/n8Vy9DweC3+XWJelJD41FBlKMXR0OVxjLMiCe9clb4yTgKhYxRGTyh9KjmhWsng9gG/DphO0ixWsOgdXmA== dependencies: apollo-server-env "2.4.0" - graphql-extensions "0.7.1" + graphql-extensions "0.7.2" apollo-tracing@^0.1.0: version "0.1.4" @@ -1559,13 +1566,13 @@ apollo-upload-server@^7.0.0: http-errors "^1.7.0" object-path "^0.11.4" -apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.1.tgz#4c45f9b52783c324e2beef822700bdea374f82d1" - integrity sha512-P5cJ75rvhm9hcx9V/xCW0vlHhRd0S2icEcYPoRYNTc5djbynpuO+mQuJ4zMHgjNDpvvDxDfZxXTJ6ZUuJZodiQ== +apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" + integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== dependencies: + "@wry/equality" "^0.1.2" fast-json-stable-stringify "^2.0.0" - lodash.isequal "^4.5.0" ts-invariant "^0.4.0" tslib "^1.9.3" @@ -2579,10 +2586,10 @@ data-urls@^1.0.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" -date-fns@2.0.0-alpha.29: - version "2.0.0-alpha.29" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.29.tgz#9d4a36e3ebba63d009e957fea8fdfef7921bc6cb" - integrity sha512-AIFZ0hG/1fdb7HZHTDyiEJdNiaFyZxXcx/kF8z3I9wxbhkN678KrrLSneKcsb0Xy5KqCA4wCIxmGpdVWSNZnpA== +date-fns@2.0.0-alpha.33: + version "2.0.0-alpha.33" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.33.tgz#c2f73c3cc50ac301c9217eb93603c9bc40e891bf" + integrity sha512-tqUVEk3oxnJuNIvwAMKHAMo4uFRG0zXvjxZQll+BonoPt+m4NMcUgO14NDxbHuy7uYcrVErd2GdSsw02EDZQ7w== debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" @@ -3704,10 +3711,10 @@ graphql-deduplicator@^2.0.1: resolved "https://registry.yarnpkg.com/graphql-deduplicator/-/graphql-deduplicator-2.0.2.tgz#d8608161cf6be97725e178df0c41f6a1f9f778f3" integrity sha512-0CGmTmQh4UvJfsaTPppJAcHwHln8Ayat7yXXxdnuWT+Mb1dBzkbErabCWzjXyKh/RefqlGTTA7EQOZHofMaKJA== -graphql-extensions@0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.1.tgz#f55b01ac8ddf09a215e21f34caeee3ae66a88f21" - integrity sha512-4NkAz/f0j5a1DSPl3V77OcesBmwhHz56Soj0yTImlcDdRv9knyO2e+ehi1TIeKBOyIKS7d3A7zqOW/4ieGxlVA== +graphql-extensions@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.7.2.tgz#8711543f835661eaf24b48d6ac2aad44dbbd5506" + integrity sha512-TuVINuAOrEtzQAkAlCZMi9aP5rcZ+pVaqoBI5fD2k5O9fmb8OuXUQOW028MUhC66tg4E7h4YSF1uYUIimbu4SQ== dependencies: "@apollographql/apollo-tools" "^0.3.6" @@ -3765,12 +3772,12 @@ graphql-request@~1.8.2: dependencies: cross-fetch "2.2.2" -graphql-shield@~5.3.6: - version "5.3.6" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.6.tgz#20061b02f77056c0870a623c530ef28a1bf4fff4" - integrity sha512-ihw/i4X+d1kpj1SVA6iBkVl2DZhPsI+xV08geR2TX3FWhpU7zakk/16yBzDRJTTCUgKsWfgyebrgIBsuhTwMnA== +graphql-shield@~5.3.8: + version "5.3.8" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.3.8.tgz#f9e7ad2285f6cfbe20a8a49154ce6c1b184e3893" + integrity sha512-33rQ8U5jMurHIapctHk7hBcUg3nxC7fmMIMtyWiomJXhBmztFq/SG7jNaapnL5M7Q/0BmoaSQd3FLSpelP9KPw== dependencies: - "@types/yup" "0.26.14" + "@types/yup" "0.26.16" lightercollective "^0.3.0" object-hash "^1.3.1" yup "^0.27.0" @@ -5230,11 +5237,6 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= - lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" @@ -5520,7 +5522,7 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= -ms@2.1.1, ms@^2.1.1, ms@~2.1.1: +ms@2.1.1, ms@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== @@ -6228,10 +6230,10 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@~1.17.1: - version "1.17.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.17.1.tgz#ed64b4e93e370cb8a25b9ef7fef3e4fd1c0995db" - integrity sha512-TzGRNvuUSmPgwivDqkZ9tM/qTGW9hqDKWOE9YHiyQdixlKbv7kvEqsmDPrcHJTKwthU774TQwZXVtaQ/mMsvjg== +prettier@~1.18.2: + version "1.18.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" + integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw== pretty-format@^24.8.0: version "24.8.0" diff --git a/deployment/legacy-migration/maintenance-worker/binaries/.env b/deployment/legacy-migration/maintenance-worker/binaries/.env new file mode 100644 index 000000000..773918095 --- /dev/null +++ b/deployment/legacy-migration/maintenance-worker/binaries/.env @@ -0,0 +1,6 @@ +# SSH Access +# SSH_USERNAME='username' +# SSH_HOST='example.org' + +# UPLOADS_DIRECTORY=/var/www/api/uploads +OUTPUT_DIRECTORY='/uploads/' \ No newline at end of file diff --git a/deployment/legacy-migration/maintenance-worker/binaries/import_legacy_uploads b/deployment/legacy-migration/maintenance-worker/binaries/import_legacy_uploads index 11fd81623..5c0b67d74 100755 --- a/deployment/legacy-migration/maintenance-worker/binaries/import_legacy_uploads +++ b/deployment/legacy-migration/maintenance-worker/binaries/import_legacy_uploads @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -e +# import .env config +set -o allexport +source $(dirname "$0")/.env +set +o allexport + for var in "SSH_USERNAME" "SSH_HOST" "UPLOADS_DIRECTORY" do if [[ -z "${!var}" ]]; then @@ -9,4 +14,4 @@ do fi done -rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/ /uploads/ +rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/ ${OUTPUT_DIRECTORY} diff --git a/package.json b/package.json index dd7454c54..fed6c742b 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "codecov": "^3.5.0", "cross-env": "^5.2.0", "cypress": "^3.3.1", - "cypress-cucumber-preprocessor": "^1.11.2", - "cypress-file-upload": "^3.1.2", + "cypress-cucumber-preprocessor": "^1.12.0", + "cypress-file-upload": "^3.1.4", "cypress-plugin-retries": "^1.2.2", "dotenv": "^8.0.0", "faker": "^4.1.0", diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index db967e973..560249b4a 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -10,7 +10,7 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1); &::before { @include border-radius($border-radius-x-large); box-shadow: inset 0 0 0 5px $color-danger; - content: ""; + content: ''; display: block; position: absolute; width: 100%; @@ -102,10 +102,10 @@ hr { height: 1px !important; } -[class$=menu-trigger] { +[class$='menu-trigger'] { user-select: none; } -[class$=menu-popover] { +[class$='menu-popover'] { display: inline-block; nav { @@ -145,10 +145,11 @@ hr { } } -[class$="menu-popover"] { +[class$='menu-popover'] { min-width: 130px; - a, button { + a, + button { display: flex; align-content: center; align-items: center; diff --git a/webapp/components/Avatar/Avatar.spec.js b/webapp/components/Avatar/Avatar.spec.js index ae91fecfe..d3ebcb030 100644 --- a/webapp/components/Avatar/Avatar.spec.js +++ b/webapp/components/Avatar/Avatar.spec.js @@ -1,9 +1,11 @@ import { mount, createLocalVue } from '@vue/test-utils' import Styleguide from '@human-connection/styleguide' import Avatar from './Avatar.vue' +import Filters from '~/plugins/vue-filters' const localVue = createLocalVue() localVue.use(Styleguide) +localVue.use(Filters) describe('Avatar.vue', () => { let propsData = {} diff --git a/webapp/components/Avatar/Avatar.vue b/webapp/components/Avatar/Avatar.vue index 0d997c745..ec2f9b28b 100644 --- a/webapp/components/Avatar/Avatar.vue +++ b/webapp/components/Avatar/Avatar.vue @@ -1,5 +1,10 @@ + diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index c312a401b..030ad20da 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -9,9 +9,11 @@ localVue.use(Styleguide) describe('FilterMenu.vue', () => { let wrapper let mocks + let propsData const createWrapper = mountMethod => { return mountMethod(FilterMenu, { + propsData, mocks, localVue, }) @@ -19,35 +21,48 @@ describe('FilterMenu.vue', () => { beforeEach(() => { mocks = { $t: () => {} } + propsData = {} }) - describe('mount', () => { + describe('given a user', () => { beforeEach(() => { - wrapper = createWrapper(mount) + propsData = { + user: { + id: '4711', + }, + } }) - it('renders a card', () => { - expect(wrapper.is('.ds-card')).toBe(true) - }) - - describe('click "filter-by-followed-authors-only" button', () => { - it('emits filterBubble object', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')).toBeTruthy() + describe('mount', () => { + beforeEach(() => { + wrapper = createWrapper(mount) }) - it('toggles filterBubble.author property', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([{ author: 'following' }]) - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{ author: 'all' }]) + it('renders a card', () => { + expect(wrapper.is('.ds-card')).toBe(true) }) - it('makes button primary', () => { - wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') - expect( - wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'), - ).toBe(true) + describe('click "filter-by-followed-authors-only" button', () => { + it('emits filterBubble object', () => { + wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') + expect(wrapper.emitted('changeFilterBubble')).toBeTruthy() + }) + + it('toggles filterBubble.author property', () => { + wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') + expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([ + { author: { followedBy_some: { id: '4711' } } }, + ]) + wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') + expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{}]) + }) + + it('makes button primary', () => { + wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click') + expect( + wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'), + ).toBe(true) + }) }) }) }) diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue index a2195a5fd..70dd3c236 100644 --- a/webapp/components/FilterMenu/FilterMenu.vue +++ b/webapp/components/FilterMenu/FilterMenu.vue @@ -11,7 +11,7 @@ @@ -22,24 +22,30 @@ diff --git a/webapp/components/Image/spec.js b/webapp/components/Image/spec.js deleted file mode 100644 index be568964a..000000000 --- a/webapp/components/Image/spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { shallowMount } from '@vue/test-utils' -import Image from '.' - -describe('Image', () => { - let propsData = { imageProps: { class: 'hc-badge', src: '' } } - - const Wrapper = () => { - return shallowMount(Image, { propsData }) - } - - it('renders', () => { - expect(Wrapper().is('img')).toBe(true) - }) - - it('passes properties down to `img`', () => { - expect(Wrapper().classes()).toEqual(['hc-badge']) - }) - - describe('given a relative `src`', () => { - beforeEach(() => { - propsData.imageProps.src = '/img/badges/fundraisingbox_de_airship.svg' - }) - - it('adds a prefix to load the image from the backend', () => { - expect(Wrapper().attributes('src')).toBe('/api/img/badges/fundraisingbox_de_airship.svg') - }) - }) - - describe('given an absolute `src`', () => { - beforeEach(() => { - propsData.imageProps.src = 'http://lorempixel.com/640/480/animals' - }) - - it('keeps the URL as is', () => { - // e.g. our seeds have absolute image URLs - expect(Wrapper().attributes('src')).toBe('http://lorempixel.com/640/480/animals') - }) - }) -}) diff --git a/webapp/components/LoadMore.vue b/webapp/components/LoadMore.vue index 3d583ef50..ff8d4e6c4 100644 --- a/webapp/components/LoadMore.vue +++ b/webapp/components/LoadMore.vue @@ -1,5 +1,5 @@ diff --git a/webapp/components/PostCard/spec.js b/webapp/components/PostCard/spec.js deleted file mode 100644 index 8f818b26b..000000000 --- a/webapp/components/PostCard/spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' -import PostCard from '.' -import Styleguide from '@human-connection/styleguide' -import Vuex from 'vuex' -import Filters from '~/plugins/vue-filters' - -const localVue = createLocalVue() - -localVue.use(Vuex) -localVue.use(Styleguide) -localVue.use(Filters) - -config.stubs['no-ssr'] = '' -config.stubs['v-popover'] = '' - -describe('PostCard', () => { - let stubs - let mocks - let propsData - let getters - - beforeEach(() => { - propsData = {} - stubs = { - NuxtLink: RouterLinkStub, - } - mocks = { - $t: jest.fn(), - } - getters = { - 'auth/user': () => { - return {} - }, - } - }) - - const Wrapper = () => { - const store = new Vuex.Store({ - getters, - }) - return mount(PostCard, { - stubs, - mocks, - propsData, - store, - localVue, - }) - } - - describe('given a post', () => { - beforeEach(() => { - propsData.post = { - title: "It's a title", - } - }) - - it('renders title', () => { - expect(Wrapper().text()).toContain("It's a title") - }) - }) -}) diff --git a/webapp/components/ReleaseModal/ReleaseModal.spec.js b/webapp/components/ReleaseModal/ReleaseModal.spec.js index 766d981f8..bb7281ab7 100644 --- a/webapp/components/ReleaseModal/ReleaseModal.spec.js +++ b/webapp/components/ReleaseModal/ReleaseModal.spec.js @@ -23,12 +23,15 @@ describe('ReleaseModal.vue', () => { truncate: a => a, }, $toast: { - success: () => {}, - error: () => {}, + success: jest.fn(), + error: jest.fn(), }, $t: jest.fn(), $apollo: { - mutate: jest.fn().mockResolvedValue(), + mutate: jest + .fn() + .mockResolvedValueOnce({ enable: 'u4711' }) + .mockRejectedValue({ message: 'Not Authorised!' }), }, location: { reload: jest.fn(), @@ -146,6 +149,10 @@ describe('ReleaseModal.vue', () => { wrapper.find('button.confirm').trigger('click') }) + afterEach(() => { + jest.clearAllMocks() + }) + it('calls mutation', () => { expect(mocks.$apollo.mutate).toHaveBeenCalled() }) @@ -169,6 +176,18 @@ describe('ReleaseModal.vue', () => { expect(wrapper.emitted().close).toBeTruthy() }) }) + + describe('handles errors', () => { + beforeEach(() => { + wrapper = Wrapper() + // second submission causes mutation to reject + wrapper.find('button.confirm').trigger('click') + }) + + it('shows an error toaster when mutation rejects', async () => { + await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') + }) + }) }) }) }) diff --git a/webapp/components/ReleaseModal/ReleaseModal.vue b/webapp/components/ReleaseModal/ReleaseModal.vue index f414d4328..dace3d665 100644 --- a/webapp/components/ReleaseModal/ReleaseModal.vue +++ b/webapp/components/ReleaseModal/ReleaseModal.vue @@ -40,6 +40,8 @@ export default { }, methods: { cancel() { + // TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!! + // await this.modalData.buttons.cancel.callback() this.isOpen = false setTimeout(() => { this.$emit('close') @@ -47,6 +49,8 @@ export default { }, async confirm() { try { + // TODO: Use the "modalData" structure introduced in "ConfirmModal" and refactor this here. Be aware that all the Jest tests have to be refactored as well !!! + // await this.modalData.buttons.confirm.callback() await this.$apollo.mutate({ mutation: gql` mutation($id: ID!) { @@ -57,17 +61,9 @@ export default { }) this.$toast.success(this.$t('release.success')) this.isOpen = false - /* - setTimeout(() => { - location.reload() - }, 1500) - */ setTimeout(() => { this.$emit('close') }, 1000) - setTimeout(() => { - location.reload() - }, 250) } catch (err) { this.$toast.error(err.message) } diff --git a/webapp/components/User/spec.js b/webapp/components/User/spec.js index 56bdc10f1..312615a5b 100644 --- a/webapp/components/User/spec.js +++ b/webapp/components/User/spec.js @@ -2,6 +2,7 @@ import { mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' import User from './index' import Vuex from 'vuex' import VTooltip from 'v-tooltip' +import Filters from '~/plugins/vue-filters' import Styleguide from '@human-connection/styleguide' @@ -11,6 +12,7 @@ const filter = jest.fn(str => str) localVue.use(Vuex) localVue.use(VTooltip) localVue.use(Styleguide) +localVue.use(Filters) localVue.filter('truncate', filter) diff --git a/webapp/components/comments/CommentList/CommentList.spec.js b/webapp/components/comments/CommentList/CommentList.spec.js index 9bfa13ea5..b15e4f7d6 100644 --- a/webapp/components/comments/CommentList/CommentList.spec.js +++ b/webapp/components/comments/CommentList/CommentList.spec.js @@ -3,11 +3,13 @@ import CommentList from '.' import Empty from '~/components/Empty' import Vuex from 'vuex' import Styleguide from '@human-connection/styleguide' +import Filters from '~/plugins/vue-filters' const localVue = createLocalVue() localVue.use(Styleguide) localVue.use(Vuex) +localVue.use(Filters) localVue.filter('truncate', string => string) config.stubs['v-popover'] = '' @@ -22,7 +24,9 @@ describe('CommentList.vue', () => { let data propsData = { - post: { id: 1 }, + post: { + id: 1, + }, } store = new Vuex.Store({ getters: { @@ -33,6 +37,9 @@ describe('CommentList.vue', () => { }) mocks = { $t: jest.fn(), + $filters: { + truncate: a => a, + }, $apollo: { queries: { Post: { @@ -49,13 +56,24 @@ describe('CommentList.vue', () => { describe('shallowMount', () => { const Wrapper = () => { - return mount(CommentList, { store, mocks, localVue, propsData, data }) + return mount(CommentList, { + store, + mocks, + localVue, + propsData, + data, + }) } beforeEach(() => { wrapper = Wrapper() wrapper.setData({ - comments: [{ id: 'c1', contentExcerpt: 'this is a comment' }], + comments: [ + { + id: 'c1', + contentExcerpt: 'this is a comment', + }, + ], }) }) diff --git a/webapp/components/utils/PostHelpers.js b/webapp/components/utils/PostHelpers.js new file mode 100644 index 000000000..5ff912a97 --- /dev/null +++ b/webapp/components/utils/PostHelpers.js @@ -0,0 +1,35 @@ +import PostMutations from '~/graphql/PostMutations.js' + +export function postMenuModalsData(truncatedPostName, confirmCallback, cancelCallback = () => {}) { + return { + delete: { + titleIdent: 'delete.contribution.title', + messageIdent: 'delete.contribution.message', + messageParams: { + name: truncatedPostName, + }, + buttons: { + confirm: { + danger: true, + icon: 'trash', + textIdent: 'delete.submit', + callback: confirmCallback, + }, + cancel: { + icon: 'close', + textIdent: 'delete.cancel', + callback: cancelCallback, + }, + }, + }, + } +} + +export function deletePostMutation(postId) { + return { + mutation: PostMutations().DeletePost, + variables: { + id: postId, + }, + } +} diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index dbaa4cd78..ad629bee7 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -1,28 +1,37 @@ import gql from 'graphql-tag' -export default app => { +export default () => { return { - CreatePost: gql(` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { + CreatePost: gql` + mutation($title: String!, $content: String!, $language: String) { + CreatePost(title: $title, content: $content, language: $language) { id title slug content contentExcerpt + language } } - `), - UpdatePost: gql(` - mutation($id: ID!, $title: String!, $content: String!) { - UpdatePost(id: $id, title: $title, content: $content) { + `, + UpdatePost: gql` + mutation($id: ID!, $title: String!, $content: String!, $language: String) { + UpdatePost(id: $id, title: $title, content: $content, language: $language) { id title slug content contentExcerpt + language } } - `), + `, + DeletePost: gql` + mutation($id: ID!) { + DeletePost(id: $id) { + id + } + } + `, } } diff --git a/webapp/graphql/UserProfile/Post.js b/webapp/graphql/UserProfile/Post.js new file mode 100644 index 000000000..c33572a9e --- /dev/null +++ b/webapp/graphql/UserProfile/Post.js @@ -0,0 +1,38 @@ +import gql from 'graphql-tag' + +export default i18n => { + const lang = i18n.locale().toUpperCase() + return gql(` + query Post($filter: _PostFilter, $first: Int, $offset: Int) { + Post(filter: $filter, first: $first, offset: $offset, orderBy: createdAt_desc) { + id + slug + title + contentExcerpt + shoutedCount + commentsCount + deleted + image + createdAt + disabled + deleted + categories { + id + name + icon + } + author { + id + slug + avatar + name + disabled + deleted + location { + name: name${lang} + } + } + } + } + `) +} diff --git a/webapp/graphql/UserProfileQuery.js b/webapp/graphql/UserProfile/User.js similarity index 63% rename from webapp/graphql/UserProfileQuery.js rename to webapp/graphql/UserProfile/User.js index 16e7e1440..5bfe0510f 100644 --- a/webapp/graphql/UserProfileQuery.js +++ b/webapp/graphql/UserProfile/User.js @@ -1,10 +1,10 @@ import gql from 'graphql-tag' -export default app => { - const lang = app.$i18n.locale().toUpperCase() +export default i18n => { + const lang = i18n.locale().toUpperCase() return gql(` - query User($slug: String!, $first: Int, $offset: Int) { - User(slug: $slug) { + query User($id: ID!) { + User(id: $id) { id slug name @@ -24,7 +24,7 @@ export default app => { } badgesCount shoutedCount - commentsCount + commentedCount followingCount following(first: 7) { id @@ -69,35 +69,6 @@ export default app => { } } contributionsCount - contributions(first: $first, offset: $offset, orderBy: createdAt_desc) { - id - slug - title - contentExcerpt - shoutedCount - commentsCount - deleted - image - createdAt - disabled - deleted - categories { - id - name - icon - } - author { - id - slug - avatar - name - disabled - deleted - location { - name: name${lang} - } - } - } socialMedia { id url diff --git a/webapp/locales/de.json b/webapp/locales/de.json index a790e6461..efe05a472 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -25,7 +25,16 @@ "shouted": "Empfohlen", "commented": "Kommentiert", "userAnonym": "Anonymus", - "socialMedia": "Wo sonst finde ich" + "socialMedia": "Wo sonst finde ich", + "network": { + "title": "Netzwerk", + "following": "folgt:", + "followingNobody": "folgt niemandem.", + "followedBy": "wird gefolgt von:", + "followedByNobody": "wird von niemandem gefolgt.", + "and": "und", + "more": "weitere" + } }, "notifications": { "menu": { @@ -73,8 +82,14 @@ "download": { "name": "Daten herunterladen" }, - "delete": { - "name": "Konto löschen" + "deleteUserAccount": { + "name": "Daten löschen", + "contributionsCount": "Meine {count} Beiträge löschen", + "commentsCount": "Meine {count} Kommentare löschen", + "accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.", + "accountWarning": "Dein Konto, deine Beiträge oder Kommentare kannst du nach dem Löschen WEDER VERWALTEN NOCH WIEDERHERSTELLEN!", + "success": "Konto erfolgreich gelöscht", + "pleaseConfirm": "Zerstörerische Aktion! Gib {confirm} ein, um zu bestätigen." }, "organizations": { "name": "Meine Organisationen" @@ -163,11 +178,6 @@ } }, "common": { - "your": { - "post": "Dein Beitrag ::: Deine Beiträge", - "comment": "Dein Kommentar ::: Deine Kommentare", - "shouted": "Deine Empfehlung ::: Deine Empfehlungen" - }, "post": "Beitrag ::: Beiträge", "comment": "Kommentar ::: Kommentare", "letsTalk": "Miteinander reden", @@ -235,7 +245,7 @@ "comment": { "title": "Lösche Kommentar", "type": "Comment", - "message": "Bist du sicher, dass du den Kommentar von \"{name}\" löschen möchtest?", + "message": "Bist du sicher, dass du den Kommentar \"{name}\" löschen möchtest?", "success": "Kommentar erfolgreich gelöscht!" } }, @@ -291,5 +301,9 @@ "avatar": { "submitted": "Upload erfolgreich" } + }, + "contribution": { + "success": "Gespeichert!", + "languageSelectLabel": "Sprache" } } diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 289928f92..4fdcadedb 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -25,7 +25,16 @@ "shouted": "Shouted", "commented": "Commented", "userAnonym": "Anonymous", - "socialMedia": "Where else can I find" + "socialMedia": "Where else can I find", + "network": { + "title": "Network", + "following": "is following:", + "followingNobody": "follows nobody.", + "followedBy": "is followed by:", + "followedByNobody": "is not followed by anyone.", + "and": "and", + "more": "more" + } }, "notifications": { "menu": { @@ -73,8 +82,14 @@ "download": { "name": "Download Data" }, - "delete": { - "name": "Delete Account" + "deleteUserAccount": { + "name": "Delete Data", + "contributionsCount": "Delete my {count} posts", + "commentsCount": "Delete my {count} comments", + "accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.", + "accountWarning": "You CAN'T MANAGE and CAN'T RECOVER your Account, Posts, or Comments after deleting your account!", + "success": "Account successfully deleted", + "pleaseConfirm": "Destructive action! Type {confirm} to confirm" }, "organizations": { "name": "My Organizations" @@ -163,11 +178,6 @@ } }, "common": { - "your": { - "post": "Your Post ::: Your Posts", - "comment": "Your Comment ::: Your Comments", - "shout": "Your Shout ::: Your Shouts" - }, "post": "Post ::: Posts", "comment": "Comment ::: Comments", "letsTalk": "Let`s Talk", @@ -235,7 +245,7 @@ "comment": { "title": "Delete Comment", "type": "Comment", - "message": "Do you really want to delete the comment from \"{name}\"?", + "message": "Do you really want to delete the comment \"{name}\"?", "success": "Comment successfully deleted!" } }, @@ -290,5 +300,9 @@ "avatar": { "submitted": "Upload successful" } + }, + "contribution": { + "success": "Saved!", + "languageSelectLabel": "Language" } } diff --git a/webapp/mixins/PostMutationHelpers.js b/webapp/mixins/PostMutationHelpers.js deleted file mode 100644 index 2e7a91e4d..000000000 --- a/webapp/mixins/PostMutationHelpers.js +++ /dev/null @@ -1,39 +0,0 @@ -import gql from 'graphql-tag' - -export default { - methods: { - async deletePostCallback(postDisplayType = 'list') { - // console.log('inside "deletePostCallback" !!! ', this.post) - try { - var gqlMutation = gql` - mutation($id: ID!) { - DeletePost(id: $id) { - id - } - } - ` - await this.$apollo.mutate({ - mutation: gqlMutation, - variables: { - id: this.post.id, - }, - }) - this.$toast.success(this.$t('delete.contribution.success')) - // console.log('called "this.$t" !!!') - switch (postDisplayType) { - case 'list': - this.$emit('deletePost') - // console.log('emitted "deletePost" !!!') - break - default: - // case 'page' - // console.log('called "this.$router.history.push" !!!') - this.$router.history.push('/') // Single page type: Redirect to index (main) page - break - } - } catch (err) { - this.$toast.error(err.message) - } - }, - }, -} diff --git a/webapp/package.json b/webapp/package.json index 379053d08..b5c74de99 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -56,10 +56,10 @@ "@nuxtjs/style-resources": "~0.1.2", "accounting": "~0.4.1", "apollo-cache-inmemory": "~1.5.1", - "apollo-client": "~2.6.1", - "cookie-universal-nuxt": "~2.0.14", + "apollo-client": "~2.6.2", + "cookie-universal-nuxt": "~2.0.16", "cross-env": "~5.2.0", - "date-fns": "2.0.0-alpha.29", + "date-fns": "2.0.0-alpha.33", "express": "~4.17.1", "graphql": "~14.3.1", "jsonwebtoken": "~8.5.1", @@ -70,7 +70,7 @@ "stack-utils": "^1.0.2", "string-hash": "^1.1.3", "tiptap": "1.20.1", - "tiptap-extensions": "1.21.0", + "tiptap-extensions": "1.22.2", "v-tooltip": "~2.0.2", "vue-count-to": "~1.0.13", "vue-izitoast": "1.1.2", @@ -104,9 +104,9 @@ "jest": "~24.8.0", "node-sass": "~4.12.0", "nodemon": "~1.19.1", - "prettier": "~1.17.1", + "prettier": "~1.18.2", "sass-loader": "~7.1.0", - "tippy.js": "^4.3.3", + "tippy.js": "^4.3.4", "vue-jest": "~3.0.4", "vue-svg-loader": "~0.12.0" } diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index d8becf206..a18c80b9f 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -2,14 +2,14 @@
- + @@ -32,6 +32,7 @@ import gql from 'graphql-tag' import uniqBy from 'lodash/uniqBy' import HcPostCard from '~/components/PostCard' import HcLoadMore from '~/components/LoadMore.vue' +import { mapGetters } from 'vuex' export default { components: { @@ -45,10 +46,13 @@ export default { Post: [], page: 1, pageSize: 10, - filterBubble: { author: 'all' }, + filter: {}, } }, computed: { + ...mapGetters({ + currentUser: 'auth/user', + }), tags() { return this.Post ? this.Post[0].tags.map(tag => tag.name) : '-' }, @@ -57,8 +61,8 @@ export default { }, }, methods: { - changeFilterBubble(filterBubble) { - this.filterBubble = filterBubble + changeFilterBubble(filter) { + this.filter = filter this.$apollo.queries.Post.refresh() }, uniq(items, field = 'id') { @@ -76,7 +80,7 @@ export default { this.page++ this.$apollo.queries.Post.fetchMore({ variables: { - filterBubble: this.filterBubble, + filter: this.filter, first: this.pageSize, offset: this.offset, }, @@ -102,8 +106,8 @@ export default { Post: { query() { return gql(` - query Post($filterBubble: FilterBubble, $first: Int, $offset: Int) { - Post(filterBubble: $filterBubble, first: $first, offset: $offset) { + query Post($filter: _PostFilter, $first: Int, $offset: Int) { + Post(filter: $filter, first: $first, offset: $offset) { id title contentExcerpt @@ -146,7 +150,7 @@ export default { }, variables() { return { - filterBubble: this.filterBubble, + filter: this.filter, first: this.pageSize, offset: 0, } diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index 5bf4ea168..33916c7a5 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -56,7 +56,7 @@ describe('PostSlug', () => { beforeEach(jest.useFakeTimers) - describe('test mixin "PostMutationHelpers"', () => { + describe('test Post callbacks', () => { beforeEach(() => { wrapper = Wrapper() wrapper.setData({ @@ -70,22 +70,14 @@ describe('PostSlug', () => { }) }) - describe('deletion of Post from Page by invoking "deletePostCallback(`page`)"', () => { + describe('deletion of Post from Page by invoking "deletePostCallback()"', () => { beforeEach(() => { - wrapper.vm.deletePostCallback('page') + wrapper.vm.deletePostCallback() }) describe('after timeout', () => { beforeEach(jest.runAllTimers) - it('not emits "deletePost"', () => { - expect(wrapper.emitted().deletePost).toBeFalsy() - }) - - it('does go to index (main) page', () => { - expect(mocks.$router.history.push).toHaveBeenCalledTimes(1) - }) - it('does call mutation', () => { expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) }) @@ -93,6 +85,10 @@ describe('PostSlug', () => { it('mutation is successful', () => { expect(mocks.$toast.success).toHaveBeenCalledTimes(1) }) + + it('does go to index (main) page', () => { + expect(mocks.$router.history.push).toHaveBeenCalledTimes(1) + }) }) }) }) diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index 81f05c36c..5751f42b0 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -7,19 +7,18 @@ > + - - {{ post.title }} - + {{ post.title }} @@ -72,7 +71,7 @@ import HcUser from '~/components/User' import HcShoutButton from '~/components/ShoutButton.vue' import HcCommentForm from '~/components/comments/CommentForm' import HcCommentList from '~/components/comments/CommentList' -import PostMutationHelpers from '~/mixins/PostMutationHelpers' +import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' export default { name: 'PostSlug', @@ -89,7 +88,6 @@ export default { HcCommentForm, HcCommentList, }, - mixins: [PostMutationHelpers], head() { return { title: this.title, @@ -211,10 +209,28 @@ export default { this.ready = true }, 50) }, + computed: { + menuModalsData() { + return postMenuModalsData( + // "this.post" may not always be defined at the beginning … + this.post ? this.$filters.truncate(this.post.title, 30) : '', + this.deletePostCallback, + ) + }, + }, methods: { isAuthor(id) { return this.$store.getters['auth/user'].id === id }, + async deletePostCallback() { + try { + await this.$apollo.mutate(deletePostMutation(this.post.id)) + this.$toast.success(this.$t('delete.contribution.success')) + this.$router.history.push('/') // Redirect to index (main) page + } catch (err) { + this.$toast.error(err.message) + } + }, }, } diff --git a/webapp/pages/post/_id/_slug/more-info.vue b/webapp/pages/post/_id/_slug/more-info.vue index 1cb93ae6c..167926e95 100644 --- a/webapp/pages/post/_id/_slug/more-info.vue +++ b/webapp/pages/post/_id/_slug/more-info.vue @@ -1,8 +1,6 @@ @@ -49,6 +47,7 @@ export default { deleted slug image + language author { id disabled diff --git a/webapp/pages/profile/_id/_slug.spec.js b/webapp/pages/profile/_id/_slug.spec.js index d79f9b885..bd6c9c598 100644 --- a/webapp/pages/profile/_id/_slug.spec.js +++ b/webapp/pages/profile/_id/_slug.spec.js @@ -1,12 +1,19 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils' +import { config, mount, createLocalVue } from '@vue/test-utils' import ProfileSlug from './_slug.vue' import Vuex from 'vuex' import Styleguide from '@human-connection/styleguide' +import Filters from '~/plugins/vue-filters' const localVue = createLocalVue() localVue.use(Vuex) localVue.use(Styleguide) +localVue.use(Filters) +localVue.filter('date', d => d) + +config.stubs['no-ssr'] = '' +config.stubs['v-popover'] = '' +config.stubs['nuxt-link'] = '' describe('ProfileSlug', () => { let wrapper @@ -20,7 +27,13 @@ describe('ProfileSlug', () => { name: 'It is a post', }, $t: jest.fn(), - // If you mocking router, than don't use VueRouter with lacalVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html + // If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html + $route: { + params: { + id: '4711', + slug: 'john-doe', + }, + }, $router: { history: { push: jest.fn(), @@ -31,48 +44,138 @@ describe('ProfileSlug', () => { error: jest.fn(), }, $apollo: { + loading: false, mutate: jest.fn().mockResolvedValue(), }, } }) - describe('shallowMount', () => { + describe('mount', () => { Wrapper = () => { - return shallowMount(ProfileSlug, { + return mount(ProfileSlug, { mocks, localVue, }) } - beforeEach(jest.useFakeTimers) - - describe('test mixin "PostMutationHelpers"', () => { + describe('given an authenticated user', () => { beforeEach(() => { - wrapper = Wrapper() + mocks.$filters = { + removeLinks: c => c, + truncate: a => a, + } + mocks.$store = { + getters: { + 'auth/user': { + id: 'u23', + }, + }, + } }) - describe('deletion of Post from List by invoking "deletePostCallback(`list`)"', () => { + describe('given a user for the profile', () => { beforeEach(() => { - wrapper.vm.deletePostCallback('list') + wrapper = Wrapper() + wrapper.setData({ + User: [ + { + id: 'u3', + name: 'Bob the builder', + contributionsCount: 6, + shoutedCount: 7, + commentedCount: 8, + }, + ], + }) }) - describe('after timeout', () => { - beforeEach(jest.runAllTimers) + it('displays name of the user', () => { + expect(wrapper.text()).toContain('Bob the builder') + }) - it('emits "deletePost"', () => { - expect(wrapper.emitted().deletePost.length).toBe(1) + describe('load more button', () => { + const aPost = { + title: 'I am a post', + content: 'This is my content', + contentExcerpt: 'This is my content', + } + + describe('currently no posts available (e.g. after tab switching)', () => { + beforeEach(() => { + wrapper.setData({ + Post: null, + }) + }) + + it('displays no "load more" button', () => { + expect(wrapper.find('.load-more').exists()).toBe(false) + }) + + describe('apollo client in `loading` state', () => { + beforeEach(() => { + wrapper.vm.$apollo.loading = true + }) + + it('never displays more than one loading spinner', () => { + expect(wrapper.findAll('.ds-spinner')).toHaveLength(1) + }) + + it('displays a loading spinner below the posts list', () => { + expect(wrapper.find('.user-profile-posts-list .ds-spinner').exists()).toBe(true) + }) + }) }) - it('does not go to index (main) page', () => { - expect(mocks.$router.history.push).not.toHaveBeenCalled() + describe('pagination returned less posts than available', () => { + beforeEach(() => { + const posts = [1, 2, 3, 4, 5].map(id => { + return { + ...aPost, + id, + } + }) + + wrapper.setData({ + Post: posts, + }) + }) + + it('displays a "load more" button', () => { + expect(wrapper.find('.load-more').exists()).toBe(true) + }) + + describe('apollo client in `loading` state', () => { + beforeEach(() => { + wrapper.vm.$apollo.loading = true + }) + + it('never displays more than one loading spinner', () => { + expect(wrapper.findAll('.ds-spinner')).toHaveLength(1) + }) + + it('displays a loading spinner below the posts list', () => { + expect(wrapper.find('.load-more .ds-spinner').exists()).toBe(true) + }) + }) }) - it('does call mutation', () => { - expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) - }) + describe('pagination returned as many posts as available', () => { + beforeEach(() => { + const posts = [1, 2, 3, 4, 5, 6].map(id => { + return { + ...aPost, + id, + } + }) - it('mutation is successful', () => { - expect(mocks.$toast.success).toHaveBeenCalledTimes(1) + wrapper.setData({ + Post: posts, + }) + }) + + it('displays no "load more" button', () => { + expect(wrapper.find('.load-more').exists()).toBe(false) + }) }) }) }) diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index 9a7af82ac..638a00f37 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -12,12 +12,12 @@ > + @@ -69,11 +69,13 @@ - Netzwerk + {{ $t('profile.network.title') }} - Wem folgt {{ userName | truncate(15) }}? + + {{ userName | truncate(15) }} {{ $t('profile.network.following') }} + - Wer folgt {{ userName | truncate(15) }}? + + {{ userName | truncate(15) }} {{ $t('profile.network.followedBy') }} + @@ -132,69 +142,50 @@ + - +