feat: Introduce graphql image type (#3043)

* refactor(graphql): Introduce image type

* Undo changes to .travis.yml

* chore: Upgrade travis to node LTS

- URL is available since v10

* chore: use lts

Co-authored-by: mattwr18 <mattwr18@gmail.com>
This commit is contained in:
Robert Schäfer 2020-03-16 15:32:19 +01:00 committed by GitHub
parent 23afe9be74
commit 512ef672bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 1572 additions and 936 deletions

View File

@ -4,33 +4,6 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.4.2](https://github.com/Human-Connection/Human-Connection/compare/v0.4.1...v0.4.2)
> 12 March 2020
- build(deps): bump @sentry/node from 5.13.1 to 5.14.0 in /backend [`#3260`](https://github.com/Human-Connection/Human-Connection/pull/3260)
- build(deps): bump graphql-shield from 7.0.14 to 7.1.0 in /backend [`#3259`](https://github.com/Human-Connection/Human-Connection/pull/3259)
- feat: more prominent output of ./scripts/translations/sort.sh and hint to --fix feature of the script on errors [`#3251`](https://github.com/Human-Connection/Human-Connection/pull/3251)
- build(deps): bump nodemailer from 6.4.4 to 6.4.5 in /backend [`#3254`](https://github.com/Human-Connection/Human-Connection/pull/3254)
- build(deps-dev): bump @vue/test-utils from 1.0.0-beta.31 to 1.0.0-beta.32 in /webapp [`#3248`](https://github.com/Human-Connection/Human-Connection/pull/3248)
- build(deps-dev): bump async-validator from 3.2.3 to 3.2.4 in /webapp [`#3255`](https://github.com/Human-Connection/Human-Connection/pull/3255)
- build(deps-dev): bump eslint-plugin-jest from 23.8.1 to 23.8.2 in /backend [`#3253`](https://github.com/Human-Connection/Human-Connection/pull/3253)
- feature: Delete_user_as_admin_through_API_only [`#3063`](https://github.com/Human-Connection/Human-Connection/pull/3063)
- feat: zero bell to all notifications page [2823] [`#3219`](https://github.com/Human-Connection/Human-Connection/pull/3219)
- fix: layout shift [2607] [`#3218`](https://github.com/Human-Connection/Human-Connection/pull/3218)
- feat: Documentation for locales script [`#3242`](https://github.com/Human-Connection/Human-Connection/pull/3242)
- build(deps): bump metascraper-audio from 5.11.1 to 5.11.6 in /backend [`#3235`](https://github.com/Human-Connection/Human-Connection/pull/3235)
- build(deps): bump metascraper-video from 5.11.1 to 5.11.6 in /backend [`#3247`](https://github.com/Human-Connection/Human-Connection/pull/3247)
- build(deps): bump metascraper-soundcloud from 5.11.5 to 5.11.6 in /backend [`#3246`](https://github.com/Human-Connection/Human-Connection/pull/3246)
- build(deps): bump metascraper-lang from 5.11.1 to 5.11.6 in /backend [`#3234`](https://github.com/Human-Connection/Human-Connection/pull/3234)
- build(deps): bump metascraper-description from 5.11.1 to 5.11.6 in /backend [`#3233`](https://github.com/Human-Connection/Human-Connection/pull/3233)
- build(deps): bump cross-env from 7.0.1 to 7.0.2 in /backend [`#3245`](https://github.com/Human-Connection/Human-Connection/pull/3245)
- build(deps): bump metascraper-title from 5.11.1 to 5.11.6 in /backend [`#3244`](https://github.com/Human-Connection/Human-Connection/pull/3244)
- chore: Update to v0.4.1 [`#3243`](https://github.com/Human-Connection/Human-Connection/pull/3243)
- DRY user.spec.js [`da16590`](https://github.com/Human-Connection/Human-Connection/commit/da165906e2ed12baddd902b43064103ab3adfa06)
- test deleteuser as admin, moderator, another user and as I myself, fix lint [`3983612`](https://github.com/Human-Connection/Human-Connection/commit/3983612c56ac92473a192a318959e4c691a3e7b8)
- feature: test delete user as admin [`84c1547`](https://github.com/Human-Connection/Human-Connection/commit/84c154798efac0cec4c13dfefae18a6a9542058a)
#### [v0.4.1](https://github.com/Human-Connection/Human-Connection/compare/v0.4.0...v0.4.1) #### [v0.4.1](https://github.com/Human-Connection/Human-Connection/compare/v0.4.0...v0.4.1)
> 9 March 2020 > 9 March 2020

View File

@ -1,6 +1,6 @@
{ {
"name": "human-connection-backend", "name": "human-connection-backend",
"version": "0.4.2", "version": "0.4.1",
"description": "GraphQL Backend for Human Connection", "description": "GraphQL Backend for Human Connection",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@ -61,7 +61,7 @@
"graphql-middleware": "~4.0.2", "graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1", "graphql-middleware-sentry": "^3.2.1",
"graphql-redis-subscriptions": "^2.2.1", "graphql-redis-subscriptions": "^2.2.1",
"graphql-shield": "~7.2.0", "graphql-shield": "~7.0.14",
"graphql-tag": "~2.10.3", "graphql-tag": "~2.10.3",
"helmet": "~3.21.3", "helmet": "~3.21.3",
"ioredis": "^4.16.0", "ioredis": "^4.16.0",
@ -92,11 +92,11 @@
"neo4j-graphql-js": "^2.11.5", "neo4j-graphql-js": "^2.11.5",
"neode": "^0.3.7", "neode": "^0.3.7",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.4.5", "nodemailer": "^6.4.4",
"nodemailer-html-to-text": "^3.1.0", "nodemailer-html-to-text": "^3.1.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
"request": "~2.88.2", "request": "~2.88.2",
"sanitize-html": "~1.22.1", "sanitize-html": "~1.22.0",
"slug": "~2.1.1", "slug": "~2.1.1",
"subscriptions-transport-ws": "^0.9.16", "subscriptions-transport-ws": "^0.9.16",
"trunc-html": "~1.1.2", "trunc-html": "~1.1.2",

View File

@ -7,7 +7,7 @@ import request from 'request'
import NitroDataSource from './NitroDataSource' import NitroDataSource from './NitroDataSource'
import router from './routes' import router from './routes'
import Collections from './Collections' import Collections from './Collections'
import uuid from 'uuid/v4' import { v4 as uuid } from 'uuid'
import CONFIG from '../config' import CONFIG from '../config'
const debug = require('debug')('ea') const debug = require('debug')('ea')

View File

@ -4,9 +4,16 @@ import slugify from 'slug'
import { hashSync } from 'bcryptjs' import { hashSync } from 'bcryptjs'
import { Factory } from 'rosie' import { Factory } from 'rosie'
import { getDriver, getNeode } from './neo4j' import { getDriver, getNeode } from './neo4j'
import CONFIG from '../config/index.js'
const neode = getNeode() const neode = getNeode()
const uniqueImageUrl = imageUrl => {
const newUrl = new URL(imageUrl, CONFIG.CLIENT_URI)
newUrl.search = `random=${uuid()}`
return newUrl.toString()
}
export const cleanDatabase = async (options = {}) => { export const cleanDatabase = async (options = {}) => {
const { driver = getDriver() } = options const { driver = getDriver() } = options
const session = driver.session() const session = driver.session()
@ -39,14 +46,23 @@ Factory.define('badge')
return neode.create('Badge', buildObject) return neode.create('Badge', buildObject)
}) })
Factory.define('userWithoutEmailAddress') Factory.define('image')
.attr('url', faker.image.unsplash.imageUrl)
.attr('aspectRatio', 1)
.attr('alt', faker.lorem.sentence)
.after((buildObject, options) => {
const { url: imageUrl } = buildObject
if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl)
return neode.create('Image', buildObject)
})
Factory.define('basicUser')
.option('password', '1234') .option('password', '1234')
.attrs({ .attrs({
id: uuid, id: uuid,
name: faker.name.findName, name: faker.name.findName,
password: '1234', password: '1234',
role: 'user', role: 'user',
avatar: faker.internet.avatar,
about: faker.lorem.paragraph, about: faker.lorem.paragraph,
termsAndConditionsAgreedVersion: '0.0.1', termsAndConditionsAgreedVersion: '0.0.1',
termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z',
@ -60,19 +76,29 @@ Factory.define('userWithoutEmailAddress')
.attr('encryptedPassword', ['password'], password => { .attr('encryptedPassword', ['password'], password => {
return hashSync(password, 10) return hashSync(password, 10)
}) })
Factory.define('userWithoutEmailAddress')
.extend('basicUser')
.after(async (buildObject, options) => { .after(async (buildObject, options) => {
return neode.create('User', buildObject) return neode.create('User', buildObject)
}) })
Factory.define('user') Factory.define('user')
.extend('userWithoutEmailAddress') .extend('basicUser')
.option('email', faker.internet.exampleEmail) .option('email', faker.internet.exampleEmail)
.option('avatar', () =>
Factory.build('image', {
url: faker.internet.avatar(),
}),
)
.after(async (buildObject, options) => { .after(async (buildObject, options) => {
const [user, email] = await Promise.all([ const [user, email, avatar] = await Promise.all([
buildObject, neode.create('User', buildObject),
neode.create('EmailAddress', { email: options.email }), neode.create('EmailAddress', { email: options.email }),
options.avatar,
]) ])
await Promise.all([user.relateTo(email, 'primaryEmail'), email.relateTo(user, 'belongsTo')]) await Promise.all([user.relateTo(email, 'primaryEmail'), email.relateTo(user, 'belongsTo')])
if (avatar) await user.relateTo(avatar, 'avatar')
return user return user
}) })
@ -93,11 +119,11 @@ Factory.define('post')
return Factory.build('user') return Factory.build('user')
}) })
.option('pinnedBy', null) .option('pinnedBy', null)
.option('image', () => Factory.build('image'))
.attrs({ .attrs({
id: uuid, id: uuid,
title: faker.lorem.sentence, title: faker.lorem.sentence,
content: faker.lorem.paragraphs, content: faker.lorem.paragraphs,
image: faker.image.unsplash.imageUrl,
visibility: 'public', visibility: 'public',
deleted: false, deleted: false,
imageBlurred: false, imageBlurred: false,
@ -117,9 +143,10 @@ Factory.define('post')
return language || 'en' return language || 'en'
}) })
.after(async (buildObject, options) => { .after(async (buildObject, options) => {
const [post, author, categories, tags] = await Promise.all([ const [post, author, image, categories, tags] = await Promise.all([
neode.create('Post', buildObject), neode.create('Post', buildObject),
options.author, options.author,
options.image,
options.categories, options.categories,
options.tags, options.tags,
]) ])
@ -128,6 +155,7 @@ Factory.define('post')
Promise.all(categories.map(c => c.relateTo(post, 'post'))), Promise.all(categories.map(c => c.relateTo(post, 'post'))),
Promise.all(tags.map(t => t.relateTo(post, 'post'))), Promise.all(tags.map(t => t.relateTo(post, 'post'))),
]) ])
if (image) await post.relateTo(image, 'image')
if (buildObject.pinned) { if (buildObject.pinned) {
const pinnedBy = await (options.pinnedBy || Factory.build('user', { role: 'admin' })) const pinnedBy = await (options.pinnedBy || Factory.build('user', { role: 'admin' }))
await pinnedBy.relateTo(post, 'pinned') await pinnedBy.relateTo(post, 'pinned')

View File

@ -0,0 +1,101 @@
/* eslint-disable no-console */
import { getDriver } from '../../db/neo4j'
export const description = `
Refactor all our image properties on posts and users to a dedicated type
"Image" which contains metadata and image file urls.
`
const printSummaries = summaries => {
console.log('=========================================')
summaries.forEach(stat => {
console.log(stat.query.text)
console.log(JSON.stringify(stat.counters, null, 2))
})
console.log('=========================================')
}
export async function up() {
const driver = getDriver()
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => {
const runs = await Promise.all(
[
`
MATCH (post:Post)
WHERE post.image IS NOT NULL
CREATE (post)-[:HERO_IMAGE]->(image:Image)
SET
image.url = post.image,
image.sensitive = post.imageBlurred,
image.aspectRatio = post.imageAspectRatio
REMOVE
post.image,
post.imageBlurred,
post.imageAspectRatio
`,
`
MATCH (user:User)
WHERE user.avatar IS NOT NULL
CREATE (user)-[:AVATAR_IMAGE]->(avatar:Image)
SET avatar.url = user.avatar
REMOVE user.avatar
`,
`
MATCH (user:User)
WHERE user.coverImg IS NOT NULL
CREATE (user)-[:COVER_IMAGE]->(coverImage:Image)
SET coverImage.url = user.coverImg
REMOVE user.coverImg
`,
].map(s => txc.run(s)),
)
return runs.map(({ summary }) => summary)
})
try {
const stats = await writeTxResultPromise
console.log('Created image nodes from all user avatars and post images.')
printSummaries(stats)
} finally {
session.close()
}
}
export async function down() {
const driver = getDriver()
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => {
const runs = await Promise.all(
[
`
MATCH (post)-[:HERO_IMAGE]->(image:Image)
SET
post.image = image.url,
post.imageBlurred = image.sensitive,
post.imageAspectRatio = image.aspectRatio
DETACH DELETE image
`,
`
MATCH(user)-[:AVATAR_IMAGE]->(avatar:Image)
SET user.avatar = avatar.url
DETACH DELETE avatar
`,
`
MATCH(user)-[:COVER_IMAGE]->(coverImage:Image)
SET user.coverImg = coverImage.url
DETACH DELETE coverImage
`,
].map(s => txc.run(s)),
)
return runs.map(({ summary }) => summary)
})
try {
const stats = await writeTxResultPromise
console.log('UNDO: Split images from users and posts.')
printSummaries(stats)
} finally {
session.close()
}
}

View File

@ -0,0 +1,53 @@
import { getDriver } from '../../db/neo4j'
export const description = `
We introduced a new node label 'Image' and we need a primary key for it. Best
would probably be the 'url' property which should be unique and would also
prevent us from overwriting existing images.
`
export async function up(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
CREATE CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
} finally {
session.close()
}
}
export async function down(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
DROP CONSTRAINT ON ( image:Image ) ASSERT image.url IS UNIQUE
`)
await transaction.commit()
next()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
} finally {
session.close()
}
}

View File

@ -389,13 +389,15 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{ {
id: 'p0', id: 'p0',
language: sample(languages), language: sample(languages),
image: faker.image.unsplash.food(300, 169),
imageBlurred: true,
imageAspectRatio: 300 / 169,
}, },
{ {
categoryIds: ['cat16'], categoryIds: ['cat16'],
author: peterLustig, author: peterLustig,
image: Factory.build('image', {
url: faker.image.unsplash.food(300, 169),
sensitive: true,
aspectRatio: 300 / 169,
}),
}, },
), ),
Factory.build( Factory.build(
@ -403,12 +405,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{ {
id: 'p1', id: 'p1',
language: sample(languages), language: sample(languages),
image: faker.image.unsplash.technology(300, 1500),
imageAspectRatio: 300 / 1500,
}, },
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: bobDerBaumeister, author: bobDerBaumeister,
image: Factory.build('image', {
url: faker.image.unsplash.technology(300, 1500),
aspectRatio: 300 / 1500,
}),
}, },
), ),
Factory.build( Factory.build(
@ -449,12 +453,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{ {
id: 'p6', id: 'p6',
language: sample(languages), language: sample(languages),
image: faker.image.unsplash.buildings(300, 857),
imageAspectRatio: 300 / 857,
}, },
{ {
categoryIds: ['cat6'], categoryIds: ['cat6'],
author: peterLustig, author: peterLustig,
image: Factory.build('image', {
url: faker.image.unsplash.buildings(300, 857),
aspectRatio: 300 / 857,
}),
}, },
), ),
Factory.build( Factory.build(
@ -472,11 +478,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
'post', 'post',
{ {
id: 'p10', id: 'p10',
imageBlurred: true,
}, },
{ {
categoryIds: ['cat10'], categoryIds: ['cat10'],
author: dewey, author: dewey,
image: Factory.build('image', {
sensitive: true,
}),
}, },
), ),
Factory.build( Factory.build(
@ -484,12 +492,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{ {
id: 'p11', id: 'p11',
language: sample(languages), language: sample(languages),
image: faker.image.unsplash.people(300, 901),
imageAspectRatio: 300 / 901,
}, },
{ {
categoryIds: ['cat11'], categoryIds: ['cat11'],
author: louie, author: louie,
image: Factory.build('image', {
url: faker.image.unsplash.people(300, 901),
aspectRatio: 300 / 901,
}),
}, },
), ),
Factory.build( Factory.build(
@ -508,12 +518,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{ {
id: 'p14', id: 'p14',
language: sample(languages), language: sample(languages),
image: faker.image.unsplash.objects(300, 200),
imageAspectRatio: 300 / 450,
}, },
{ {
categoryIds: ['cat14'], categoryIds: ['cat14'],
author: jennyRostock, author: jennyRostock,
image: Factory.build('image', {
url: faker.image.unsplash.objects(300, 200),
aspectRatio: 300 / 450,
}),
}, },
), ),
Factory.build( Factory.build(
@ -539,22 +551,8 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
const hashtagAndMention1 = const hashtagAndMention1 =
'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)' 'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
const createPostMutation = gql` const createPostMutation = gql`
mutation( mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) {
$id: ID CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
$title: String!
$content: String!
$categoryIds: [ID]
$imageBlurred: Boolean
$imageAspectRatio: Float
) {
CreatePost(
id: $id
title: $title
content: $content
categoryIds: $categoryIds
imageBlurred: $imageBlurred
imageAspectRatio: $imageAspectRatio
) {
id id
} }
} }
@ -568,7 +566,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
title: `Nature Philosophy Yoga`, title: `Nature Philosophy Yoga`,
content: hashtag1, content: hashtag1,
categoryIds: ['cat2'], categoryIds: ['cat2'],
imageAspectRatio: 300 / 200,
}, },
}), }),
mutate({ mutate({
@ -578,7 +575,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
title: 'This is post #7', title: 'This is post #7',
content: `${mention1} ${faker.lorem.paragraph()}`, content: `${mention1} ${faker.lorem.paragraph()}`,
categoryIds: ['cat7'], categoryIds: ['cat7'],
imageAspectRatio: 300 / 180,
}, },
}), }),
mutate({ mutate({
@ -589,7 +585,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
title: `Quantum Flow Theory explains Quantum Gravity`, title: `Quantum Flow Theory explains Quantum Gravity`,
content: hashtagAndMention1, content: hashtagAndMention1,
categoryIds: ['cat8'], categoryIds: ['cat8'],
imageAspectRatio: 300 / 900,
}, },
}), }),
mutate({ mutate({
@ -599,7 +594,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
title: 'This is post #12', title: 'This is post #12',
content: `${mention2} ${faker.lorem.paragraph()}`, content: `${mention2} ${faker.lorem.paragraph()}`,
categoryIds: ['cat12'], categoryIds: ['cat12'],
imageAspectRatio: 300 / 200,
}, },
}), }),
]) ])
@ -759,6 +753,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}, },
), ),
]) ])
const trollingComment = comments[0] const trollingComment = comments[0]
await Promise.all([ await Promise.all([
@ -939,12 +934,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(30).keys()].map(() => [...Array(30).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.objects(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: jennyRostock, author: jennyRostock,
image: Factory.build('image', {
url: faker.image.unsplash.objects(),
}),
}, },
), ),
), ),
@ -993,12 +989,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(21).keys()].map(() => [...Array(21).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.buildings(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: peterLustig, author: peterLustig,
image: Factory.build('image', {
url: faker.image.unsplash.buildings(),
}),
}, },
), ),
), ),
@ -1047,12 +1044,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(11).keys()].map(() => [...Array(11).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.food(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: dewey, author: dewey,
image: Factory.build('image', {
url: faker.image.unsplash.food(),
}),
}, },
), ),
), ),
@ -1101,12 +1099,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(16).keys()].map(() => [...Array(16).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.technology(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: louie, author: louie,
image: Factory.build('image', {
url: faker.image.unsplash.technology(),
}),
}, },
), ),
), ),
@ -1155,12 +1154,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(45).keys()].map(() => [...Array(45).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.people(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: bobDerBaumeister, author: bobDerBaumeister,
image: Factory.build('image', {
url: faker.image.unsplash.people(),
}),
}, },
), ),
), ),
@ -1209,12 +1209,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
[...Array(8).keys()].map(() => [...Array(8).keys()].map(() =>
Factory.build( Factory.build(
'post', 'post',
{ {},
image: faker.image.unsplash.nature(),
},
{ {
categoryIds: ['cat1'], categoryIds: ['cat1'],
author: huey, author: huey,
image: Factory.build('image', {
url: faker.image.unsplash.nature(),
}),
}, },
), ),
), ),

View File

@ -15,10 +15,10 @@ export default async (driver, authorizationHeader) => {
const writeTxResultPromise = session.writeTransaction(async transaction => { const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserLastActiveTransactionResponse = await transaction.run( const updateUserLastActiveTransactionResponse = await transaction.run(
` `
MATCH (user:User {id: $id, deleted: false, disabled: false }) MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime()) SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} RETURN user {.id, .slug, .name, .role, .disabled, .actorId}
LIMIT 1 LIMIT 1
`, `,
{ id }, { id },

View File

@ -69,23 +69,23 @@ describe('decode', () => {
{ {
role: 'user', role: 'user',
name: 'Jenny Rostock', name: 'Jenny Rostock',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
id: 'u3', id: 'u3',
slug: 'jenny-rostock', slug: 'jenny-rostock',
}, },
{ {
image: Factory.build('image', {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
}),
email: 'user@example.org', email: 'user@example.org',
}, },
) )
}) })
it('returns user object except email', async () => { it('returns user object without email', async () => {
await expect(decode(driver, authorizationHeader)).resolves.toMatchObject({ await expect(decode(driver, authorizationHeader)).resolves.toMatchObject({
role: 'user', role: 'user',
name: 'Jenny Rostock', name: 'Jenny Rostock',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
id: 'u3', id: 'u3',
email: null,
slug: 'jenny-rostock', slug: 'jenny-rostock',
}) })
}) })

View File

@ -28,14 +28,21 @@ beforeAll(async () => {
password: '1234', password: '1234',
}, },
), ),
Factory.build('user', { Factory.build(
id: 'u2', 'user',
role: 'user', {
name: 'Offensive Name', id: 'u2',
slug: 'offensive-name', role: 'user',
avatar: '/some/offensive/avatar.jpg', name: 'Offensive Name',
about: 'This self description is very offensive', slug: 'offensive-name',
}), about: 'This self description is very offensive',
},
{
avatar: Factory.build('image', {
url: '/some/offensive/avatar.jpg',
}),
},
),
neode.create('Category', { neode.create('Category', {
id: 'cat9', id: 'cat9',
name: 'Democracy & Politics', name: 'Democracy & Politics',
@ -96,10 +103,12 @@ beforeAll(async () => {
title: 'Disabled post', title: 'Disabled post',
content: 'This is an offensive post content', content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content', contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false, deleted: false,
}, },
{ {
image: Factory.build('image', {
url: '/some/offensive/image.jpg',
}),
author: troll, author: troll,
categoryIds, categoryIds,
}, },
@ -213,7 +222,9 @@ describe('softDeleteMiddleware', () => {
name name
slug slug
about about
avatar avatar {
url
}
} }
} }
} }
@ -229,7 +240,9 @@ describe('softDeleteMiddleware', () => {
contributions { contributions {
title title
slug slug
image image {
url
}
content content
contentExcerpt contentExcerpt
} }
@ -253,7 +266,10 @@ describe('softDeleteMiddleware', () => {
it('displays slug', () => expect(subject.slug).toEqual('offensive-name')) it('displays slug', () => expect(subject.slug).toEqual('offensive-name'))
it('displays about', () => it('displays about', () =>
expect(subject.about).toEqual('This self description is very offensive')) expect(subject.about).toEqual('This self description is very offensive'))
it('displays avatar', () => expect(subject.avatar).toEqual('/some/offensive/avatar.jpg')) it('displays avatar', () =>
expect(subject.avatar).toEqual({
url: expect.stringContaining('/some/offensive/avatar.jpg'),
}))
}) })
describe('Post', () => { describe('Post', () => {
@ -265,7 +281,10 @@ describe('softDeleteMiddleware', () => {
expect(subject.content).toEqual('This is an offensive post content')) expect(subject.content).toEqual('This is an offensive post content'))
it('displays contentExcerpt', () => it('displays contentExcerpt', () =>
expect(subject.contentExcerpt).toEqual('This is an offensive post content')) expect(subject.contentExcerpt).toEqual('This is an offensive post content'))
it('displays image', () => expect(subject.image).toEqual('/some/offensive/image.jpg')) it('displays image', () =>
expect(subject.image).toEqual({
url: expect.stringContaining('/some/offensive/image.jpg'),
}))
}) })
describe('Comment', () => { describe('Comment', () => {
@ -288,7 +307,7 @@ describe('softDeleteMiddleware', () => {
it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE')) it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE'))
it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE')) it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE'))
it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE')) it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE'))
it('obfuscates avatar', () => expect(subject.avatar).toEqual('UNAVAILABLE')) it('obfuscates avatar', () => expect(subject.avatar).toEqual(null))
}) })
describe('Post', () => { describe('Post', () => {

View File

@ -0,0 +1,7 @@
export default {
url: { primary: true, type: 'string', uri: { allowRelative: true } },
alt: { type: 'string' },
sensitive: { type: 'boolean', default: false },
aspectRatio: { type: 'float', default: 1.0 },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
}

View File

@ -4,6 +4,12 @@ export default {
id: { type: 'string', primary: true, default: uuid }, id: { type: 'string', primary: true, default: uuid },
activityId: { type: 'string', allow: [null] }, activityId: { type: 'string', allow: [null] },
objectId: { type: 'string', allow: [null] }, objectId: { type: 'string', allow: [null] },
image: {
type: 'relationship',
relationship: 'HERO_IMAGE',
target: 'Image',
direction: 'out',
},
author: { author: {
type: 'relationship', type: 'relationship',
relationship: 'WROTE', relationship: 'WROTE',
@ -14,7 +20,6 @@ export default {
slug: { type: 'string', allow: [null], unique: 'true' }, slug: { type: 'string', allow: [null], unique: 'true' },
content: { type: 'string', disallow: [null], min: 3 }, content: { type: 'string', disallow: [null], min: 3 },
contentExcerpt: { type: 'string', allow: [null] }, contentExcerpt: { type: 'string', allow: [null] },
image: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false }, deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false },
notified: { notified: {
@ -39,8 +44,6 @@ export default {
default: () => new Date().toISOString(), default: () => new Date().toISOString(),
}, },
language: { type: 'string', allow: [null] }, language: { type: 'string', allow: [null] },
imageBlurred: { type: 'boolean', default: false },
imageAspectRatio: { type: 'float', default: 1.0 },
comments: { comments: {
type: 'relationship', type: 'relationship',
relationship: 'COMMENTS', relationship: 'COMMENTS',

View File

@ -6,8 +6,12 @@ export default {
name: { type: 'string', disallow: [null], min: 3 }, name: { type: 'string', disallow: [null], min: 3 },
slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true }, slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true },
encryptedPassword: 'string', encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] }, avatar: {
coverImg: { type: 'string', allow: [null] }, type: 'relationship',
relationship: 'AVATAR_IMAGE',
target: 'Image',
direction: 'out',
},
deleted: { type: 'boolean', default: false }, deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false },
role: { type: 'string', default: 'user' }, role: { type: 'string', default: 'user' },

View File

@ -1,6 +1,7 @@
// NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm // NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm
// module that is not browser-compatible. Node's `fs` module is server-side only // module that is not browser-compatible. Node's `fs` module is server-side only
export default { export default {
Image: require('./Image.js').default,
Badge: require('./Badge.js').default, Badge: require('./Badge.js').default,
User: require('./User.js').default, User: require('./User.js').default,
EmailAddress: require('./EmailAddress.js').default, EmailAddress: require('./EmailAddress.js').default,

View File

@ -1,28 +0,0 @@
import { createWriteStream } from 'fs'
import path from 'path'
import slug from 'slug'
import { v4 as uuid } from 'uuid'
const localFileUpload = async ({ createReadStream, uniqueFilename }) => {
await new Promise((resolve, reject) =>
createReadStream()
.pipe(createWriteStream(`public${uniqueFilename}`))
.on('finish', resolve)
.on('error', reject),
)
return uniqueFilename
}
export default async function fileUpload(params, { file, url }, uploadCallback = localFileUpload) {
const upload = params[file]
if (upload) {
const { createReadStream, filename } = await upload
const { name, ext } = path.parse(filename)
const uniqueFilename = `/uploads/${uuid()}-${slug(name)}${ext}`
const location = await uploadCallback({ createReadStream, uniqueFilename })
delete params[file]
params[url] = location
}
return params
}

View File

@ -1,59 +0,0 @@
import fileUpload from '.'
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}'
describe('fileUpload', () => {
let params
let uploadCallback
beforeEach(() => {
params = {
uploadAttribute: {
filename: 'avatar.jpg',
mimetype: 'image/jpeg',
encoding: '7bit',
createReadStream: jest.fn(),
},
}
uploadCallback = jest.fn(({ uniqueFilename }) => uniqueFilename)
})
it('calls uploadCallback', async () => {
await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback)
expect(uploadCallback).toHaveBeenCalled()
})
describe('file name', () => {
it('saves the upload url in params[url]', async () => {
await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback)
expect(params.attribute).toMatch(new RegExp(`^/uploads/${uuid}-avatar.jpg`))
})
it('creates a url safe name', async () => {
params.uploadAttribute.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg'
await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback)
expect(params.attribute).toMatch(new RegExp(`/uploads/${uuid}-foo-bar-avatar.jpg$`))
})
describe('in case of duplicates', () => {
it('creates unique names to avoid overwriting existing files', async () => {
const { attribute: first } = await fileUpload(
{
...params,
},
{ file: 'uploadAttribute', url: 'attribute' },
uploadCallback,
)
const { attribute: second } = await fileUpload(
{
...params,
},
{ file: 'uploadAttribute', url: 'attribute' },
uploadCallback,
)
expect(first).not.toEqual(second)
})
})
})
})

View File

@ -0,0 +1,8 @@
import Resolver from './helpers/Resolver'
export default {
Image: {
...Resolver('Image', {
undefinedToNull: ['sensitive', 'alt', 'aspectRatio'],
}),
},
}

View File

@ -0,0 +1,121 @@
import path from 'path'
import { v4 as uuid } from 'uuid'
import slug from 'slug'
import { existsSync, unlinkSync, createWriteStream } from 'fs'
import { getDriver } from '../../../db/neo4j'
import { UserInputError } from 'apollo-server'
// const widths = [34, 160, 320, 640, 1024]
export async function deleteImage(resource, relationshipType, opts = {}) {
sanitizeRelationshipType(relationshipType)
const { transaction, deleteCallback } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(image:Image)
WITH image, image {.*} as imageProps
DETACH DELETE image
RETURN imageProps
`,
{ resource },
)
const [image] = txResult.records.map(record => record.get('imageProps'))
// This behaviour differs from `mergeImage`. If you call `mergeImage`
// with metadata for an image that does not exist, it's an indicator
// of an error (so throw an error). If we bulk delete an image, it
// could very well be that there is no image for the resource.
if (image) deleteImageFile(image, deleteCallback)
return image
}
export async function mergeImage(resource, relationshipType, imageInput, opts = {}) {
if (typeof imageInput === 'undefined') return
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
sanitizeRelationshipType(relationshipType)
const { transaction, uploadCallback, deleteCallback } = opts
if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
let txResult
txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})-[:${relationshipType}]->(image:Image)
RETURN image {.*}
`,
{ resource },
)
const [existingImage] = txResult.records.map(record => record.get('image'))
const { upload } = imageInput
if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource')
if (existingImage && upload) deleteImageFile(existingImage, deleteCallback)
const url = await uploadImageFile(upload, uploadCallback)
const { alt, sensitive, aspectRatio } = imageInput
const image = { alt, sensitive, aspectRatio, url }
txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})
MERGE (resource)-[:${relationshipType}]->(image:Image)
ON CREATE SET image.createdAt = toString(datetime())
ON MATCH SET image.updatedAt = toString(datetime())
SET image += $image
RETURN image {.*}
`,
{ resource, image },
)
const [mergedImage] = txResult.records.map(record => record.get('image'))
return mergedImage
}
const wrapTransaction = async (wrappedCallback, args, opts) => {
const session = getDriver().session()
try {
const result = await session.writeTransaction(async transaction => {
return wrappedCallback(...args, { ...opts, transaction })
})
return result
} finally {
session.close()
}
}
const deleteImageFile = (image, deleteCallback = localFileDelete) => {
const { url } = image
deleteCallback(url)
return url
}
const uploadImageFile = async (upload, uploadCallback = localFileUpload) => {
if (!upload) return undefined
const { createReadStream, filename, mimetype } = await upload
const { name, ext } = path.parse(filename)
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
return uploadCallback({
createReadStream,
destination: `/uploads/${uniqueFilename}`,
mimetype,
})
}
const sanitizeRelationshipType = relationshipType => {
// Cypher query language does not allow to parameterize relationship types
// See: https://github.com/neo4j/neo4j/issues/340
if (!['HERO_IMAGE', 'AVATAR_IMAGE'].includes(relationshipType)) {
throw new Error(`Unknown relationship type ${relationshipType}`)
}
}
const localFileUpload = ({ createReadStream, destination }) => {
return new Promise((resolve, reject) =>
createReadStream()
.pipe(createWriteStream(`public${destination}`))
.on('finish', () => resolve(destination))
.on('error', reject),
)
}
const localFileDelete = async url => {
const location = `public${url}`
if (existsSync(location)) unlinkSync(location)
}

View File

@ -0,0 +1,344 @@
import { deleteImage, mergeImage } from './images'
import { getNeode, getDriver } from '../../../db/neo4j'
import Factory, { cleanDatabase } from '../../../db/factories'
import { UserInputError } from 'apollo-server'
const driver = getDriver()
const neode = getNeode()
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}'
let uploadCallback
let deleteCallback
beforeEach(async () => {
await cleanDatabase()
uploadCallback = jest.fn(({ destination }) => destination)
deleteCallback = jest.fn()
})
describe('deleteImage', () => {
describe('given a resource with an image', () => {
let user
beforeEach(async () => {
user = await Factory.build(
'user',
{},
{
avatar: Factory.build('image', {
url: '/some/avatar/url/',
alt: 'This is the avatar image of a user',
}),
},
)
user = await user.toJson()
})
it('soft deletes `Image` node', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(1)
await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback })
await expect(neode.all('Image')).resolves.toHaveLength(0)
})
it('calls deleteCallback', async () => {
user = await Factory.build('user')
user = await user.toJson()
await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback })
expect(deleteCallback).toHaveBeenCalled()
})
describe('given a transaction parameter', () => {
it('executes cypher statements within the transaction', async () => {
const session = driver.session()
let someString
try {
someString = await session.writeTransaction(async transaction => {
await deleteImage(user, 'AVATAR_IMAGE', {
deleteCallback,
transaction,
})
const txResult = await transaction.run('RETURN "Hello" as result')
const [result] = txResult.records.map(record => record.get('result'))
return result
})
} finally {
session.close()
}
await expect(neode.all('Image')).resolves.toHaveLength(0)
await expect(someString).toEqual('Hello')
})
it('rolls back the transaction in case of errors', async done => {
await expect(neode.all('Image')).resolves.toHaveLength(1)
const session = driver.session()
try {
await session.writeTransaction(async transaction => {
await deleteImage(user, 'AVATAR_IMAGE', {
deleteCallback,
transaction,
})
throw new Error('Ouch!')
})
} catch (err) {
// nothing has been deleted
await expect(neode.all('Image')).resolves.toHaveLength(1)
// all good
done()
} finally {
session.close()
}
})
})
})
})
describe('mergeImage', () => {
let imageInput
let post
beforeEach(() => {
imageInput = {
alt: 'A description of the new image',
}
})
describe('on existing resource', () => {
beforeEach(async () => {
post = await Factory.build(
'post',
{ id: 'p99' },
{
author: Factory.build('user', {}, { avatar: null }),
image: null,
},
)
post = await post.toJson()
})
describe('given image.upload', () => {
beforeEach(() => {
imageInput = {
...imageInput,
upload: {
filename: 'image.jpg',
mimetype: 'image/jpeg',
encoding: '7bit',
createReadStream: () => ({
pipe: () => ({
on: (_, callback) => callback(),
}),
}),
},
}
})
it('returns new image', async () => {
await expect(
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),
).resolves.toMatchObject({
url: expect.any(String),
alt: 'A description of the new image',
})
})
it('calls upload callback', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
expect(uploadCallback).toHaveBeenCalled()
})
it('creates `:Image` node', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(0)
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
await expect(neode.all('Image')).resolves.toHaveLength(1)
})
it('creates a url safe name', async () => {
imageInput.upload.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg'
await expect(
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),
).resolves.toMatchObject({
url: expect.stringMatching(new RegExp(`^/uploads/${uuid}-foo-bar-avatar.jpg`)),
})
})
it.skip('automatically creates different image sizes', async () => {
await expect(
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),
).resolves.toEqual({
url: expect.any(String),
alt: expect.any(String),
urlW34: expect.stringMatching(new RegExp(`^/uploads/W34/${uuid}-image.jpg`)),
urlW160: expect.stringMatching(new RegExp(`^/uploads/W160/${uuid}-image.jpg`)),
urlW320: expect.stringMatching(new RegExp(`^/uploads/W320/${uuid}-image.jpg`)),
urlW640: expect.stringMatching(new RegExp(`^/uploads/W640/${uuid}-image.jpg`)),
urlW1024: expect.stringMatching(new RegExp(`^/uploads/W1024/${uuid}-image.jpg`)),
})
})
it('connects resource with image via given image type', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const result = await neode.cypher(`
MATCH(p:Post {id: "p99"})-[:HERO_IMAGE]->(i:Image) RETURN i,p
`)
post = neode.hydrateFirst(result, 'p', neode.model('Post'))
const image = neode.hydrateFirst(result, 'i', neode.model('Image'))
expect(post).toBeTruthy()
expect(image).toBeTruthy()
})
it('whitelists relationship types', async () => {
await expect(
mergeImage(post, 'WHATEVER', imageInput, { uploadCallback, deleteCallback }),
).rejects.toEqual(new Error('Unknown relationship type WHATEVER'))
})
it('sets metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const image = await neode.first('Image', {})
await expect(image.toJson()).resolves.toMatchObject({
alt: 'A description of the new image',
createdAt: expect.any(String),
url: expect.any(String),
})
})
describe('given a transaction parameter', () => {
it('executes cypher statements within the transaction', async () => {
const session = driver.session()
try {
await session.writeTransaction(async transaction => {
const image = await mergeImage(post, 'HERO_IMAGE', imageInput, {
uploadCallback,
deleteCallback,
transaction,
})
return transaction.run(
`
MATCH(image:Image {url: $image.url})
SET image.alt = 'This alt text gets overwritten'
RETURN image {.*}
`,
{ image },
)
})
} finally {
session.close()
}
const image = await neode.first('Image', { alt: 'This alt text gets overwritten' })
await expect(image.toJson()).resolves.toMatchObject({
alt: 'This alt text gets overwritten',
})
})
it('rolls back the transaction in case of errors', async done => {
const session = driver.session()
try {
await session.writeTransaction(async transaction => {
const image = await mergeImage(post, 'HERO_IMAGE', imageInput, {
uploadCallback,
deleteCallback,
transaction,
})
return transaction.run('Ooops invalid cypher!', { image })
})
} catch (err) {
// nothing has been created
await expect(neode.all('Image')).resolves.toHaveLength(0)
// all good
done()
} finally {
session.close()
}
})
})
describe('if resource has an image already', () => {
beforeEach(async () => {
const [post, image] = await Promise.all([
neode.find('Post', 'p99'),
Factory.build('image'),
])
await post.relateTo(image, 'image')
})
it('calls deleteCallback', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
expect(deleteCallback).toHaveBeenCalled()
})
it('calls uploadCallback', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
expect(uploadCallback).toHaveBeenCalled()
})
it('updates metadata of existing image node', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(1)
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
await expect(neode.all('Image')).resolves.toHaveLength(1)
const image = await neode.first('Image', {})
await expect(image.toJson()).resolves.toMatchObject({
alt: 'A description of the new image',
createdAt: expect.any(String),
url: expect.any(String),
// TODO
// width:
// height:
})
})
})
})
})
describe('without image.upload', () => {
it('throws UserInputError', async () => {
post = await Factory.build('post', { id: 'p99' }, { image: null })
post = await post.toJson()
await expect(mergeImage(post, 'HERO_IMAGE', imageInput)).rejects.toEqual(
new UserInputError('Cannot find image for given resource'),
)
})
describe('if resource has an image already', () => {
beforeEach(async () => {
post = await Factory.build(
'post',
{
id: 'p99',
},
{
author: Factory.build(
'user',
{},
{
avatar: null,
},
),
image: Factory.build('image', {
alt: 'This is the previous, not updated image',
url: '/some/original/url',
}),
},
)
post = await post.toJson()
})
it('does not call deleteCallback', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
expect(deleteCallback).not.toHaveBeenCalled()
})
it('does not call uploadCallback', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
expect(uploadCallback).not.toHaveBeenCalled()
})
it('updates metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput)
const images = await neode.all('Image')
expect(images).toHaveLength(1)
await expect(images.first().toJson()).resolves.toMatchObject({
createdAt: expect.any(String),
url: expect.any(String),
alt: 'A description of the new image',
})
})
})
})
})

View File

@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import fileUpload from './fileUpload' import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers' import { filterForMutedUsers } from './helpers/filterForMutedUsers'
@ -77,14 +77,16 @@ export default {
Mutation: { Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => { CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params const { categoryIds } = params
const { image: imageInput } = params
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) delete params.image
params.id = params.id || uuid() params.id = params.id || uuid()
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => { const writeTxResultPromise = session.writeTransaction(async transaction => {
const createPostTransactionResponse = await transaction.run( const createPostTransactionResponse = await transaction.run(
` `
CREATE (post:Post {params}) CREATE (post:Post)
SET post += $params
SET post.createdAt = toString(datetime()) SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime()) SET post.updatedAt = toString(datetime())
WITH post WITH post
@ -94,14 +96,18 @@ export default {
UNWIND $categoryIds AS categoryId UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId}) MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category) MERGE (post)-[:CATEGORIZED]->(category)
RETURN post RETURN post {.*}
`, `,
{ userId: context.user.id, categoryIds, params }, { userId: context.user.id, categoryIds, params },
) )
return createPostTransactionResponse.records.map(record => record.get('post').properties) const [post] = createPostTransactionResponse.records.map(record => record.get('post'))
if (imageInput) {
await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction })
}
return post
}) })
try { try {
const [post] = await writeTxResultPromise const post = await writeTxResultPromise
return post return post
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
@ -113,8 +119,9 @@ export default {
}, },
UpdatePost: async (_parent, params, context, _resolveInfo) => { UpdatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params const { categoryIds } = params
const { image: imageInput } = params
delete params.categoryIds delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) delete params.image
const session = context.driver.session() const session = context.driver.session()
let updatePostCypher = ` let updatePostCypher = `
MATCH (post:Post {id: $params.id}) MATCH (post:Post {id: $params.id})
@ -142,7 +149,7 @@ export default {
` `
} }
updatePostCypher += `RETURN post` updatePostCypher += `RETURN post {.*}`
const updatePostVariables = { categoryIds, params } const updatePostVariables = { categoryIds, params }
try { try {
const writeTxResultPromise = session.writeTransaction(async transaction => { const writeTxResultPromise = session.writeTransaction(async transaction => {
@ -150,9 +157,11 @@ export default {
updatePostCypher, updatePostCypher,
updatePostVariables, updatePostVariables,
) )
return updatePostTransactionResponse.records.map(record => record.get('post').properties) const [post] = updatePostTransactionResponse.records.map(record => record.get('post'))
await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction })
return post
}) })
const [post] = await writeTxResultPromise const post = await writeTxResultPromise
return post return post
} finally { } finally {
session.close() session.close()
@ -171,15 +180,16 @@ export default {
SET post.contentExcerpt = 'UNAVAILABLE' SET post.contentExcerpt = 'UNAVAILABLE'
SET post.title = 'UNAVAILABLE' SET post.title = 'UNAVAILABLE'
SET comment.deleted = TRUE SET comment.deleted = TRUE
REMOVE post.image RETURN post {.*}
RETURN post
`, `,
{ postId: args.id }, { postId: args.id },
) )
return deletePostTransactionResponse.records.map(record => record.get('post').properties) const [post] = deletePostTransactionResponse.records.map(record => record.get('post'))
await deleteImage(post, 'HERO_IMAGE', { transaction })
return post
}) })
try { try {
const [post] = await writeTxResultPromise const post = await writeTxResultPromise
return post return post
} finally { } finally {
session.close() session.close()
@ -311,16 +321,7 @@ export default {
}, },
Post: { Post: {
...Resolver('Post', { ...Resolver('Post', {
undefinedToNull: [ undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'],
'activityId',
'objectId',
'image',
'language',
'pinnedAt',
'pinned',
'imageBlurred',
'imageAspectRatio',
],
hasMany: { hasMany: {
tags: '-[:TAGGED]->(related:Tag)', tags: '-[:TAGGED]->(related:Tag)',
categories: '-[:CATEGORIZED]->(related:Category)', categories: '-[:CATEGORIZED]->(related:Category)',
@ -331,6 +332,7 @@ export default {
hasOne: { hasOne: {
author: '<-[:WROTE]-(related:User)', author: '<-[:WROTE]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)',
image: '-[:HERO_IMAGE]->(related:Image)',
}, },
count: { count: {
commentsCount: commentsCount:

View File

@ -336,8 +336,14 @@ describe('CreatePost', () => {
describe('UpdatePost', () => { describe('UpdatePost', () => {
let author, newlyCreatedPost let author, newlyCreatedPost
const updatePostMutation = gql` const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { UpdatePost(
id: $id
title: $title
content: $content
categoryIds: $categoryIds
image: $image
) {
id id
title title
content content
@ -472,418 +478,471 @@ describe('UpdatePost', () => {
) )
}) })
}) })
describe('params.image', () => {
describe('is object', () => {
beforeEach(() => {
variables = { ...variables, image: { sensitive: true } }
})
it('updates the image', async () => {
await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy()
})
})
describe('is null', () => {
beforeEach(() => {
variables = { ...variables, image: null }
})
it('deletes the image', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(6)
await mutate({ mutation: updatePostMutation, variables })
await expect(neode.all('Image')).resolves.toHaveLength(5)
})
})
describe('is undefined', () => {
beforeEach(() => {
delete variables.image
})
it('keeps the image unchanged', async () => {
await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy()
})
})
})
})
})
describe('pin posts', () => {
let author
const pinPostMutation = gql`
mutation($id: ID!) {
pinPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinnedAt
pinned
}
}
`
beforeEach(async () => {
author = await Factory.build('user', { slug: 'the-author' })
await Factory.build(
'post',
{
id: 'p9876',
title: 'Old title',
content: 'Old content',
},
{
author,
categoryIds,
},
)
variables = {
id: 'p9876',
}
}) })
describe('pin posts', () => { describe('unauthenticated', () => {
const pinPostMutation = gql` it('throws authorization error', async () => {
mutation($id: ID!) { authenticatedUser = null
pinPost(id: $id) { await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
id errors: [{ message: 'Not Authorised!' }],
title data: { pinPost: null },
content })
author { })
name })
slug
} describe('ordinary users', () => {
pinnedBy { it('throws authorization error', async () => {
id await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
name errors: [{ message: 'Not Authorised!' }],
role data: { pinPost: null },
} })
createdAt })
updatedAt })
pinnedAt
pinned describe('moderators', () => {
} let moderator
}
`
beforeEach(async () => { beforeEach(async () => {
variables = { ...variables } moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() })
authenticatedUser = await moderator.toJson()
}) })
describe('unauthenticated', () => { it('throws authorization error', async () => {
it('throws authorization error', async () => { await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
authenticatedUser = null errors: [{ message: 'Not Authorised!' }],
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ data: { pinPost: null },
errors: [{ message: 'Not Authorised!' }],
data: { pinPost: null },
})
}) })
}) })
})
describe('ordinary users', () => { describe('admins', () => {
it('throws authorization error', async () => { let admin
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ beforeEach(async () => {
errors: [{ message: 'Not Authorised!' }], admin = await user.update({
data: { pinPost: null }, role: 'admin',
}) name: 'Admin',
updatedAt: new Date().toISOString(),
}) })
authenticatedUser = await admin.toJson()
}) })
describe('moderators', () => { describe('are allowed to pin posts', () => {
let moderator
beforeEach(async () => { beforeEach(async () => {
moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) await Factory.build(
authenticatedUser = await moderator.toJson() 'post',
{
id: 'created-and-pinned-by-same-admin',
},
{
author: admin,
},
)
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
}) })
it('throws authorization error', async () => { it('responds with the updated Post', async () => {
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ const expected = {
errors: [{ message: 'Not Authorised!' }], data: {
data: { pinPost: null }, pinPost: {
})
})
})
describe('admins', () => {
let admin
beforeEach(async () => {
admin = await user.update({
role: 'admin',
name: 'Admin',
updatedAt: new Date().toISOString(),
})
authenticatedUser = await admin.toJson()
})
describe('are allowed to pin posts', () => {
beforeEach(async () => {
await Factory.build(
'post',
{
id: 'created-and-pinned-by-same-admin', id: 'created-and-pinned-by-same-admin',
}, author: {
{ name: 'Admin',
author: admin, },
}, pinnedBy: {
) id: 'current-user',
variables = { ...variables, id: 'created-and-pinned-by-same-admin' } name: 'Admin',
}) role: 'admin',
it('responds with the updated Post', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
author: {
name: 'Admin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
}, },
}, },
errors: undefined, },
} errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
})
it('sets createdAt date for PINNED', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
pinnedAt: expect.any(String),
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets redundant `pinned` property for performant ordering', async () => {
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
const expected = {
data: { pinPost: { pinned: true } },
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
}) })
describe('post created by another admin', () => { it('sets createdAt date for PINNED', async () => {
let otherAdmin const expected = {
beforeEach(async () => { data: {
otherAdmin = await Factory.build('user', { pinPost: {
role: 'admin', id: 'created-and-pinned-by-same-admin',
name: 'otherAdmin', pinnedAt: expect.any(String),
}) },
authenticatedUser = await otherAdmin.toJson() },
await Factory.build( errors: undefined,
'post', }
{ await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets redundant `pinned` property for performant ordering', async () => {
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
const expected = {
data: { pinPost: { pinned: true } },
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('post created by another admin', () => {
let otherAdmin
beforeEach(async () => {
otherAdmin = await Factory.build('user', {
role: 'admin',
name: 'otherAdmin',
})
authenticatedUser = await otherAdmin.toJson()
await Factory.build(
'post',
{
id: 'created-by-one-admin-pinned-by-different-one',
},
{
author: otherAdmin,
},
)
})
it('responds with the updated Post', async () => {
authenticatedUser = await admin.toJson()
variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' }
const expected = {
data: {
pinPost: {
id: 'created-by-one-admin-pinned-by-different-one', id: 'created-by-one-admin-pinned-by-different-one',
}, author: {
{ name: 'otherAdmin',
author: otherAdmin, },
}, pinnedBy: {
) id: 'current-user',
}) name: 'Admin',
role: 'admin',
it('responds with the updated Post', async () => {
authenticatedUser = await admin.toJson()
variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' }
const expected = {
data: {
pinPost: {
id: 'created-by-one-admin-pinned-by-different-one',
author: {
name: 'otherAdmin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
}, },
}, },
errors: undefined, },
} errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
})
}) })
})
describe('post created by another user', () => { describe('post created by another user', () => {
it('responds with the updated Post', async () => { it('responds with the updated Post', async () => {
const expected = { const expected = {
data: { data: {
pinPost: { pinPost: {
id: 'p9876', id: 'p9876',
author: { author: {
slug: 'the-author', slug: 'the-author',
}, },
pinnedBy: { pinnedBy: {
id: 'current-user', id: 'current-user',
name: 'Admin', name: 'Admin',
role: 'admin', role: 'admin',
},
}, },
}, },
errors: undefined, },
} errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
})
})
describe('pinned post already exists', () => {
let pinnedPost
beforeEach(async () => {
await Factory.build(
'post',
{
id: 'only-pinned-post',
},
{
author: admin,
},
)
await mutate({ mutation: pinPostMutation, variables })
})
it('removes previous `pinned` attribute', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post'
pinnedPost = await neode.cypher(cypher)
expect(pinnedPost.records).toHaveLength(1)
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await neode.cypher(cypher)
expect(pinnedPost.records).toHaveLength(1)
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
)
expect(pinnedPost.records).toHaveLength(1)
})
})
describe('PostOrdering', () => {
beforeEach(async () => {
await Factory.build('post', {
id: 'im-a-pinned-post',
createdAt: '2019-11-22T17:26:29.070Z',
pinned: true,
})
await Factory.build('post', {
id: 'i-was-created-before-pinned-post',
// fairly old, so this should be 3rd
createdAt: '2019-10-22T17:26:29.070Z',
}) })
}) })
describe('pinned post already exists', () => { describe('order by `pinned_asc` and `createdAt_desc`', () => {
let pinnedPost beforeEach(() => {
beforeEach(async () => { // this is the ordering in the frontend
await Factory.build( variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
'post',
{
id: 'only-pinned-post',
},
{
author: admin,
},
)
await mutate({ mutation: pinPostMutation, variables })
}) })
it('removes previous `pinned` attribute', async () => { it('pinned post appear first even when created before other posts', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' const postOrderingQuery = gql`
pinnedPost = await neode.cypher(cypher) query($orderBy: [_PostOrdering]) {
expect(pinnedPost.records).toHaveLength(1) Post(orderBy: $orderBy) {
variables = { ...variables, id: 'only-pinned-post' } id
await mutate({ mutation: pinPostMutation, variables }) pinned
pinnedPost = await neode.cypher(cypher) createdAt
expect(pinnedPost.records).toHaveLength(1) pinnedAt
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
)
expect(pinnedPost.records).toHaveLength(1)
})
})
describe('PostOrdering', () => {
beforeEach(async () => {
await Factory.build('post', {
id: 'im-a-pinned-post',
createdAt: '2019-11-22T17:26:29.070Z',
pinned: true,
})
await Factory.build('post', {
id: 'i-was-created-before-pinned-post',
// fairly old, so this should be 3rd
createdAt: '2019-10-22T17:26:29.070Z',
})
})
describe('order by `pinned_asc` and `createdAt_desc`', () => {
beforeEach(() => {
// this is the ordering in the frontend
variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
})
it('pinned post appear first even when created before other posts', async () => {
const postOrderingQuery = gql`
query($orderBy: [_PostOrdering]) {
Post(orderBy: $orderBy) {
id
pinned
createdAt
pinnedAt
}
} }
` }
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ `
data: { await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({
Post: [ data: {
{ Post: [
id: 'im-a-pinned-post', {
pinned: true, id: 'im-a-pinned-post',
createdAt: '2019-11-22T17:26:29.070Z', pinned: true,
pinnedAt: expect.any(String), createdAt: '2019-11-22T17:26:29.070Z',
}, pinnedAt: expect.any(String),
{ },
id: 'p9876', {
pinned: null, id: 'p9876',
createdAt: expect.any(String), pinned: null,
pinnedAt: null, createdAt: expect.any(String),
}, pinnedAt: null,
{ },
id: 'i-was-created-before-pinned-post', {
pinned: null, id: 'i-was-created-before-pinned-post',
createdAt: '2019-10-22T17:26:29.070Z', pinned: null,
pinnedAt: null, createdAt: '2019-10-22T17:26:29.070Z',
}, pinnedAt: null,
], },
}, ],
errors: undefined, },
}) errors: undefined,
}) })
}) })
}) })
}) })
}) })
})
describe('unpin posts', () => { describe('unpin posts', () => {
const unpinPostMutation = gql` let pinnedPost
mutation($id: ID!) { const unpinPostMutation = gql`
unpinPost(id: $id) { mutation($id: ID!) {
unpinPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id id
title name
content role
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinned
pinnedAt
} }
createdAt
updatedAt
pinned
pinnedAt
} }
` }
`
beforeEach(async () => {
pinnedPost = await Factory.build('post', { id: 'post-to-be-unpinned' })
variables = {
id: 'post-to-be-unpinned',
}
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { unpinPost: null },
})
})
})
describe('users cannot unpin posts', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { unpinPost: null },
})
})
})
describe('moderators cannot unpin posts', () => {
let moderator
beforeEach(async () => { beforeEach(async () => {
variables = { ...variables } moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() })
authenticatedUser = await moderator.toJson()
}) })
describe('unauthenticated', () => { it('throws authorization error', async () => {
it('throws authorization error', async () => { await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({
authenticatedUser = null errors: [{ message: 'Not Authorised!' }],
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ data: { unpinPost: null },
errors: [{ message: 'Not Authorised!' }],
data: { unpinPost: null },
})
}) })
}) })
})
describe('users cannot unpin posts', () => { describe('admin can unpin posts', () => {
it('throws authorization error', async () => { let admin
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({ beforeEach(async () => {
errors: [{ message: 'Not Authorised!' }], admin = await user.update({
data: { unpinPost: null }, role: 'admin',
}) name: 'Admin',
updatedAt: new Date().toISOString(),
}) })
authenticatedUser = await admin.toJson()
await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() })
}) })
describe('moderators cannot unpin posts', () => { it('responds with the unpinned Post', async () => {
let moderator authenticatedUser = await admin.toJson()
beforeEach(async () => { const expected = {
moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) data: {
authenticatedUser = await moderator.toJson() unpinPost: {
}) id: 'post-to-be-unpinned',
pinnedBy: null,
it('throws authorization error', async () => { pinnedAt: null,
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { unpinPost: null },
})
})
})
describe('admin can unpin posts', () => {
let admin, pinnedPost
beforeEach(async () => {
pinnedPost = await Factory.build('post', { id: 'post-to-be-unpinned' })
admin = await user.update({
role: 'admin',
name: 'Admin',
updatedAt: new Date().toISOString(),
})
authenticatedUser = await admin.toJson()
await admin.relateTo(pinnedPost, 'pinned', { createdAt: new Date().toISOString() })
variables = { ...variables, id: 'post-to-be-unpinned' }
})
it('responds with the unpinned Post', async () => {
authenticatedUser = await admin.toJson()
const expected = {
data: {
unpinPost: {
id: 'post-to-be-unpinned',
pinnedBy: null,
pinnedAt: null,
},
}, },
errors: undefined, },
} errors: undefined,
}
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject(
expected, expected,
) )
}) })
it('unsets `pinned` property', async () => { it('unsets `pinned` property', async () => {
const expected = { const expected = {
data: { data: {
unpinPost: { unpinPost: {
id: 'post-to-be-unpinned', id: 'post-to-be-unpinned',
pinned: null, pinned: null,
},
}, },
errors: undefined, },
} errors: undefined,
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject( }
expected, await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject(
) expected,
}) )
}) })
}) })
}) })
@ -897,7 +956,9 @@ describe('DeletePost', () => {
deleted deleted
content content
contentExcerpt contentExcerpt
image image {
url
}
comments { comments {
deleted deleted
content content
@ -915,9 +976,11 @@ describe('DeletePost', () => {
id: 'p4711', id: 'p4711',
title: 'I will be deleted', title: 'I will be deleted',
content: 'To be deleted', content: 'To be deleted',
image: 'path/to/some/image',
}, },
{ {
image: Factory.build('image', {
url: 'path/to/some/image',
}),
author, author,
categoryIds, categoryIds,
}, },

View File

@ -1,11 +1,9 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import { getNeode } from '../../db/neo4j' import { getNeode } from '../../db/neo4j'
import fileUpload from './fileUpload'
import encryptPassword from '../../helpers/encryptPassword' import encryptPassword from '../../helpers/encryptPassword'
import generateNonce from './helpers/generateNonce' import generateNonce from './helpers/generateNonce'
import existingEmailAddress from './helpers/existingEmailAddress' import existingEmailAddress from './helpers/existingEmailAddress'
import normalizeEmail from './helpers/normalizeEmail' import normalizeEmail from './helpers/normalizeEmail'
import createOrUpdateLocations from './users/location'
const neode = getNeode() const neode = getNeode()
@ -24,8 +22,6 @@ export default {
} }
}, },
SignupVerification: async (_parent, args, context) => { SignupVerification: async (_parent, args, context) => {
const { driver } = context
const session = driver.session()
const { termsAndConditionsAgreedVersion } = args const { termsAndConditionsAgreedVersion } = args
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) { if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -35,27 +31,39 @@ export default {
let { nonce, email } = args let { nonce, email } = args
email = normalizeEmail(email) email = normalizeEmail(email)
const result = await neode.cypher( delete args.nonce
` delete args.email
MATCH(email:EmailAddress {nonce: {nonce}, email: {email}}) args = encryptPassword(args)
WHERE NOT (email)-[:BELONGS_TO]->()
RETURN email const { driver } = context
`, const session = driver.session()
{ nonce, email }, const writeTxResultPromise = session.writeTransaction(async transaction => {
) const createUserTransactionResponse = await transaction.run(
const emailAddress = await neode.hydrateFirst(result, 'email', neode.model('EmailAddress')) `
if (!emailAddress) throw new UserInputError('Invalid email or nonce') MATCH(email:EmailAddress {nonce: $nonce, email: $email})
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) WHERE NOT (email)-[:BELONGS_TO]->()
args = await encryptPassword(args) CREATE (user:User)
MERGE(user)-[:PRIMARY_EMAIL]->(email)
MERGE(user)<-[:BELONGS_TO]-(email)
SET user += $args
SET user.id = randomUUID()
SET user.role = 'user'
SET user.createdAt = toString(datetime())
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = FALSE
SET user.showShoutsPublicly = FALSE
SET email.verifiedAt = toString(datetime())
RETURN user {.*}
`,
{ args, nonce, email },
)
const [user] = createUserTransactionResponse.records.map(record => record.get('user'))
if (!user) throw new UserInputError('Invalid email or nonce')
return user
})
try { try {
const user = await neode.create('User', args) const user = await writeTxResultPromise
await Promise.all([ return user
user.relateTo(emailAddress, 'primaryEmail'),
emailAddress.relateTo(user, 'belongsTo'),
emailAddress.update({ verifiedAt: new Date().toISOString() }),
])
await createOrUpdateLocations(args.id, args.locationName, session)
return user.toJson()
} catch (e) { } catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('User with this slug already exists!') throw new UserInputError('User with this slug already exists!')

View File

@ -48,7 +48,7 @@ export default {
const loginTransactionResponse = await transaction.run( const loginTransactionResponse = await transaction.run(
` `
MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail}) MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})
RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1 RETURN user {.id, .slug, .name, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1
`, `,
{ userEmail: email }, { userEmail: email },
) )

View File

@ -106,7 +106,9 @@ describe('currentUser', () => {
id id
slug slug
name name
avatar avatar {
url
}
email email
role role
} }
@ -131,13 +133,15 @@ describe('currentUser', () => {
{ {
id: 'u3', id: 'u3',
// the `id` is the only thing that has to match the decoded JWT bearer token // the `id` is the only thing that has to match the decoded JWT bearer token
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
name: 'Matilde Hermiston', name: 'Matilde Hermiston',
slug: 'matilde-hermiston', slug: 'matilde-hermiston',
role: 'user', role: 'user',
}, },
{ {
email: 'test@example.org', email: 'test@example.org',
avatar: Factory.build('image', {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
}),
}, },
) )
const userBearerToken = encode({ id: 'u3' }) const userBearerToken = encode({ id: 'u3' })
@ -149,7 +153,9 @@ describe('currentUser', () => {
data: { data: {
currentUser: { currentUser: {
id: 'u3', id: 'u3',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', avatar: Factory.build('image', {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
}),
email: 'test@example.org', email: 'test@example.org',
name: 'Matilde Hermiston', name: 'Matilde Hermiston',
slug: 'matilde-hermiston', slug: 'matilde-hermiston',

View File

@ -1,7 +1,7 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { getNeode } from '../../db/neo4j' import { getNeode } from '../../db/neo4j'
import { UserInputError, ForbiddenError } from 'apollo-server' import { UserInputError, ForbiddenError } from 'apollo-server'
import { mergeImage, deleteImage } from './images/images'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import log from './helpers/databaseLogger' import log from './helpers/databaseLogger'
import createOrUpdateLocations from './users/location' import createOrUpdateLocations from './users/location'
@ -140,6 +140,8 @@ export default {
}, },
UpdateUser: async (_parent, params, context, _resolveInfo) => { UpdateUser: async (_parent, params, context, _resolveInfo) => {
const { termsAndConditionsAgreedVersion } = params const { termsAndConditionsAgreedVersion } = params
const { avatar: avatarInput } = params
delete params.avatar
if (termsAndConditionsAgreedVersion) { if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) { if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -147,7 +149,6 @@ export default {
} }
params.termsAndConditionsAgreedAt = new Date().toISOString() params.termsAndConditionsAgreedAt = new Date().toISOString()
} }
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => { const writeTxResultPromise = session.writeTransaction(async transaction => {
@ -156,14 +157,18 @@ export default {
MATCH (user:User {id: $params.id}) MATCH (user:User {id: $params.id})
SET user += $params SET user += $params
SET user.updatedAt = toString(datetime()) SET user.updatedAt = toString(datetime())
RETURN user RETURN user {.*}
`, `,
{ params }, { params },
) )
return updateUserTransactionResponse.records.map(record => record.get('user').properties) const [user] = updateUserTransactionResponse.records.map(record => record.get('user'))
if (avatarInput) {
await mergeImage(user, 'AVATAR_IMAGE', avatarInput, { transaction })
}
return user
}) })
try { try {
const [user] = await writeTxResultPromise const user = await writeTxResultPromise
await createOrUpdateLocations(params.id, params.locationName, session) await createOrUpdateLocations(params.id, params.locationName, session)
return user return user
} catch (error) { } catch (error) {
@ -173,34 +178,38 @@ export default {
} }
}, },
DeleteUser: async (object, params, context, resolveInfo) => { DeleteUser: async (object, params, context, resolveInfo) => {
const { resource } = params const { resource, id: userId } = params
const session = context.driver.session() const session = context.driver.session()
const { id: userId } = params
try { const deleteUserTxResultPromise = session.writeTransaction(async transaction => {
if (resource && resource.length) { if (resource && resource.length) {
await session.writeTransaction(transaction => { await Promise.all(
resource.map(node => { resource.map(async node => {
return transaction.run( const txResult = await transaction.run(
` `
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId}) MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment) OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
SET resource.deleted = true SET resource.deleted = true
SET resource.content = 'UNAVAILABLE' SET resource.content = 'UNAVAILABLE'
SET resource.contentExcerpt = 'UNAVAILABLE' SET resource.contentExcerpt = 'UNAVAILABLE'
SET comment.deleted = true SET comment.deleted = true
RETURN author RETURN resource {.*}
`, `,
{ {
userId, userId,
}, },
) )
}) return Promise.all(
}) txResult.records
.map(record => record.get('resource'))
.map(resource => deleteImage(resource, 'HERO_IMAGE', { transaction })),
)
}),
)
} }
const deleteUserTxResultPromise = session.writeTransaction(async transaction => { const deleteUserTransactionResponse = await transaction.run(
const deleteUserTransactionResponse = await transaction.run( `
`
MATCH (user:User {id: $userId}) MATCH (user:User {id: $userId})
SET user.deleted = true SET user.deleted = true
SET user.name = 'UNAVAILABLE' SET user.name = 'UNAVAILABLE'
@ -211,14 +220,17 @@ export default {
WITH user WITH user
OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia) OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia)
DETACH DELETE socialMedia DETACH DELETE socialMedia
RETURN user RETURN user {.*}
`, `,
{ userId }, { userId },
) )
log(deleteUserTransactionResponse) log(deleteUserTransactionResponse)
return deleteUserTransactionResponse.records.map(record => record.get('user').properties) const [user] = deleteUserTransactionResponse.records.map(record => record.get('user'))
}) await deleteImage(user, 'AVATAR_IMAGE', { transaction })
const [user] = await deleteUserTxResultPromise return user
})
try {
const user = await deleteUserTxResultPromise
return user return user
} finally { } finally {
session.close() session.close()
@ -237,8 +249,6 @@ export default {
...Resolver('User', { ...Resolver('User', {
undefinedToNull: [ undefinedToNull: [
'actorId', 'actorId',
'avatar',
'coverImg',
'deleted', 'deleted',
'disabled', 'disabled',
'locationName', 'locationName',
@ -272,6 +282,7 @@ export default {
badgesCount: '<-[:REWARDED]-(related:Badge)', badgesCount: '<-[:REWARDED]-(related:Badge)',
}, },
hasOne: { hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
invitedBy: '<-[:INVITED]-(related:User)', invitedBy: '<-[:INVITED]-(related:User)',
location: '-[:IS_IN]->(related:Location)', location: '-[:IS_IN]->(related:Location)',
}, },

View File

@ -29,7 +29,7 @@ beforeAll(() => {
mutate = createTestClient(server).mutate mutate = createTestClient(server).mutate
}) })
afterEach(async () => { beforeEach(async () => {
await cleanDatabase() await cleanDatabase()
}) })
@ -495,6 +495,12 @@ describe('DeleteUser', () => {
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
).resolves.toMatchObject(expectedResponse) ).resolves.toMatchObject(expectedResponse)
}) })
it('deletes user avatar and post hero images', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(22)
await mutate({ mutation: deleteUserMutation, variables })
await expect(neode.all('Image')).resolves.toHaveLength(20)
})
}) })
}) })
@ -785,6 +791,12 @@ describe('DeleteUser', () => {
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
).resolves.toMatchObject(expectedResponse) ).resolves.toMatchObject(expectedResponse)
}) })
it('deletes user avatar and post hero images', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(22)
await mutate({ mutation: deleteUserMutation, variables })
await expect(neode.all('Image')).resolves.toHaveLength(20)
})
}) })
}) })
@ -834,7 +846,6 @@ describe('DeleteUser', () => {
).resolves.toMatchObject(expectedResponse) ).resolves.toMatchObject(expectedResponse)
}) })
}) })
describe('deletion of all post and comments requested', () => { describe('deletion of all post and comments requested', () => {
beforeEach(() => { beforeEach(() => {
variables = { ...variables, resource: ['Post', 'Comment'] } variables = { ...variables, resource: ['Post', 'Comment'] }
@ -882,27 +893,27 @@ describe('DeleteUser', () => {
}) })
}) })
}) })
})
})
describe('connected `EmailAddress` nodes', () => { describe('connected `EmailAddress` nodes', () => {
it('will be removed completely', async () => { it('will be removed completely', async () => {
await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
await mutate({ mutation: deleteUserMutation, variables }) await mutate({ mutation: deleteUserMutation, variables })
await expect(neode.all('EmailAddress')).resolves.toHaveLength(1) await expect(neode.all('EmailAddress')).resolves.toHaveLength(1)
}) })
}) })
describe('connected `SocialMedia` nodes', () => { describe('connected `SocialMedia` nodes', () => {
beforeEach(async () => { beforeEach(async () => {
const socialMedia = await Factory.build('socialMedia') const socialMedia = await Factory.build('socialMedia')
await socialMedia.relateTo(user, 'ownedBy') await socialMedia.relateTo(user, 'ownedBy')
}) })
it('will be removed completely', async () => { it('will be removed completely', async () => {
await expect(neode.all('SocialMedia')).resolves.toHaveLength(1) await expect(neode.all('SocialMedia')).resolves.toHaveLength(1)
await mutate({ mutation: deleteUserMutation, variables }) await mutate({ mutation: deleteUserMutation, variables })
await expect(neode.all('SocialMedia')).resolves.toHaveLength(0) await expect(neode.all('SocialMedia')).resolves.toHaveLength(0)
})
})
}) })
}) })
}) })

View File

@ -8,28 +8,6 @@ const neode = getNeode()
const driver = getDriver() const driver = getDriver()
let authenticatedUser, mutate, variables let authenticatedUser, mutate, variables
const signupVerificationMutation = gql`
mutation(
$name: String!
$password: String!
$email: String!
$nonce: String!
$termsAndConditionsAgreedVersion: String!
$locationName: String
) {
SignupVerification(
name: $name
password: $password
email: $email
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) {
locationName
}
}
`
const updateUserMutation = gql` const updateUserMutation = gql`
mutation($id: ID!, $name: String!, $locationName: String) { mutation($id: ID!, $name: String!, $locationName: String) {
UpdateUser(id: $id, name: $name, locationName: $locationName) { UpdateUser(id: $id, name: $name, locationName: $locationName) {
@ -38,9 +16,10 @@ const updateUserMutation = gql`
} }
` `
let newlyCreatedNodesWithLocales = [ const newlyCreatedNodesWithLocales = [
{ {
city: { city: {
lng: -74.5763,
lat: 41.1534, lat: 41.1534,
nameES: 'Hamburg', nameES: 'Hamburg',
nameFR: 'Hamburg', nameFR: 'Hamburg',
@ -54,7 +33,6 @@ let newlyCreatedNodesWithLocales = [
name: 'Hamburg', name: 'Hamburg',
namePL: 'Hamburg', namePL: 'Hamburg',
id: 'place.5977106083398860', id: 'place.5977106083398860',
lng: -74.5763,
}, },
state: { state: {
namePT: 'Nova Jérsia', namePT: 'Nova Jérsia',
@ -105,82 +83,12 @@ beforeEach(() => {
authenticatedUser = null authenticatedUser = null
}) })
afterEach(() => { afterEach(cleanDatabase)
cleanDatabase()
})
describe('userMiddleware', () => { describe('userMiddleware', () => {
describe('SignupVerification', () => {
beforeEach(async () => {
variables = {
...variables,
name: 'John Doe',
password: '123',
email: 'john@example.org',
nonce: '123456',
termsAndConditionsAgreedVersion: '0.1.0',
locationName: 'Hamburg, New Jersey, United States of America',
}
const args = {
email: 'john@example.org',
nonce: '123456',
}
await neode.model('EmailAddress').create(args)
})
it('creates a Location node with localised city/state/country names', async () => {
await mutate({ mutation: signupVerificationMutation, variables })
const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city, state, country`,
)
expect(
locations.records.map(record => {
return {
city: record.get('city').properties,
state: record.get('state').properties,
country: record.get('country').properties,
}
}),
).toEqual(newlyCreatedNodesWithLocales)
})
})
describe('UpdateUser', () => { describe('UpdateUser', () => {
let user let user
beforeEach(async () => { beforeEach(async () => {
newlyCreatedNodesWithLocales = [
{
city: {
lat: 53.55,
nameES: 'Hamburgo',
nameFR: 'Hambourg',
nameIT: 'Amburgo',
nameEN: 'Hamburg',
type: 'region',
namePT: 'Hamburgo',
nameRU: 'Гамбург',
nameDE: 'Hamburg',
nameNL: 'Hamburg',
namePL: 'Hamburg',
name: 'Hamburg',
id: 'region.10793468240398860',
lng: 10,
},
country: {
namePT: 'Alemanha',
nameRU: 'Германия',
nameDE: 'Deutschland',
nameNL: 'Duitsland',
nameES: 'Alemania',
name: 'Germany',
namePL: 'Niemcy',
nameFR: 'Allemagne',
nameIT: 'Germania',
id: 'country.10743216036480410',
nameEN: 'Germany',
type: 'country',
},
},
]
user = await Factory.build('user', { user = await Factory.build('user', {
id: 'updating-user', id: 'updating-user',
}) })
@ -192,17 +100,18 @@ describe('userMiddleware', () => {
...variables, ...variables,
id: 'updating-user', id: 'updating-user',
name: 'Updating user', name: 'Updating user',
locationName: 'Hamburg, Germany', locationName: 'Hamburg, New Jersey, United States of America',
} }
await mutate({ mutation: updateUserMutation, variables }) await mutate({ mutation: updateUserMutation, variables })
const locations = await neode.cypher( const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(country:Location) return city, country`, `MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city {.*}, state {.*}, country {.*}`,
) )
expect( expect(
locations.records.map(record => { locations.records.map(record => {
return { return {
city: record.get('city').properties, city: record.get('city'),
country: record.get('country').properties, state: record.get('state'),
country: record.get('country'),
} }
}), }),
).toEqual(newlyCreatedNodesWithLocales) ).toEqual(newlyCreatedNodesWithLocales)

View File

@ -9,14 +9,10 @@ type Mutation {
SignupByInvitation(email: String!, token: String!): EmailAddress SignupByInvitation(email: String!, token: String!): EmailAddress
SignupVerification( SignupVerification(
nonce: String! nonce: String!
name: String!
email: String! email: String!
name: String!
password: String! password: String!
slug: String slug: String
avatar: String
coverImg: String
avatarUpload: Upload
locationName: String
about: String about: String
termsAndConditionsAgreedVersion: String! termsAndConditionsAgreedVersion: String!
locale: String locale: String

View File

@ -0,0 +1,18 @@
type Image {
url: ID!,
# urlW34: String,
# urlW160: String,
# urlW320: String,
# urlW640: String,
# urlW1024: String,
alt: String,
sensitive: Boolean,
aspectRatio: Float,
}
input ImageInput {
alt: String,
upload: Upload,
sensitive: Boolean,
aspectRatio: Float,
}

View File

@ -40,7 +40,6 @@ input _PostFilter {
content_not_starts_with: String content_not_starts_with: String
content_ends_with: String content_ends_with: String
content_not_ends_with: String content_not_ends_with: String
image: String
visibility: Visibility visibility: Visibility
visibility_not: Visibility visibility_not: Visibility
visibility_in: [Visibility!] visibility_in: [Visibility!]
@ -82,7 +81,6 @@ input _PostFilter {
emotions_none: _PostEMOTEDFilter emotions_none: _PostEMOTEDFilter
emotions_single: _PostEMOTEDFilter emotions_single: _PostEMOTEDFilter
emotions_every: _PostEMOTEDFilter emotions_every: _PostEMOTEDFilter
imageBlurred: Boolean
} }
enum _PostOrdering { enum _PostOrdering {
@ -94,8 +92,6 @@ enum _PostOrdering {
slug_desc slug_desc
content_asc content_asc
content_desc content_desc
image_asc
image_desc
visibility_asc visibility_asc
visibility_desc visibility_desc
createdAt_asc createdAt_asc
@ -118,9 +114,7 @@ type Post {
slug: String! slug: String!
content: String! content: String!
contentExcerpt: String contentExcerpt: String
image: String image: Image @relation(name: "HERO_IMAGE", direction: "OUT")
imageUpload: Upload
imageAspectRatio: Float
visibility: Visibility visibility: Visibility
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean
@ -128,7 +122,6 @@ type Post {
createdAt: String createdAt: String
updatedAt: String updatedAt: String
language: String language: String
imageBlurred: Boolean
pinnedAt: String @cypher( pinnedAt: String @cypher(
statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt" statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt"
) )
@ -178,14 +171,11 @@ type Mutation {
title: String! title: String!
slug: String slug: String
content: String! content: String!
image: String image: ImageInput,
imageUpload: Upload
visibility: Visibility visibility: Visibility
language: String language: String
categoryIds: [ID] categoryIds: [ID]
contentExcerpt: String contentExcerpt: String
imageBlurred: Boolean
imageAspectRatio: Float
): Post ): Post
UpdatePost( UpdatePost(
id: ID! id: ID!
@ -193,13 +183,10 @@ type Mutation {
slug: String slug: String
content: String! content: String!
contentExcerpt: String contentExcerpt: String
image: String image: ImageInput,
imageUpload: Upload
visibility: Visibility visibility: Visibility
language: String language: String
categoryIds: [ID] categoryIds: [ID]
imageBlurred: Boolean
imageAspectRatio: Float
): Post ): Post
DeletePost(id: ID!): Post DeletePost(id: ID!): Post
AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED

View File

@ -5,10 +5,6 @@ enum _UserOrdering {
name_desc name_desc
slug_asc slug_asc
slug_desc slug_desc
avatar_asc
avatar_desc
coverImg_asc
coverImg_desc
role_asc role_asc
role_desc role_desc
locationName_asc locationName_asc
@ -29,8 +25,7 @@ type User {
name: String name: String
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email") email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
slug: String! slug: String!
avatar: String avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT")
coverImg: String
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean
role: UserGroup! role: UserGroup!
@ -161,8 +156,6 @@ type Query {
email: String # admins need to search for a user sometimes email: String # admins need to search for a user sometimes
name: String name: String
slug: String slug: String
avatar: String
coverImg: String
role: UserGroup role: UserGroup
locationName: String locationName: String
about: String about: String
@ -198,9 +191,7 @@ type Mutation {
name: String name: String
email: String email: String
slug: String slug: String
avatar: String avatar: ImageInput
coverImg: String
avatarUpload: Upload
locationName: String locationName: String
about: String about: String
termsAndConditionsAgreedVersion: String termsAndConditionsAgreedVersion: String

View File

@ -4498,14 +4498,14 @@ graphql-redis-subscriptions@^2.2.1:
optionalDependencies: optionalDependencies:
ioredis "^4.6.3" ioredis "^4.6.3"
graphql-shield@~7.2.0: graphql-shield@~7.0.14:
version "7.2.0" version "7.0.14"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.2.0.tgz#81b26794370608ad78dfe3833473789fb471fbd8" resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.14.tgz#3cbbf2722f2e3393fed7f47d866a1324bc3ce76a"
integrity sha512-eLdD+gUIKYu77XRcuHs5ewZhiBuRFeWFGxPnJa+g9AkxB7Yi5RSEjEJEx0Drg9GuNvDYpHeW7nPff4v35AT2aQ== integrity sha512-YVedaL+4pITisSGRqMVeGX8ydOLSTQlHQN6o0Jly7z2cSy1wOzGJIRpfofETJtGLhBnPHHy1otINzuAyjGJO/g==
dependencies: dependencies:
"@types/yup" "0.26.32" "@types/yup" "0.26.32"
object-hash "^2.0.3" object-hash "^2.0.3"
yup "^0.28.3" yup "^0.28.1"
graphql-subscriptions@^1.0.0: graphql-subscriptions@^1.0.0:
version "1.1.0" version "1.1.0"
@ -6684,7 +6684,7 @@ nodemailer-html-to-text@^3.1.0:
dependencies: dependencies:
html-to-text "^5.1.1" html-to-text "^5.1.1"
nodemailer@^6.4.5: nodemailer@^6.4.4:
version "6.4.5" version "6.4.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.5.tgz#45614c6454d1a947242105eeddae03df87e29916" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.5.tgz#45614c6454d1a947242105eeddae03df87e29916"
integrity sha512-NH7aNVQyZLAvGr2+EOto7znvz+qJ02Cb/xpou98ApUt5tEAUSVUxhvHvgV/8I5dhjKTYqUw0nasoKzLNBJKrDQ== integrity sha512-NH7aNVQyZLAvGr2+EOto7znvz+qJ02Cb/xpou98ApUt5tEAUSVUxhvHvgV/8I5dhjKTYqUw0nasoKzLNBJKrDQ==
@ -7888,7 +7888,7 @@ sane@^4.0.3:
minimist "^1.1.1" minimist "^1.1.1"
walker "~1.0.5" walker "~1.0.5"
sanitize-html@~1.22.1: sanitize-html@~1.22.0:
version "1.22.1" version "1.22.1"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.22.1.tgz#5b36c92ab27917ddd2775396815c2bc1a6268310" resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.22.1.tgz#5b36c92ab27917ddd2775396815c2bc1a6268310"
integrity sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q== integrity sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q==
@ -9378,7 +9378,7 @@ yargs@^15.0.0:
y18n "^4.0.0" y18n "^4.0.0"
yargs-parser "^16.1.0" yargs-parser "^16.1.0"
yup@^0.28.3: yup@^0.28.1:
version "0.28.3" version "0.28.3"
resolved "https://registry.yarnpkg.com/yup/-/yup-0.28.3.tgz#1ca607405a8adf24a5ac51f54bd09d527555f0ba" resolved "https://registry.yarnpkg.com/yup/-/yup-0.28.3.tgz#1ca607405a8adf24a5ac51f54bd09d527555f0ba"
integrity sha512-amVkCgFWe5bGjrrUiODkbIzrSwtB8JpZrQYSrfj2YsbRdrV+tn9LquWdZDlfOx2HXyfEA8FGnlwidE/bFDxO7Q== integrity sha512-amVkCgFWe5bGjrrUiODkbIzrSwtB8JpZrQYSrfj2YsbRdrV+tn9LquWdZDlfOx2HXyfEA8FGnlwidE/bFDxO7Q==

View File

@ -3,8 +3,6 @@ import locales from '../../../webapp/locales'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
const languages = orderBy(locales, 'name') const languages = orderBy(locales, 'name')
const narratorAvatar =
"https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg";
When("I type in a comment with {int} characters", size => { When("I type in a comment with {int} characters", size => {
var c=""; var c="";
@ -32,9 +30,11 @@ Then("my comment should be successfully created", () => {
Then("I should see my comment", () => { Then("I should see my comment", () => {
cy.get("article.comment-card p") cy.get("article.comment-card p")
.should("contain", "Human Connection rocks") .should("contain", "Human Connection rocks")
.get(".user-teaser span.slug")
.should("contain", "@peter-pan") // specific enough
.get(".user-avatar img") .get(".user-avatar img")
.should("have.attr", "src") .should("have.attr", "src")
.and("contain", narratorAvatar) .and("contain", 'https://') // some url
.get(".user-teaser > .info > .text") .get(".user-teaser > .info > .text")
.should("contain", "today at"); .should("contain", "today at");
}); });

View File

@ -24,7 +24,6 @@ const narratorParams = {
id: 'id-of-peter-pan', id: 'id-of-peter-pan',
name: "Peter Pan", name: "Peter Pan",
slug: "peter-pan", slug: "peter-pan",
avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg",
...termsAndConditionsAgreedVersion, ...termsAndConditionsAgreedVersion,
}; };

View File

@ -7,7 +7,7 @@ Feature: Delete Teaser Image
Given I have a user account Given I have a user account
Given I am logged in Given I am logged in
Given we have the following posts in our database: Given we have the following posts in our database:
| authorId | id | title | content | | authorId | id | title | content |
| id-of-peter-pan | p1 | Post to be updated | successfully updated | | id-of-peter-pan | p1 | Post to be updated | successfully updated |
Scenario: Delete existing image Scenario: Delete existing image

View File

@ -1,6 +1,6 @@
{ {
"name": "human-connection", "name": "human-connection",
"version": "0.4.2", "version": "0.4.1",
"description": "Fullstack and API tests with cypress and cucumber for Human Connection", "description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh", "author": "Human Connection gGmbh",
"license": "MIT", "license": "MIT",

View File

@ -3,12 +3,6 @@
ROOT_DIR=$(dirname "$0")/../.. ROOT_DIR=$(dirname "$0")/../..
tmp=$(mktemp) tmp=$(mktemp)
exit_code=0 exit_code=0
errors=0
TEXT_RED="\e[31m"
TEXT_BLUE="\e[34m"
TEXT_RESET="\e[0m"
TEXT_BOLD="\e[1m"
for locale_file in $ROOT_DIR/webapp/locales/*.json for locale_file in $ROOT_DIR/webapp/locales/*.json
do do
@ -22,13 +16,9 @@ do
: # all good : # all good
else else
exit_code=$? exit_code=$?
echo -e "${TEXT_BOLD}${TEXT_RED}>>> $(basename -- $locale_file) is not sorted by keys <<<${TEXT_RESET}" echo "$(basename -- $locale_file) is not sorted by keys"
errors=1
fi fi
fi fi
done done
[ "$errors" = 1 ] && echo -e "${TEXT_BOLD}${TEXT_BLUE}Please run $0 --fix to sort your locale definitions!${TEXT_RESET}";
exit $exit_code exit $exit_code

View File

@ -2,20 +2,29 @@ import { shallowMount } from '@vue/test-utils'
import Badges from './Badges.vue' import Badges from './Badges.vue'
describe('Badges.vue', () => { describe('Badges.vue', () => {
let wrapper let propsData
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(Badges, {}) propsData = {}
}) })
it('renders', () => { describe('shallowMount', () => {
expect(wrapper.is('div')).toBe(true) const Wrapper = () => {
}) return shallowMount(Badges, { propsData })
}
it('has class "hc-badges"', () => { it('has class "hc-badges"', () => {
expect(wrapper.contains('.hc-badges')).toBe(true) expect(Wrapper().contains('.hc-badges')).toBe(true)
}) })
// TODO: add similar software tests for other components describe('given a badge', () => {
// TODO: add more test cases in this file beforeEach(() => {
propsData.badges = [{ id: '1', icon: '/path/to/some/icon' }]
})
it('proxies badge icon, which is just a URL without metadata', () => {
expect(Wrapper().contains('img[src="/api/path/to/some/icon"]')).toBe(true)
})
})
})
}) })

View File

@ -17,8 +17,10 @@ const comment = {
disabled: false, disabled: false,
author: { author: {
id: '1', id: '1',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
slug: 'jenny-rostock', slug: 'jenny-rostock',
name: 'Rainer Unsinn', name: 'Rainer Unsinn',
disabled: false, disabled: false,

View File

@ -70,7 +70,7 @@ describe('ContributionForm.vue', () => {
}, },
url: 'someUrlToImage', url: 'someUrlToImage',
} }
const image = '/uploads/1562010976466-avataaars' const image = { sensitive: false, url: '/uploads/1562010976466-avataaars', aspectRatio: 1 }
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
@ -199,10 +199,7 @@ describe('ContributionForm.vue', () => {
language: 'en', language: 'en',
id: null, id: null,
categoryIds: ['cat12'], categoryIds: ['cat12'],
imageUpload: null,
imageAspectRatio: null,
image: null, image: null,
imageBlurred: false,
}, },
} }
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
@ -233,8 +230,16 @@ describe('ContributionForm.vue', () => {
}) })
it('supports adding a teaser image', async () => { it('supports adding a teaser image', async () => {
const spy = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {}) expectedParams.variables.image = {
expectedParams.variables.imageUpload = imageUpload aspectRatio: null,
sensitive: false,
upload: imageUpload,
}
const spy = jest
.spyOn(FileReader.prototype, 'readAsDataURL')
.mockImplementation(function() {
this.onload({ target: { result: 'someUrlToImage' } })
})
wrapper.find(ImageUploader).vm.$emit('addHeroImage', imageUpload) wrapper.find(ImageUploader).vm.$emit('addHeroImage', imageUpload)
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
@ -317,7 +322,6 @@ describe('ContributionForm.vue', () => {
name: 'Democracy & Politics', name: 'Democracy & Politics',
}, },
], ],
imageAspectRatio: 1,
}, },
} }
wrapper = Wrapper() wrapper = Wrapper()
@ -354,10 +358,9 @@ describe('ContributionForm.vue', () => {
language: propsData.contribution.language, language: propsData.contribution.language,
id: propsData.contribution.id, id: propsData.contribution.id,
categoryIds: ['cat12'], categoryIds: ['cat12'],
image, image: {
imageUpload: null, sensitive: false,
imageAspectRatio: 1, },
imageBlurred: false,
}, },
} }
}) })
@ -383,8 +386,7 @@ describe('ContributionForm.vue', () => {
it('supports deleting a teaser image', async () => { it('supports deleting a teaser image', async () => {
expectedParams.variables.image = null expectedParams.variables.image = null
expectedParams.variables.imageAspectRatio = null propsData.contribution.image = { url: '/uploads/someimage.png' }
propsData.contribution.image = '/uploads/someimage.png'
wrapper = Wrapper() wrapper = Wrapper()
wrapper.find('[data-test="delete-button"]').trigger('click') wrapper.find('[data-test="delete-button"]').trigger('click')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')

View File

@ -106,27 +106,20 @@ export default {
}, },
}, },
data() { data() {
const { const { title, content, image, language, categories } = this.contribution
title,
content,
image,
imageAspectRatio,
imageBlurred,
language,
categories,
} = this.contribution
const languageOptions = orderBy(locales, 'name').map(locale => { const languageOptions = orderBy(locales, 'name').map(locale => {
return { label: locale.name, value: locale.code } return { label: locale.name, value: locale.code }
}) })
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {}
return { return {
formData: { formData: {
title: title || '', title: title || '',
content: content || '', content: content || '',
image: image || null, image: image || null,
imageAspectRatio: imageAspectRatio || null, imageAspectRatio,
imageBlurred: imageBlurred || false, imageBlurred,
language: languageOptions.find(option => option.value === language) || null, language: languageOptions.find(option => option.value === language) || null,
categoryIds: categories ? categories.map(category => category.id) : [], categoryIds: categories ? categories.map(category => category.id) : [],
}, },
@ -163,16 +156,28 @@ export default {
}, },
methods: { methods: {
submit() { submit() {
let image = null
const { title, content, categoryIds } = this.formData
if (this.formData.image) {
image = {
sensitive: this.formData.imageBlurred,
}
if (this.imageUpload) {
image.upload = this.imageUpload
image.aspectRatio = this.formData.imageAspectRatio
}
}
this.loading = true this.loading = true
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost, mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: { variables: {
...this.formData, title,
content,
categoryIds,
id: this.contribution.id || null, id: this.contribution.id || null,
language: this.formData.language.value, language: this.formData.language.value,
image: this.imageUpload ? null : this.formData.image, image,
imageUpload: this.imageUpload,
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
@ -198,10 +203,13 @@ export default {
if (file) { if (file) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
this.formData.image = target.result this.formData.image = {
...this.formData.image,
url: target.result,
}
} }
this.imageUpload = file
reader.readAsDataURL(file) reader.readAsDataURL(file)
this.imageUpload = file
} }
}, },
addImageAspectRatio(aspectRatio) { addImageAspectRatio(aspectRatio) {

View File

@ -16,8 +16,10 @@ export const post = {
image: null, image: null,
author: { author: {
id: 'u3', id: 'u3',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
slug: 'jenny-rostock', slug: 'jenny-rostock',
name: 'Rainer Unsinn', name: 'Rainer Unsinn',
disabled: false, disabled: false,

View File

@ -7,7 +7,7 @@
:lang="post.language" :lang="post.language"
:class="{ :class="{
'disabled-content': post.disabled, 'disabled-content': post.disabled,
'--blur-image': post.imageBlurred, '--blur-image': post.image && post.image.sensitive,
}" }"
:highlight="isPinned" :highlight="isPinned"
> >
@ -93,8 +93,10 @@ export default {
}, },
}, },
mounted() { mounted() {
const { image } = this.post
if (!image) return
const width = this.$el.offsetWidth const width = this.$el.offsetWidth
const height = Math.min(width / this.post.imageAspectRatio, 2000) const height = Math.min(width / image.aspectRatio, 2000)
const imageElement = this.$el.querySelector('.hero-image') const imageElement = this.$el.querySelector('.hero-image')
if (imageElement) { if (imageElement) {
imageElement.style.height = `${height}px` imageElement.style.height = `${height}px`

View File

@ -2,7 +2,7 @@
<div> <div>
<vue-dropzone <vue-dropzone
id="customdropzone" id="customdropzone"
:key="user.avatar" :key="avatarUrl"
ref="el" ref="el"
:use-custom-slot="true" :use-custom-slot="true"
:options="dropzoneOptions" :options="dropzoneOptions"
@ -41,6 +41,12 @@ export default {
hover: false, hover: false,
} }
}, },
computed: {
avatarUrl() {
const { avatar } = this.user
return avatar && avatar.url
},
},
watch: { watch: {
error() { error() {
const that = this const that = this
@ -64,7 +70,9 @@ export default {
.mutate({ .mutate({
mutation: updateUserMutation(), mutation: updateUserMutation(),
variables: { variables: {
avatarUpload, avatar: {
upload: avatarUpload,
},
id: this.user.id, id: this.user.id,
}, },
}) })

View File

@ -12,7 +12,7 @@ describe('Upload', () => {
mutate: jest mutate: jest
.fn() .fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce({
data: { UpdateUser: { id: 'upload1', avatar: '/upload/avatar.jpg' } }, data: { UpdateUser: { id: 'upload1', avatar: { url: '/upload/avatar.jpg' } } },
}) })
.mockRejectedValue({ .mockRejectedValue({
message: 'File upload unsuccessful! Whatcha gonna do?', message: 'File upload unsuccessful! Whatcha gonna do?',
@ -27,7 +27,7 @@ describe('Upload', () => {
const propsData = { const propsData = {
user: { user: {
avatar: '/api/generic.jpg', avatar: { url: '/api/generic.jpg' },
}, },
} }

View File

@ -9,7 +9,9 @@ export const user = {
id: 'u6', id: 'u6',
slug: 'louie', slug: 'louie',
name: 'Louie', name: 'Louie',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/designervzm/128.jpg', avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/designervzm/128.jpg',
},
about: about:
'Illum in et velit soluta voluptatem architecto consequuntur enim placeat. Eum excepturi est ratione rerum in voluptatum corporis. Illum consequatur minus. Modi incidunt velit.', 'Illum in et velit soluta voluptatem architecto consequuntur enim placeat. Eum excepturi est ratione rerum in voluptatum corporis. Illum consequatur minus. Modi incidunt velit.',
disabled: false, disabled: false,
@ -28,7 +30,9 @@ export const user = {
id: 'u3', id: 'u3',
slug: 'jenny-rostock', slug: 'jenny-rostock',
name: 'Jenny Rostock', name: 'Jenny Rostock',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/bowbrick/128.jpg', avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/bowbrick/128.jpg',
},
disabled: false, disabled: false,
deleted: false, deleted: false,
followedByCount: 2, followedByCount: 2,
@ -83,7 +87,7 @@ storiesOf('UserTeaser', module)
<template #dateTime> <template #dateTime>
- HEY! I'm edited - HEY! I'm edited
</template> </template>
</user> </user-teaser>
`, `,
})) }))
.add('anonymous', () => ({ .add('anonymous', () => ({

View File

@ -66,7 +66,9 @@ describe('UserAvatar.vue', () => {
propsData = { propsData = {
user: { user: {
name: 'Not Anonymous', name: 'Not Anonymous',
avatar: '/avatar.jpg', avatar: {
url: '/avatar.jpg',
},
}, },
} }
wrapper = Wrapper() wrapper = Wrapper()
@ -82,7 +84,9 @@ describe('UserAvatar.vue', () => {
propsData = { propsData = {
user: { user: {
name: 'Not Anonymous', name: 'Not Anonymous',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg', avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
},
}, },
} }
wrapper = Wrapper() wrapper = Wrapper()

View File

@ -69,32 +69,40 @@ export const searchResults = [
{ {
id: 'u1', id: 'u1',
__typename: 'User', __typename: 'User',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
name: 'Peter Lustig', name: 'Peter Lustig',
slug: 'peter-lustig', slug: 'peter-lustig',
}, },
{ {
id: 'cdbca762-0632-4564-b646-415a0c42d8b8', id: 'cdbca762-0632-4564-b646-415a0c42d8b8',
__typename: 'User', __typename: 'User',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
name: 'Herbert Schultz', name: 'Herbert Schultz',
slug: 'herbert-schultz', slug: 'herbert-schultz',
}, },
{ {
id: 'u2', id: 'u2',
__typename: 'User', __typename: 'User',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
name: 'Bob der Baumeister', name: 'Bob der Baumeister',
slug: 'bob-der-baumeister', slug: 'bob-der-baumeister',
}, },
{ {
id: '7b654f72-f4da-4315-8bed-39de0859754b', id: '7b654f72-f4da-4315-8bed-39de0859754b',
__typename: 'User', __typename: 'User',
avatar: avatar: {
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', url:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
},
name: 'Tonya Mohr', name: 'Tonya Mohr',
slug: 'tonya-mohr', slug: 'tonya-mohr',
}, },

View File

@ -17,7 +17,9 @@ export default i18n => {
id id
slug slug
name name
avatar avatar {
url
}
disabled disabled
deleted deleted
shoutedCount shoutedCount
@ -47,7 +49,9 @@ export default i18n => {
id id
slug slug
name name
avatar avatar {
url
}
disabled disabled
deleted deleted
} }
@ -67,7 +71,9 @@ export default i18n => {
id id
slug slug
name name
avatar avatar {
url
}
disabled disabled
deleted deleted
shoutedCount shoutedCount

View File

@ -12,7 +12,9 @@ export default app => {
id id
slug slug
name name
avatar avatar {
url
}
disabled disabled
deleted deleted
shoutedCount shoutedCount

View File

@ -5,7 +5,9 @@ export const userFragment = gql`
id id
slug slug
name name
avatar avatar {
url
}
disabled disabled
deleted deleted
} }
@ -44,14 +46,16 @@ export const postFragment = gql`
disabled disabled
deleted deleted
slug slug
image
language language
imageBlurred image {
url
sensitive
aspectRatio
}
author { author {
...user ...user
} }
pinnedAt pinnedAt
imageAspectRatio
pinned pinned
} }
` `

View File

@ -8,25 +8,23 @@ export default () => {
$content: String! $content: String!
$language: String $language: String
$categoryIds: [ID] $categoryIds: [ID]
$imageUpload: Upload $image: ImageInput
$imageBlurred: Boolean
$imageAspectRatio: Float
) { ) {
CreatePost( CreatePost(
title: $title title: $title
content: $content content: $content
language: $language language: $language
categoryIds: $categoryIds categoryIds: $categoryIds
imageUpload: $imageUpload image: $image
imageBlurred: $imageBlurred
imageAspectRatio: $imageAspectRatio
) { ) {
title title
slug slug
content content
contentExcerpt contentExcerpt
language language
imageBlurred image {
sensitive
}
} }
} }
`, `,
@ -36,22 +34,16 @@ export default () => {
$title: String! $title: String!
$content: String! $content: String!
$language: String $language: String
$imageUpload: Upload $image: ImageInput
$categoryIds: [ID] $categoryIds: [ID]
$image: String
$imageBlurred: Boolean
$imageAspectRatio: Float
) { ) {
UpdatePost( UpdatePost(
id: $id id: $id
title: $title title: $title
content: $content content: $content
language: $language language: $language
imageUpload: $imageUpload
categoryIds: $categoryIds
image: $image image: $image
imageBlurred: $imageBlurred categoryIds: $categoryIds
imageAspectRatio: $imageAspectRatio
) { ) {
id id
title title
@ -59,13 +51,15 @@ export default () => {
content content
contentExcerpt contentExcerpt
language language
imageBlurred image {
sensitive
aspectRatio
}
pinnedBy { pinnedBy {
id id
name name
role role
} }
imageAspectRatio
} }
} }
`, `,

View File

@ -53,7 +53,9 @@ export const minimisedUserQuery = () => {
id id
slug slug
name name
avatar avatar {
url
}
} }
} }
` `
@ -223,7 +225,7 @@ export const updateUserMutation = () => {
$allowEmbedIframes: Boolean $allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean $showShoutsPublicly: Boolean
$termsAndConditionsAgreedVersion: String $termsAndConditionsAgreedVersion: String
$avatarUpload: Upload $avatar: ImageInput
) { ) {
UpdateUser( UpdateUser(
id: $id id: $id
@ -234,7 +236,7 @@ export const updateUserMutation = () => {
allowEmbedIframes: $allowEmbedIframes allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly showShoutsPublicly: $showShoutsPublicly
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatarUpload: $avatarUpload avatar: $avatar
) { ) {
id id
slug slug
@ -245,7 +247,9 @@ export const updateUserMutation = () => {
showShoutsPublicly showShoutsPublicly
locale locale
termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion
avatar avatar {
url
}
} }
} }
` `

View File

@ -7,7 +7,9 @@ export const blockedUsers = () => {
id id
name name
slug slug
avatar avatar {
url
}
about about
disabled disabled
deleted deleted

View File

@ -7,7 +7,9 @@ export const mutedUsers = () => {
id id
name name
slug slug
avatar avatar {
url
}
about about
disabled disabled
deleted deleted

View File

@ -1,6 +1,6 @@
{ {
"name": "human-connection-webapp", "name": "human-connection-webapp",
"version": "0.4.2", "version": "0.4.1",
"description": "Human Connection Frontend", "description": "Human Connection Frontend",
"authors": [ "authors": [
"Grzegorz Leoniec (appinteractive)", "Grzegorz Leoniec (appinteractive)",
@ -63,7 +63,7 @@
"@nuxtjs/axios": "~5.9.5", "@nuxtjs/axios": "~5.9.5",
"@nuxtjs/dotenv": "~1.4.1", "@nuxtjs/dotenv": "~1.4.1",
"@nuxtjs/pwa": "^3.0.0-beta.20", "@nuxtjs/pwa": "^3.0.0-beta.20",
"@nuxtjs/sentry": "^3.3.1", "@nuxtjs/sentry": "^3.2.4",
"@nuxtjs/style-resources": "~1.0.0", "@nuxtjs/style-resources": "~1.0.0",
"accounting": "~0.4.1", "accounting": "~0.4.1",
"apollo-cache-inmemory": "~1.6.5", "apollo-cache-inmemory": "~1.6.5",
@ -99,15 +99,15 @@
"devDependencies": { "devDependencies": {
"@babel/core": "~7.8.7", "@babel/core": "~7.8.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "~7.8.7", "@babel/preset-env": "~7.8.6",
"@storybook/addon-a11y": "^5.3.14", "@storybook/addon-a11y": "^5.3.14",
"@storybook/addon-actions": "^5.3.17", "@storybook/addon-actions": "^5.3.17",
"@storybook/addon-notes": "^5.3.17", "@storybook/addon-notes": "^5.3.17",
"@storybook/vue": "~5.3.14", "@storybook/vue": "~5.3.14",
"@vue/cli-shared-utils": "~4.2.3", "@vue/cli-shared-utils": "~4.2.3",
"@vue/eslint-config-prettier": "~6.0.0", "@vue/eslint-config-prettier": "~6.0.0",
"@vue/server-test-utils": "~1.0.0-beta.32", "@vue/server-test-utils": "~1.0.0-beta.31",
"@vue/test-utils": "~1.0.0-beta.32", "@vue/test-utils": "~1.0.0-beta.31",
"async-validator": "^3.2.4", "async-validator": "^3.2.4",
"babel-core": "~7.0.0-bridge.0", "babel-core": "~7.0.0-bridge.0",
"babel-eslint": "~10.1.0", "babel-eslint": "~10.1.0",
@ -122,7 +122,7 @@
"eslint-config-standard": "~14.1.0", "eslint-config-standard": "~14.1.0",
"eslint-loader": "~3.0.3", "eslint-loader": "~3.0.3",
"eslint-plugin-import": "~2.20.1", "eslint-plugin-import": "~2.20.1",
"eslint-plugin-jest": "~23.8.2", "eslint-plugin-jest": "~23.8.1",
"eslint-plugin-node": "~11.0.0", "eslint-plugin-node": "~11.0.0",
"eslint-plugin-prettier": "~3.1.2", "eslint-plugin-prettier": "~3.1.2",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
@ -130,7 +130,7 @@
"eslint-plugin-vue": "~6.2.2", "eslint-plugin-vue": "~6.2.2",
"faker": "^4.1.0", "faker": "^4.1.0",
"flush-promises": "^1.0.2", "flush-promises": "^1.0.2",
"fuse.js": "^3.6.1", "fuse.js": "^3.4.6",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "~25.1.0", "jest": "~25.1.0",
"mutation-observer": "^1.0.3", "mutation-observer": "^1.0.3",

View File

@ -24,7 +24,7 @@
<masonry-grid-item <masonry-grid-item
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
:imageAspectRatio="post.imageAspectRatio" :imageAspectRatio="post.image && post.image.aspectRatio"
> >
<post-teaser <post-teaser
:post="post" :post="post"

View File

@ -11,7 +11,7 @@
> >
<template #heroImage v-if="post.image"> <template #heroImage v-if="post.image">
<img :src="post.image | proxyApiUrl" class="image" /> <img :src="post.image | proxyApiUrl" class="image" />
<aside v-show="post.imageBlurred" class="blur-toggle"> <aside v-show="post.image && post.image.sensitive" class="blur-toggle">
<img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" /> <img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" />
<base-button <base-button
:icon="blurred ? 'eye' : 'eye-slash'" :icon="blurred ? 'eye' : 'eye-slash'"
@ -235,8 +235,9 @@ export default {
update({ Post }) { update({ Post }) {
this.post = Post[0] || {} this.post = Post[0] || {}
this.title = this.post.title this.title = this.post.title
this.blurred = this.post.imageBlurred const { image } = this.post
this.postAuthor = this.post.author this.postAuthor = this.post.author
this.blurred = image && image.sensitive
}, },
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}, },

View File

@ -237,7 +237,7 @@
<masonry-grid-item <masonry-grid-item
v-for="post in posts" v-for="post in posts"
:key="post.id" :key="post.id"
:imageAspectRatio="post.imageAspectRatio" :imageAspectRatio="post.image && post.image.aspectRatio"
> >
<post-teaser <post-teaser
:post="post" :post="post"

View File

@ -48,7 +48,7 @@ describe('blocked-users.vue', () => {
describe('given a list of blocked users', () => { describe('given a list of blocked users', () => {
beforeEach(() => { beforeEach(() => {
const blockedUsers = [{ id: 'u1', name: 'John Doe', slug: 'john-doe', avatar: '' }] const blockedUsers = [{ id: 'u1', name: 'John Doe', slug: 'john-doe' }]
wrapper.setData({ blockedUsers }) wrapper.setData({ blockedUsers })
}) })

View File

@ -48,7 +48,7 @@ describe('muted-users.vue', () => {
describe('given a list of muted users', () => { describe('given a list of muted users', () => {
beforeEach(() => { beforeEach(() => {
const mutedUsers = [{ id: 'u1', name: 'John Doe', slug: 'john-doe', avatar: '' }] const mutedUsers = [{ id: 'u1', name: 'John Doe', slug: 'john-doe' }]
wrapper.setData({ mutedUsers }) wrapper.setData({ mutedUsers })
}) })

View File

@ -81,7 +81,8 @@ export default ({ app = {} }) => {
return contentExcerpt return contentExcerpt
}, },
proxyApiUrl: url => { proxyApiUrl: input => {
const url = input && (input.url || input)
if (!url) return url if (!url) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url return url.startsWith('/') ? url.replace('/', '/api/') : url
}, },

View File

@ -11,7 +11,9 @@ const currentUser = {
name: 'Jenny Rostock', name: 'Jenny Rostock',
slug: 'jenny-rostock', slug: 'jenny-rostock',
email: 'user@example.org', email: 'user@example.org',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/mutu_krish/128.jpg', avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/mutu_krish/128.jpg',
},
role: 'user', role: 'user',
locale: 'de', locale: 'de',
} }
@ -125,7 +127,9 @@ describe('actions', () => {
name: 'Jenny Rostock', name: 'Jenny Rostock',
slug: 'jenny-rostock', slug: 'jenny-rostock',
email: 'user@example.org', email: 'user@example.org',
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/mutu_krish/128.jpg', avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/mutu_krish/128.jpg',
},
role: 'user', role: 'user',
locale: 'de', locale: 'de',
}, },

View File

@ -820,7 +820,7 @@
"@babel/helper-create-regexp-features-plugin" "^7.8.3" "@babel/helper-create-regexp-features-plugin" "^7.8.3"
"@babel/helper-plugin-utils" "^7.8.3" "@babel/helper-plugin-utils" "^7.8.3"
"@babel/preset-env@^7.4.5", "@babel/preset-env@^7.7.6", "@babel/preset-env@~7.8.7": "@babel/preset-env@^7.4.5", "@babel/preset-env@^7.7.6", "@babel/preset-env@~7.8.6":
version "7.8.7" version "7.8.7"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.8.7.tgz#1fc7d89c7f75d2d70c2b6768de6c2e049b3cb9db" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.8.7.tgz#1fc7d89c7f75d2d70c2b6768de6c2e049b3cb9db"
integrity sha512-BYftCVOdAYJk5ASsznKAUl53EMhfBbr8CJ1X+AJLfGPscQkwJFiaV/Wn9DPH/7fzm2v6iRYJKYHSqyynTGw0nw== integrity sha512-BYftCVOdAYJk5ASsznKAUl53EMhfBbr8CJ1X+AJLfGPscQkwJFiaV/Wn9DPH/7fzm2v6iRYJKYHSqyynTGw0nw==
@ -1738,7 +1738,7 @@
jimp-compact "^0.8.0" jimp-compact "^0.8.0"
workbox-cdn "^4.3.1" workbox-cdn "^4.3.1"
"@nuxtjs/sentry@^3.3.1": "@nuxtjs/sentry@^3.2.4":
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/@nuxtjs/sentry/-/sentry-3.3.1.tgz#b3f21851103d5194b9da314a5eec7b154ed50cbe" resolved "https://registry.yarnpkg.com/@nuxtjs/sentry/-/sentry-3.3.1.tgz#b3f21851103d5194b9da314a5eec7b154ed50cbe"
integrity sha512-o7aGlI7OvaRDT0SsV73Ye1r5sfylKwRGG8EyRClOrhi6dpMIuqcmzgILHIIcHpFkrm61D3sORc7d7rhlHtY6DA== integrity sha512-o7aGlI7OvaRDT0SsV73Ye1r5sfylKwRGG8EyRClOrhi6dpMIuqcmzgILHIIcHpFkrm61D3sORc7d7rhlHtY6DA==
@ -3249,7 +3249,7 @@
dependencies: dependencies:
eslint-config-prettier "^6.0.0" eslint-config-prettier "^6.0.0"
"@vue/server-test-utils@~1.0.0-beta.32": "@vue/server-test-utils@~1.0.0-beta.31":
version "1.0.0-beta.32" version "1.0.0-beta.32"
resolved "https://registry.yarnpkg.com/@vue/server-test-utils/-/server-test-utils-1.0.0-beta.32.tgz#698424d5d76fea10ee3d2ec45f2416e31681f01e" resolved "https://registry.yarnpkg.com/@vue/server-test-utils/-/server-test-utils-1.0.0-beta.32.tgz#698424d5d76fea10ee3d2ec45f2416e31681f01e"
integrity sha512-1dxJyrO805pr4tyNckAwRojxby3g37IHpmBURInz4yccsiwHsOhSi1tR23HovOocqmu1/NttiI5rHtv9MtL9Ig== integrity sha512-1dxJyrO805pr4tyNckAwRojxby3g37IHpmBURInz4yccsiwHsOhSi1tR23HovOocqmu1/NttiI5rHtv9MtL9Ig==
@ -3257,7 +3257,7 @@
"@types/cheerio" "^0.22.10" "@types/cheerio" "^0.22.10"
cheerio "^1.0.0-rc.2" cheerio "^1.0.0-rc.2"
"@vue/test-utils@~1.0.0-beta.32": "@vue/test-utils@~1.0.0-beta.31":
version "1.0.0-beta.32" version "1.0.0-beta.32"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.32.tgz#38c3947886236201a3f24b583c73598eb95ccc69" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.32.tgz#38c3947886236201a3f24b583c73598eb95ccc69"
integrity sha512-ywhe7PATMAk/ZGdsrcuQIliQusOyfe0OOHjKKCCERqgHh1g/kqPtmSMT5Jx4sErx53SYbNucr8QOK6/u5ianAw== integrity sha512-ywhe7PATMAk/ZGdsrcuQIliQusOyfe0OOHjKKCCERqgHh1g/kqPtmSMT5Jx4sErx53SYbNucr8QOK6/u5ianAw==
@ -7453,7 +7453,7 @@ eslint-plugin-import@~2.20.1:
read-pkg-up "^2.0.0" read-pkg-up "^2.0.0"
resolve "^1.12.0" resolve "^1.12.0"
eslint-plugin-jest@~23.8.2: eslint-plugin-jest@~23.8.1:
version "23.8.2" version "23.8.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.8.2.tgz#6f28b41c67ef635f803ebd9e168f6b73858eb8d4" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.8.2.tgz#6f28b41c67ef635f803ebd9e168f6b73858eb8d4"
integrity sha512-xwbnvOsotSV27MtAe7s8uGWOori0nUsrXh2f1EnpmXua8sDfY6VZhHAhHg2sqK7HBNycRQExF074XSZ7DvfoFg== integrity sha512-xwbnvOsotSV27MtAe7s8uGWOori0nUsrXh2f1EnpmXua8sDfY6VZhHAhHg2sqK7HBNycRQExF074XSZ7DvfoFg==
@ -8363,7 +8363,7 @@ functions-have-names@^1.1.1:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.1.1.tgz#79d35927f07b8e7103d819fed475b64ccf7225ea" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.1.1.tgz#79d35927f07b8e7103d819fed475b64ccf7225ea"
integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw== integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw==
fuse.js@^3.4.6, fuse.js@^3.6.1: fuse.js@^3.4.6:
version "3.6.1" version "3.6.1"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c"
integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==