mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-12 23:35:52 +00:00
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:
parent
23afe9be74
commit
512ef672bf
27
CHANGELOG.md
27
CHANGELOG.md
@ -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).
|
||||
|
||||
#### [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)
|
||||
|
||||
> 9 March 2020
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "human-connection-backend",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.1",
|
||||
"description": "GraphQL Backend for Human Connection",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@ -61,7 +61,7 @@
|
||||
"graphql-middleware": "~4.0.2",
|
||||
"graphql-middleware-sentry": "^3.2.1",
|
||||
"graphql-redis-subscriptions": "^2.2.1",
|
||||
"graphql-shield": "~7.2.0",
|
||||
"graphql-shield": "~7.0.14",
|
||||
"graphql-tag": "~2.10.3",
|
||||
"helmet": "~3.21.3",
|
||||
"ioredis": "^4.16.0",
|
||||
@ -92,11 +92,11 @@
|
||||
"neo4j-graphql-js": "^2.11.5",
|
||||
"neode": "^0.3.7",
|
||||
"node-fetch": "~2.6.0",
|
||||
"nodemailer": "^6.4.5",
|
||||
"nodemailer": "^6.4.4",
|
||||
"nodemailer-html-to-text": "^3.1.0",
|
||||
"npm-run-all": "~4.1.5",
|
||||
"request": "~2.88.2",
|
||||
"sanitize-html": "~1.22.1",
|
||||
"sanitize-html": "~1.22.0",
|
||||
"slug": "~2.1.1",
|
||||
"subscriptions-transport-ws": "^0.9.16",
|
||||
"trunc-html": "~1.1.2",
|
||||
|
||||
@ -7,7 +7,7 @@ import request from 'request'
|
||||
import NitroDataSource from './NitroDataSource'
|
||||
import router from './routes'
|
||||
import Collections from './Collections'
|
||||
import uuid from 'uuid/v4'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import CONFIG from '../config'
|
||||
const debug = require('debug')('ea')
|
||||
|
||||
|
||||
@ -4,9 +4,16 @@ import slugify from 'slug'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
import { Factory } from 'rosie'
|
||||
import { getDriver, getNeode } from './neo4j'
|
||||
import CONFIG from '../config/index.js'
|
||||
|
||||
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 = {}) => {
|
||||
const { driver = getDriver() } = options
|
||||
const session = driver.session()
|
||||
@ -39,14 +46,23 @@ Factory.define('badge')
|
||||
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')
|
||||
.attrs({
|
||||
id: uuid,
|
||||
name: faker.name.findName,
|
||||
password: '1234',
|
||||
role: 'user',
|
||||
avatar: faker.internet.avatar,
|
||||
about: faker.lorem.paragraph,
|
||||
termsAndConditionsAgreedVersion: '0.0.1',
|
||||
termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z',
|
||||
@ -60,19 +76,29 @@ Factory.define('userWithoutEmailAddress')
|
||||
.attr('encryptedPassword', ['password'], password => {
|
||||
return hashSync(password, 10)
|
||||
})
|
||||
|
||||
Factory.define('userWithoutEmailAddress')
|
||||
.extend('basicUser')
|
||||
.after(async (buildObject, options) => {
|
||||
return neode.create('User', buildObject)
|
||||
})
|
||||
|
||||
Factory.define('user')
|
||||
.extend('userWithoutEmailAddress')
|
||||
.extend('basicUser')
|
||||
.option('email', faker.internet.exampleEmail)
|
||||
.option('avatar', () =>
|
||||
Factory.build('image', {
|
||||
url: faker.internet.avatar(),
|
||||
}),
|
||||
)
|
||||
.after(async (buildObject, options) => {
|
||||
const [user, email] = await Promise.all([
|
||||
buildObject,
|
||||
const [user, email, avatar] = await Promise.all([
|
||||
neode.create('User', buildObject),
|
||||
neode.create('EmailAddress', { email: options.email }),
|
||||
options.avatar,
|
||||
])
|
||||
await Promise.all([user.relateTo(email, 'primaryEmail'), email.relateTo(user, 'belongsTo')])
|
||||
if (avatar) await user.relateTo(avatar, 'avatar')
|
||||
return user
|
||||
})
|
||||
|
||||
@ -93,11 +119,11 @@ Factory.define('post')
|
||||
return Factory.build('user')
|
||||
})
|
||||
.option('pinnedBy', null)
|
||||
.option('image', () => Factory.build('image'))
|
||||
.attrs({
|
||||
id: uuid,
|
||||
title: faker.lorem.sentence,
|
||||
content: faker.lorem.paragraphs,
|
||||
image: faker.image.unsplash.imageUrl,
|
||||
visibility: 'public',
|
||||
deleted: false,
|
||||
imageBlurred: false,
|
||||
@ -117,9 +143,10 @@ Factory.define('post')
|
||||
return language || 'en'
|
||||
})
|
||||
.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),
|
||||
options.author,
|
||||
options.image,
|
||||
options.categories,
|
||||
options.tags,
|
||||
])
|
||||
@ -128,6 +155,7 @@ Factory.define('post')
|
||||
Promise.all(categories.map(c => c.relateTo(post, 'post'))),
|
||||
Promise.all(tags.map(t => t.relateTo(post, 'post'))),
|
||||
])
|
||||
if (image) await post.relateTo(image, 'image')
|
||||
if (buildObject.pinned) {
|
||||
const pinnedBy = await (options.pinnedBy || Factory.build('user', { role: 'admin' }))
|
||||
await pinnedBy.relateTo(post, 'pinned')
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -389,13 +389,15 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
{
|
||||
id: 'p0',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.food(300, 169),
|
||||
imageBlurred: true,
|
||||
imageAspectRatio: 300 / 169,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat16'],
|
||||
author: peterLustig,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.food(300, 169),
|
||||
sensitive: true,
|
||||
aspectRatio: 300 / 169,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -403,12 +405,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
{
|
||||
id: 'p1',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.technology(300, 1500),
|
||||
imageAspectRatio: 300 / 1500,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
author: bobDerBaumeister,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.technology(300, 1500),
|
||||
aspectRatio: 300 / 1500,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -449,12 +453,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
{
|
||||
id: 'p6',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.buildings(300, 857),
|
||||
imageAspectRatio: 300 / 857,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat6'],
|
||||
author: peterLustig,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.buildings(300, 857),
|
||||
aspectRatio: 300 / 857,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -472,11 +478,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
'post',
|
||||
{
|
||||
id: 'p10',
|
||||
imageBlurred: true,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat10'],
|
||||
author: dewey,
|
||||
image: Factory.build('image', {
|
||||
sensitive: true,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -484,12 +492,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
{
|
||||
id: 'p11',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.people(300, 901),
|
||||
imageAspectRatio: 300 / 901,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat11'],
|
||||
author: louie,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.people(300, 901),
|
||||
aspectRatio: 300 / 901,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -508,12 +518,14 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
{
|
||||
id: 'p14',
|
||||
language: sample(languages),
|
||||
image: faker.image.unsplash.objects(300, 200),
|
||||
imageAspectRatio: 300 / 450,
|
||||
},
|
||||
{
|
||||
categoryIds: ['cat14'],
|
||||
author: jennyRostock,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.objects(300, 200),
|
||||
aspectRatio: 300 / 450,
|
||||
}),
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
@ -539,22 +551,8 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
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. ;-)'
|
||||
const createPostMutation = gql`
|
||||
mutation(
|
||||
$id: ID
|
||||
$title: String!
|
||||
$content: String!
|
||||
$categoryIds: [ID]
|
||||
$imageBlurred: Boolean
|
||||
$imageAspectRatio: Float
|
||||
) {
|
||||
CreatePost(
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
categoryIds: $categoryIds
|
||||
imageBlurred: $imageBlurred
|
||||
imageAspectRatio: $imageAspectRatio
|
||||
) {
|
||||
mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@ -568,7 +566,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
title: `Nature Philosophy Yoga`,
|
||||
content: hashtag1,
|
||||
categoryIds: ['cat2'],
|
||||
imageAspectRatio: 300 / 200,
|
||||
},
|
||||
}),
|
||||
mutate({
|
||||
@ -578,7 +575,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
title: 'This is post #7',
|
||||
content: `${mention1} ${faker.lorem.paragraph()}`,
|
||||
categoryIds: ['cat7'],
|
||||
imageAspectRatio: 300 / 180,
|
||||
},
|
||||
}),
|
||||
mutate({
|
||||
@ -589,7 +585,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
title: `Quantum Flow Theory explains Quantum Gravity`,
|
||||
content: hashtagAndMention1,
|
||||
categoryIds: ['cat8'],
|
||||
imageAspectRatio: 300 / 900,
|
||||
},
|
||||
}),
|
||||
mutate({
|
||||
@ -599,7 +594,6 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
title: 'This is post #12',
|
||||
content: `${mention2} ${faker.lorem.paragraph()}`,
|
||||
categoryIds: ['cat12'],
|
||||
imageAspectRatio: 300 / 200,
|
||||
},
|
||||
}),
|
||||
])
|
||||
@ -759,6 +753,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
const trollingComment = comments[0]
|
||||
|
||||
await Promise.all([
|
||||
@ -939,12 +934,13 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
[...Array(30).keys()].map(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.objects(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
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(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.buildings(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
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(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.food(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
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(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.technology(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
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(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.people(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
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(() =>
|
||||
Factory.build(
|
||||
'post',
|
||||
{
|
||||
image: faker.image.unsplash.nature(),
|
||||
},
|
||||
{},
|
||||
{
|
||||
categoryIds: ['cat1'],
|
||||
author: huey,
|
||||
image: Factory.build('image', {
|
||||
url: faker.image.unsplash.nature(),
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@ -15,10 +15,10 @@ export default async (driver, authorizationHeader) => {
|
||||
|
||||
const writeTxResultPromise = session.writeTransaction(async transaction => {
|
||||
const updateUserLastActiveTransactionResponse = await transaction.run(
|
||||
`
|
||||
`
|
||||
MATCH (user:User {id: $id, deleted: false, disabled: false })
|
||||
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
|
||||
`,
|
||||
{ id },
|
||||
|
||||
@ -69,23 +69,23 @@ describe('decode', () => {
|
||||
{
|
||||
role: 'user',
|
||||
name: 'Jenny Rostock',
|
||||
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
|
||||
id: 'u3',
|
||||
slug: 'jenny-rostock',
|
||||
},
|
||||
{
|
||||
image: Factory.build('image', {
|
||||
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
|
||||
}),
|
||||
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({
|
||||
role: 'user',
|
||||
name: 'Jenny Rostock',
|
||||
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg',
|
||||
id: 'u3',
|
||||
email: null,
|
||||
slug: 'jenny-rostock',
|
||||
})
|
||||
})
|
||||
|
||||
@ -28,14 +28,21 @@ beforeAll(async () => {
|
||||
password: '1234',
|
||||
},
|
||||
),
|
||||
Factory.build('user', {
|
||||
id: 'u2',
|
||||
role: 'user',
|
||||
name: 'Offensive Name',
|
||||
slug: 'offensive-name',
|
||||
avatar: '/some/offensive/avatar.jpg',
|
||||
about: 'This self description is very offensive',
|
||||
}),
|
||||
Factory.build(
|
||||
'user',
|
||||
{
|
||||
id: 'u2',
|
||||
role: 'user',
|
||||
name: 'Offensive Name',
|
||||
slug: 'offensive-name',
|
||||
about: 'This self description is very offensive',
|
||||
},
|
||||
{
|
||||
avatar: Factory.build('image', {
|
||||
url: '/some/offensive/avatar.jpg',
|
||||
}),
|
||||
},
|
||||
),
|
||||
neode.create('Category', {
|
||||
id: 'cat9',
|
||||
name: 'Democracy & Politics',
|
||||
@ -96,10 +103,12 @@ beforeAll(async () => {
|
||||
title: 'Disabled post',
|
||||
content: 'This is an offensive post content',
|
||||
contentExcerpt: 'This is an offensive post content',
|
||||
image: '/some/offensive/image.jpg',
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
image: Factory.build('image', {
|
||||
url: '/some/offensive/image.jpg',
|
||||
}),
|
||||
author: troll,
|
||||
categoryIds,
|
||||
},
|
||||
@ -213,7 +222,9 @@ describe('softDeleteMiddleware', () => {
|
||||
name
|
||||
slug
|
||||
about
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -229,7 +240,9 @@ describe('softDeleteMiddleware', () => {
|
||||
contributions {
|
||||
title
|
||||
slug
|
||||
image
|
||||
image {
|
||||
url
|
||||
}
|
||||
content
|
||||
contentExcerpt
|
||||
}
|
||||
@ -253,7 +266,10 @@ describe('softDeleteMiddleware', () => {
|
||||
it('displays slug', () => expect(subject.slug).toEqual('offensive-name'))
|
||||
it('displays about', () =>
|
||||
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', () => {
|
||||
@ -265,7 +281,10 @@ describe('softDeleteMiddleware', () => {
|
||||
expect(subject.content).toEqual('This is an offensive post content'))
|
||||
it('displays contentExcerpt', () =>
|
||||
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', () => {
|
||||
@ -288,7 +307,7 @@ describe('softDeleteMiddleware', () => {
|
||||
it('obfuscates name', () => expect(subject.name).toEqual('UNAVAILABLE'))
|
||||
it('obfuscates slug', () => expect(subject.slug).toEqual('UNAVAILABLE'))
|
||||
it('obfuscates about', () => expect(subject.about).toEqual('UNAVAILABLE'))
|
||||
it('obfuscates avatar', () => expect(subject.avatar).toEqual('UNAVAILABLE'))
|
||||
it('obfuscates avatar', () => expect(subject.avatar).toEqual(null))
|
||||
})
|
||||
|
||||
describe('Post', () => {
|
||||
|
||||
7
backend/src/models/Image.js
Normal file
7
backend/src/models/Image.js
Normal 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() },
|
||||
}
|
||||
@ -4,6 +4,12 @@ export default {
|
||||
id: { type: 'string', primary: true, default: uuid },
|
||||
activityId: { type: 'string', allow: [null] },
|
||||
objectId: { type: 'string', allow: [null] },
|
||||
image: {
|
||||
type: 'relationship',
|
||||
relationship: 'HERO_IMAGE',
|
||||
target: 'Image',
|
||||
direction: 'out',
|
||||
},
|
||||
author: {
|
||||
type: 'relationship',
|
||||
relationship: 'WROTE',
|
||||
@ -14,7 +20,6 @@ export default {
|
||||
slug: { type: 'string', allow: [null], unique: 'true' },
|
||||
content: { type: 'string', disallow: [null], min: 3 },
|
||||
contentExcerpt: { type: 'string', allow: [null] },
|
||||
image: { type: 'string', allow: [null] },
|
||||
deleted: { type: 'boolean', default: false },
|
||||
disabled: { type: 'boolean', default: false },
|
||||
notified: {
|
||||
@ -39,8 +44,6 @@ export default {
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
language: { type: 'string', allow: [null] },
|
||||
imageBlurred: { type: 'boolean', default: false },
|
||||
imageAspectRatio: { type: 'float', default: 1.0 },
|
||||
comments: {
|
||||
type: 'relationship',
|
||||
relationship: 'COMMENTS',
|
||||
|
||||
@ -6,8 +6,12 @@ export default {
|
||||
name: { type: 'string', disallow: [null], min: 3 },
|
||||
slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true },
|
||||
encryptedPassword: 'string',
|
||||
avatar: { type: 'string', allow: [null] },
|
||||
coverImg: { type: 'string', allow: [null] },
|
||||
avatar: {
|
||||
type: 'relationship',
|
||||
relationship: 'AVATAR_IMAGE',
|
||||
target: 'Image',
|
||||
direction: 'out',
|
||||
},
|
||||
deleted: { type: 'boolean', default: false },
|
||||
disabled: { type: 'boolean', default: false },
|
||||
role: { type: 'string', default: 'user' },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// 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
|
||||
export default {
|
||||
Image: require('./Image.js').default,
|
||||
Badge: require('./Badge.js').default,
|
||||
User: require('./User.js').default,
|
||||
EmailAddress: require('./EmailAddress.js').default,
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
8
backend/src/schema/resolvers/images.js
Normal file
8
backend/src/schema/resolvers/images.js
Normal file
@ -0,0 +1,8 @@
|
||||
import Resolver from './helpers/Resolver'
|
||||
export default {
|
||||
Image: {
|
||||
...Resolver('Image', {
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio'],
|
||||
}),
|
||||
},
|
||||
}
|
||||
121
backend/src/schema/resolvers/images/images.js
Normal file
121
backend/src/schema/resolvers/images/images.js
Normal 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)
|
||||
}
|
||||
344
backend/src/schema/resolvers/images/images.spec.js
Normal file
344
backend/src/schema/resolvers/images/images.spec.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import fileUpload from './fileUpload'
|
||||
import { mergeImage, deleteImage } from './images/images'
|
||||
import Resolver from './helpers/Resolver'
|
||||
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
|
||||
|
||||
@ -77,14 +77,16 @@ export default {
|
||||
Mutation: {
|
||||
CreatePost: async (_parent, params, context, _resolveInfo) => {
|
||||
const { categoryIds } = params
|
||||
const { image: imageInput } = params
|
||||
delete params.categoryIds
|
||||
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
|
||||
delete params.image
|
||||
params.id = params.id || uuid()
|
||||
const session = context.driver.session()
|
||||
const writeTxResultPromise = session.writeTransaction(async transaction => {
|
||||
const createPostTransactionResponse = await transaction.run(
|
||||
`
|
||||
CREATE (post:Post {params})
|
||||
CREATE (post:Post)
|
||||
SET post += $params
|
||||
SET post.createdAt = toString(datetime())
|
||||
SET post.updatedAt = toString(datetime())
|
||||
WITH post
|
||||
@ -94,14 +96,18 @@ export default {
|
||||
UNWIND $categoryIds AS categoryId
|
||||
MATCH (category:Category {id: categoryId})
|
||||
MERGE (post)-[:CATEGORIZED]->(category)
|
||||
RETURN post
|
||||
RETURN post {.*}
|
||||
`,
|
||||
{ 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 {
|
||||
const [post] = await writeTxResultPromise
|
||||
const post = await writeTxResultPromise
|
||||
return post
|
||||
} catch (e) {
|
||||
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
||||
@ -113,8 +119,9 @@ export default {
|
||||
},
|
||||
UpdatePost: async (_parent, params, context, _resolveInfo) => {
|
||||
const { categoryIds } = params
|
||||
const { image: imageInput } = params
|
||||
delete params.categoryIds
|
||||
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
|
||||
delete params.image
|
||||
const session = context.driver.session()
|
||||
let updatePostCypher = `
|
||||
MATCH (post:Post {id: $params.id})
|
||||
@ -142,7 +149,7 @@ export default {
|
||||
`
|
||||
}
|
||||
|
||||
updatePostCypher += `RETURN post`
|
||||
updatePostCypher += `RETURN post {.*}`
|
||||
const updatePostVariables = { categoryIds, params }
|
||||
try {
|
||||
const writeTxResultPromise = session.writeTransaction(async transaction => {
|
||||
@ -150,9 +157,11 @@ export default {
|
||||
updatePostCypher,
|
||||
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
|
||||
} finally {
|
||||
session.close()
|
||||
@ -171,15 +180,16 @@ export default {
|
||||
SET post.contentExcerpt = 'UNAVAILABLE'
|
||||
SET post.title = 'UNAVAILABLE'
|
||||
SET comment.deleted = TRUE
|
||||
REMOVE post.image
|
||||
RETURN post
|
||||
RETURN post {.*}
|
||||
`,
|
||||
{ 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 {
|
||||
const [post] = await writeTxResultPromise
|
||||
const post = await writeTxResultPromise
|
||||
return post
|
||||
} finally {
|
||||
session.close()
|
||||
@ -311,16 +321,7 @@ export default {
|
||||
},
|
||||
Post: {
|
||||
...Resolver('Post', {
|
||||
undefinedToNull: [
|
||||
'activityId',
|
||||
'objectId',
|
||||
'image',
|
||||
'language',
|
||||
'pinnedAt',
|
||||
'pinned',
|
||||
'imageBlurred',
|
||||
'imageAspectRatio',
|
||||
],
|
||||
undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'],
|
||||
hasMany: {
|
||||
tags: '-[:TAGGED]->(related:Tag)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
@ -331,6 +332,7 @@ export default {
|
||||
hasOne: {
|
||||
author: '<-[:WROTE]-(related:User)',
|
||||
pinnedBy: '<-[:PINNED]-(related:User)',
|
||||
image: '-[:HERO_IMAGE]->(related:Image)',
|
||||
},
|
||||
count: {
|
||||
commentsCount:
|
||||
|
||||
@ -336,8 +336,14 @@ describe('CreatePost', () => {
|
||||
describe('UpdatePost', () => {
|
||||
let author, newlyCreatedPost
|
||||
const updatePostMutation = gql`
|
||||
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
|
||||
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
|
||||
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) {
|
||||
UpdatePost(
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
categoryIds: $categoryIds
|
||||
image: $image
|
||||
) {
|
||||
id
|
||||
title
|
||||
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', () => {
|
||||
const pinPostMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
pinPost(id: $id) {
|
||||
id
|
||||
title
|
||||
content
|
||||
author {
|
||||
name
|
||||
slug
|
||||
}
|
||||
pinnedBy {
|
||||
id
|
||||
name
|
||||
role
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
pinnedAt
|
||||
pinned
|
||||
}
|
||||
}
|
||||
`
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
authenticatedUser = null
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordinary users', () => {
|
||||
it('throws authorization error', async () => {
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('moderators', () => {
|
||||
let moderator
|
||||
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 () => {
|
||||
authenticatedUser = null
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
it('throws authorization error', async () => {
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordinary users', () => {
|
||||
it('throws authorization error', async () => {
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
describe('admins', () => {
|
||||
let admin
|
||||
beforeEach(async () => {
|
||||
admin = await user.update({
|
||||
role: 'admin',
|
||||
name: 'Admin',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
authenticatedUser = await admin.toJson()
|
||||
})
|
||||
|
||||
describe('moderators', () => {
|
||||
let moderator
|
||||
describe('are allowed to pin posts', () => {
|
||||
beforeEach(async () => {
|
||||
moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() })
|
||||
authenticatedUser = await moderator.toJson()
|
||||
await Factory.build(
|
||||
'post',
|
||||
{
|
||||
id: 'created-and-pinned-by-same-admin',
|
||||
},
|
||||
{
|
||||
author: admin,
|
||||
},
|
||||
)
|
||||
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { pinPost: null },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
{
|
||||
it('responds with the updated Post', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
pinPost: {
|
||||
id: 'created-and-pinned-by-same-admin',
|
||||
},
|
||||
{
|
||||
author: admin,
|
||||
},
|
||||
)
|
||||
variables = { ...variables, id: 'created-and-pinned-by-same-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',
|
||||
},
|
||||
author: {
|
||||
name: 'Admin',
|
||||
},
|
||||
pinnedBy: {
|
||||
id: 'current-user',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
|
||||
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,
|
||||
)
|
||||
})
|
||||
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',
|
||||
{
|
||||
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', () => {
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
author: {
|
||||
name: 'otherAdmin',
|
||||
},
|
||||
pinnedBy: {
|
||||
id: 'current-user',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
author: {
|
||||
name: 'otherAdmin',
|
||||
},
|
||||
pinnedBy: {
|
||||
id: 'current-user',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('post created by another user', () => {
|
||||
it('responds with the updated Post', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
pinPost: {
|
||||
id: 'p9876',
|
||||
author: {
|
||||
slug: 'the-author',
|
||||
},
|
||||
pinnedBy: {
|
||||
id: 'current-user',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
describe('post created by another user', () => {
|
||||
it('responds with the updated Post', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
pinPost: {
|
||||
id: 'p9876',
|
||||
author: {
|
||||
slug: 'the-author',
|
||||
},
|
||||
pinnedBy: {
|
||||
id: 'current-user',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
|
||||
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', () => {
|
||||
let pinnedPost
|
||||
beforeEach(async () => {
|
||||
await Factory.build(
|
||||
'post',
|
||||
{
|
||||
id: 'only-pinned-post',
|
||||
},
|
||||
{
|
||||
author: admin,
|
||||
},
|
||||
)
|
||||
await mutate({ mutation: pinPostMutation, variables })
|
||||
describe('order by `pinned_asc` and `createdAt_desc`', () => {
|
||||
beforeEach(() => {
|
||||
// this is the ordering in the frontend
|
||||
variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
|
||||
})
|
||||
|
||||
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('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
|
||||
}
|
||||
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: {
|
||||
Post: [
|
||||
{
|
||||
id: 'im-a-pinned-post',
|
||||
pinned: true,
|
||||
createdAt: '2019-11-22T17:26:29.070Z',
|
||||
pinnedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
id: 'p9876',
|
||||
pinned: null,
|
||||
createdAt: expect.any(String),
|
||||
pinnedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'i-was-created-before-pinned-post',
|
||||
pinned: null,
|
||||
createdAt: '2019-10-22T17:26:29.070Z',
|
||||
pinnedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
}
|
||||
`
|
||||
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({
|
||||
data: {
|
||||
Post: [
|
||||
{
|
||||
id: 'im-a-pinned-post',
|
||||
pinned: true,
|
||||
createdAt: '2019-11-22T17:26:29.070Z',
|
||||
pinnedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
id: 'p9876',
|
||||
pinned: null,
|
||||
createdAt: expect.any(String),
|
||||
pinnedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'i-was-created-before-pinned-post',
|
||||
pinned: null,
|
||||
createdAt: '2019-10-22T17:26:29.070Z',
|
||||
pinnedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unpin posts', () => {
|
||||
const unpinPostMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
unpinPost(id: $id) {
|
||||
describe('unpin posts', () => {
|
||||
let pinnedPost
|
||||
const unpinPostMutation = gql`
|
||||
mutation($id: ID!) {
|
||||
unpinPost(id: $id) {
|
||||
id
|
||||
title
|
||||
content
|
||||
author {
|
||||
name
|
||||
slug
|
||||
}
|
||||
pinnedBy {
|
||||
id
|
||||
title
|
||||
content
|
||||
author {
|
||||
name
|
||||
slug
|
||||
}
|
||||
pinnedBy {
|
||||
id
|
||||
name
|
||||
role
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
pinned
|
||||
pinnedAt
|
||||
name
|
||||
role
|
||||
}
|
||||
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 () => {
|
||||
variables = { ...variables }
|
||||
moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() })
|
||||
authenticatedUser = await moderator.toJson()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('throws authorization error', async () => {
|
||||
authenticatedUser = null
|
||||
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject({
|
||||
errors: [{ message: 'Not Authorised!' }],
|
||||
data: { unpinPost: null },
|
||||
})
|
||||
it('throws authorization error', async () => {
|
||||
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('admin can unpin posts', () => {
|
||||
let admin
|
||||
beforeEach(async () => {
|
||||
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() })
|
||||
})
|
||||
|
||||
describe('moderators cannot unpin posts', () => {
|
||||
let moderator
|
||||
beforeEach(async () => {
|
||||
moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() })
|
||||
authenticatedUser = await moderator.toJson()
|
||||
})
|
||||
|
||||
it('throws authorization error', async () => {
|
||||
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,
|
||||
},
|
||||
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(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
it('unsets `pinned` property', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
unpinPost: {
|
||||
id: 'post-to-be-unpinned',
|
||||
pinned: null,
|
||||
},
|
||||
it('unsets `pinned` property', async () => {
|
||||
const expected = {
|
||||
data: {
|
||||
unpinPost: {
|
||||
id: 'post-to-be-unpinned',
|
||||
pinned: null,
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
},
|
||||
errors: undefined,
|
||||
}
|
||||
await expect(mutate({ mutation: unpinPostMutation, variables })).resolves.toMatchObject(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -897,7 +956,9 @@ describe('DeletePost', () => {
|
||||
deleted
|
||||
content
|
||||
contentExcerpt
|
||||
image
|
||||
image {
|
||||
url
|
||||
}
|
||||
comments {
|
||||
deleted
|
||||
content
|
||||
@ -915,9 +976,11 @@ describe('DeletePost', () => {
|
||||
id: 'p4711',
|
||||
title: 'I will be deleted',
|
||||
content: 'To be deleted',
|
||||
image: 'path/to/some/image',
|
||||
},
|
||||
{
|
||||
image: Factory.build('image', {
|
||||
url: 'path/to/some/image',
|
||||
}),
|
||||
author,
|
||||
categoryIds,
|
||||
},
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import { getNeode } from '../../db/neo4j'
|
||||
import fileUpload from './fileUpload'
|
||||
import encryptPassword from '../../helpers/encryptPassword'
|
||||
import generateNonce from './helpers/generateNonce'
|
||||
import existingEmailAddress from './helpers/existingEmailAddress'
|
||||
import normalizeEmail from './helpers/normalizeEmail'
|
||||
import createOrUpdateLocations from './users/location'
|
||||
|
||||
const neode = getNeode()
|
||||
|
||||
@ -24,8 +22,6 @@ export default {
|
||||
}
|
||||
},
|
||||
SignupVerification: async (_parent, args, context) => {
|
||||
const { driver } = context
|
||||
const session = driver.session()
|
||||
const { termsAndConditionsAgreedVersion } = args
|
||||
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
|
||||
if (!regEx.test(termsAndConditionsAgreedVersion)) {
|
||||
@ -35,27 +31,39 @@ export default {
|
||||
|
||||
let { nonce, email } = args
|
||||
email = normalizeEmail(email)
|
||||
const result = await neode.cypher(
|
||||
`
|
||||
MATCH(email:EmailAddress {nonce: {nonce}, email: {email}})
|
||||
WHERE NOT (email)-[:BELONGS_TO]->()
|
||||
RETURN email
|
||||
`,
|
||||
{ nonce, email },
|
||||
)
|
||||
const emailAddress = await neode.hydrateFirst(result, 'email', neode.model('EmailAddress'))
|
||||
if (!emailAddress) throw new UserInputError('Invalid email or nonce')
|
||||
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
|
||||
args = await encryptPassword(args)
|
||||
delete args.nonce
|
||||
delete args.email
|
||||
args = encryptPassword(args)
|
||||
|
||||
const { driver } = context
|
||||
const session = driver.session()
|
||||
const writeTxResultPromise = session.writeTransaction(async transaction => {
|
||||
const createUserTransactionResponse = await transaction.run(
|
||||
`
|
||||
MATCH(email:EmailAddress {nonce: $nonce, email: $email})
|
||||
WHERE NOT (email)-[:BELONGS_TO]->()
|
||||
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 {
|
||||
const user = await neode.create('User', args)
|
||||
await Promise.all([
|
||||
user.relateTo(emailAddress, 'primaryEmail'),
|
||||
emailAddress.relateTo(user, 'belongsTo'),
|
||||
emailAddress.update({ verifiedAt: new Date().toISOString() }),
|
||||
])
|
||||
await createOrUpdateLocations(args.id, args.locationName, session)
|
||||
return user.toJson()
|
||||
const user = await writeTxResultPromise
|
||||
return user
|
||||
} catch (e) {
|
||||
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
||||
throw new UserInputError('User with this slug already exists!')
|
||||
|
||||
@ -48,7 +48,7 @@ export default {
|
||||
const loginTransactionResponse = await transaction.run(
|
||||
`
|
||||
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 },
|
||||
)
|
||||
|
||||
@ -106,7 +106,9 @@ describe('currentUser', () => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
email
|
||||
role
|
||||
}
|
||||
@ -131,13 +133,15 @@ describe('currentUser', () => {
|
||||
{
|
||||
id: 'u3',
|
||||
// the `id` is the only thing that has to match the decoded JWT bearer token
|
||||
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
|
||||
name: 'Matilde Hermiston',
|
||||
slug: 'matilde-hermiston',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
email: 'test@example.org',
|
||||
avatar: Factory.build('image', {
|
||||
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg',
|
||||
}),
|
||||
},
|
||||
)
|
||||
const userBearerToken = encode({ id: 'u3' })
|
||||
@ -149,7 +153,9 @@ describe('currentUser', () => {
|
||||
data: {
|
||||
currentUser: {
|
||||
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',
|
||||
name: 'Matilde Hermiston',
|
||||
slug: 'matilde-hermiston',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
import fileUpload from './fileUpload'
|
||||
import { getNeode } from '../../db/neo4j'
|
||||
import { UserInputError, ForbiddenError } from 'apollo-server'
|
||||
import { mergeImage, deleteImage } from './images/images'
|
||||
import Resolver from './helpers/Resolver'
|
||||
import log from './helpers/databaseLogger'
|
||||
import createOrUpdateLocations from './users/location'
|
||||
@ -140,6 +140,8 @@ export default {
|
||||
},
|
||||
UpdateUser: async (_parent, params, context, _resolveInfo) => {
|
||||
const { termsAndConditionsAgreedVersion } = params
|
||||
const { avatar: avatarInput } = params
|
||||
delete params.avatar
|
||||
if (termsAndConditionsAgreedVersion) {
|
||||
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
|
||||
if (!regEx.test(termsAndConditionsAgreedVersion)) {
|
||||
@ -147,7 +149,6 @@ export default {
|
||||
}
|
||||
params.termsAndConditionsAgreedAt = new Date().toISOString()
|
||||
}
|
||||
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
|
||||
const session = context.driver.session()
|
||||
|
||||
const writeTxResultPromise = session.writeTransaction(async transaction => {
|
||||
@ -156,14 +157,18 @@ export default {
|
||||
MATCH (user:User {id: $params.id})
|
||||
SET user += $params
|
||||
SET user.updatedAt = toString(datetime())
|
||||
RETURN user
|
||||
RETURN user {.*}
|
||||
`,
|
||||
{ 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 {
|
||||
const [user] = await writeTxResultPromise
|
||||
const user = await writeTxResultPromise
|
||||
await createOrUpdateLocations(params.id, params.locationName, session)
|
||||
return user
|
||||
} catch (error) {
|
||||
@ -173,34 +178,38 @@ export default {
|
||||
}
|
||||
},
|
||||
DeleteUser: async (object, params, context, resolveInfo) => {
|
||||
const { resource } = params
|
||||
const { resource, id: userId } = params
|
||||
const session = context.driver.session()
|
||||
const { id: userId } = params
|
||||
try {
|
||||
|
||||
const deleteUserTxResultPromise = session.writeTransaction(async transaction => {
|
||||
if (resource && resource.length) {
|
||||
await session.writeTransaction(transaction => {
|
||||
resource.map(node => {
|
||||
return transaction.run(
|
||||
await Promise.all(
|
||||
resource.map(async node => {
|
||||
const txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
|
||||
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
|
||||
SET resource.deleted = true
|
||||
SET resource.content = 'UNAVAILABLE'
|
||||
SET resource.contentExcerpt = 'UNAVAILABLE'
|
||||
SET comment.deleted = true
|
||||
RETURN author
|
||||
`,
|
||||
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
|
||||
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
|
||||
SET resource.deleted = true
|
||||
SET resource.content = 'UNAVAILABLE'
|
||||
SET resource.contentExcerpt = 'UNAVAILABLE'
|
||||
SET comment.deleted = true
|
||||
RETURN resource {.*}
|
||||
`,
|
||||
{
|
||||
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})
|
||||
SET user.deleted = true
|
||||
SET user.name = 'UNAVAILABLE'
|
||||
@ -211,14 +220,17 @@ export default {
|
||||
WITH user
|
||||
OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia)
|
||||
DETACH DELETE socialMedia
|
||||
RETURN user
|
||||
RETURN user {.*}
|
||||
`,
|
||||
{ userId },
|
||||
)
|
||||
log(deleteUserTransactionResponse)
|
||||
return deleteUserTransactionResponse.records.map(record => record.get('user').properties)
|
||||
})
|
||||
const [user] = await deleteUserTxResultPromise
|
||||
{ userId },
|
||||
)
|
||||
log(deleteUserTransactionResponse)
|
||||
const [user] = deleteUserTransactionResponse.records.map(record => record.get('user'))
|
||||
await deleteImage(user, 'AVATAR_IMAGE', { transaction })
|
||||
return user
|
||||
})
|
||||
try {
|
||||
const user = await deleteUserTxResultPromise
|
||||
return user
|
||||
} finally {
|
||||
session.close()
|
||||
@ -237,8 +249,6 @@ export default {
|
||||
...Resolver('User', {
|
||||
undefinedToNull: [
|
||||
'actorId',
|
||||
'avatar',
|
||||
'coverImg',
|
||||
'deleted',
|
||||
'disabled',
|
||||
'locationName',
|
||||
@ -272,6 +282,7 @@ export default {
|
||||
badgesCount: '<-[:REWARDED]-(related:Badge)',
|
||||
},
|
||||
hasOne: {
|
||||
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
location: '-[:IS_IN]->(related:Location)',
|
||||
},
|
||||
|
||||
@ -29,7 +29,7 @@ beforeAll(() => {
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
@ -495,6 +495,12 @@ describe('DeleteUser', () => {
|
||||
mutate({ mutation: deleteUserMutation, variables }),
|
||||
).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 }),
|
||||
).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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletion of all post and comments requested', () => {
|
||||
beforeEach(() => {
|
||||
variables = { ...variables, resource: ['Post', 'Comment'] }
|
||||
@ -882,27 +893,27 @@ describe('DeleteUser', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('connected `EmailAddress` nodes', () => {
|
||||
it('will be removed completely', async () => {
|
||||
await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
|
||||
await mutate({ mutation: deleteUserMutation, variables })
|
||||
await expect(neode.all('EmailAddress')).resolves.toHaveLength(1)
|
||||
})
|
||||
})
|
||||
describe('connected `EmailAddress` nodes', () => {
|
||||
it('will be removed completely', async () => {
|
||||
await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
|
||||
await mutate({ mutation: deleteUserMutation, variables })
|
||||
await expect(neode.all('EmailAddress')).resolves.toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connected `SocialMedia` nodes', () => {
|
||||
beforeEach(async () => {
|
||||
const socialMedia = await Factory.build('socialMedia')
|
||||
await socialMedia.relateTo(user, 'ownedBy')
|
||||
})
|
||||
describe('connected `SocialMedia` nodes', () => {
|
||||
beforeEach(async () => {
|
||||
const socialMedia = await Factory.build('socialMedia')
|
||||
await socialMedia.relateTo(user, 'ownedBy')
|
||||
})
|
||||
|
||||
it('will be removed completely', async () => {
|
||||
await expect(neode.all('SocialMedia')).resolves.toHaveLength(1)
|
||||
await mutate({ mutation: deleteUserMutation, variables })
|
||||
await expect(neode.all('SocialMedia')).resolves.toHaveLength(0)
|
||||
})
|
||||
})
|
||||
it('will be removed completely', async () => {
|
||||
await expect(neode.all('SocialMedia')).resolves.toHaveLength(1)
|
||||
await mutate({ mutation: deleteUserMutation, variables })
|
||||
await expect(neode.all('SocialMedia')).resolves.toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,28 +8,6 @@ const neode = getNeode()
|
||||
const driver = getDriver()
|
||||
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`
|
||||
mutation($id: ID!, $name: String!, $locationName: String) {
|
||||
UpdateUser(id: $id, name: $name, locationName: $locationName) {
|
||||
@ -38,9 +16,10 @@ const updateUserMutation = gql`
|
||||
}
|
||||
`
|
||||
|
||||
let newlyCreatedNodesWithLocales = [
|
||||
const newlyCreatedNodesWithLocales = [
|
||||
{
|
||||
city: {
|
||||
lng: -74.5763,
|
||||
lat: 41.1534,
|
||||
nameES: 'Hamburg',
|
||||
nameFR: 'Hamburg',
|
||||
@ -54,7 +33,6 @@ let newlyCreatedNodesWithLocales = [
|
||||
name: 'Hamburg',
|
||||
namePL: 'Hamburg',
|
||||
id: 'place.5977106083398860',
|
||||
lng: -74.5763,
|
||||
},
|
||||
state: {
|
||||
namePT: 'Nova Jérsia',
|
||||
@ -105,82 +83,12 @@ beforeEach(() => {
|
||||
authenticatedUser = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanDatabase()
|
||||
})
|
||||
afterEach(cleanDatabase)
|
||||
|
||||
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', () => {
|
||||
let user
|
||||
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', {
|
||||
id: 'updating-user',
|
||||
})
|
||||
@ -192,17 +100,18 @@ describe('userMiddleware', () => {
|
||||
...variables,
|
||||
id: 'updating-user',
|
||||
name: 'Updating user',
|
||||
locationName: 'Hamburg, Germany',
|
||||
locationName: 'Hamburg, New Jersey, United States of America',
|
||||
}
|
||||
await mutate({ mutation: updateUserMutation, variables })
|
||||
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(
|
||||
locations.records.map(record => {
|
||||
return {
|
||||
city: record.get('city').properties,
|
||||
country: record.get('country').properties,
|
||||
city: record.get('city'),
|
||||
state: record.get('state'),
|
||||
country: record.get('country'),
|
||||
}
|
||||
}),
|
||||
).toEqual(newlyCreatedNodesWithLocales)
|
||||
|
||||
@ -9,14 +9,10 @@ type Mutation {
|
||||
SignupByInvitation(email: String!, token: String!): EmailAddress
|
||||
SignupVerification(
|
||||
nonce: String!
|
||||
name: String!
|
||||
email: String!
|
||||
name: String!
|
||||
password: String!
|
||||
slug: String
|
||||
avatar: String
|
||||
coverImg: String
|
||||
avatarUpload: Upload
|
||||
locationName: String
|
||||
about: String
|
||||
termsAndConditionsAgreedVersion: String!
|
||||
locale: String
|
||||
|
||||
18
backend/src/schema/types/type/Image.gql
Normal file
18
backend/src/schema/types/type/Image.gql
Normal 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,
|
||||
}
|
||||
@ -40,7 +40,6 @@ input _PostFilter {
|
||||
content_not_starts_with: String
|
||||
content_ends_with: String
|
||||
content_not_ends_with: String
|
||||
image: String
|
||||
visibility: Visibility
|
||||
visibility_not: Visibility
|
||||
visibility_in: [Visibility!]
|
||||
@ -82,7 +81,6 @@ input _PostFilter {
|
||||
emotions_none: _PostEMOTEDFilter
|
||||
emotions_single: _PostEMOTEDFilter
|
||||
emotions_every: _PostEMOTEDFilter
|
||||
imageBlurred: Boolean
|
||||
}
|
||||
|
||||
enum _PostOrdering {
|
||||
@ -94,8 +92,6 @@ enum _PostOrdering {
|
||||
slug_desc
|
||||
content_asc
|
||||
content_desc
|
||||
image_asc
|
||||
image_desc
|
||||
visibility_asc
|
||||
visibility_desc
|
||||
createdAt_asc
|
||||
@ -118,9 +114,7 @@ type Post {
|
||||
slug: String!
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
image: String
|
||||
imageUpload: Upload
|
||||
imageAspectRatio: Float
|
||||
image: Image @relation(name: "HERO_IMAGE", direction: "OUT")
|
||||
visibility: Visibility
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
@ -128,7 +122,6 @@ type Post {
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
language: String
|
||||
imageBlurred: Boolean
|
||||
pinnedAt: String @cypher(
|
||||
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!
|
||||
slug: String
|
||||
content: String!
|
||||
image: String
|
||||
imageUpload: Upload
|
||||
image: ImageInput,
|
||||
visibility: Visibility
|
||||
language: String
|
||||
categoryIds: [ID]
|
||||
contentExcerpt: String
|
||||
imageBlurred: Boolean
|
||||
imageAspectRatio: Float
|
||||
): Post
|
||||
UpdatePost(
|
||||
id: ID!
|
||||
@ -193,13 +183,10 @@ type Mutation {
|
||||
slug: String
|
||||
content: String!
|
||||
contentExcerpt: String
|
||||
image: String
|
||||
imageUpload: Upload
|
||||
image: ImageInput,
|
||||
visibility: Visibility
|
||||
language: String
|
||||
categoryIds: [ID]
|
||||
imageBlurred: Boolean
|
||||
imageAspectRatio: Float
|
||||
): Post
|
||||
DeletePost(id: ID!): Post
|
||||
AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED
|
||||
|
||||
@ -5,10 +5,6 @@ enum _UserOrdering {
|
||||
name_desc
|
||||
slug_asc
|
||||
slug_desc
|
||||
avatar_asc
|
||||
avatar_desc
|
||||
coverImg_asc
|
||||
coverImg_desc
|
||||
role_asc
|
||||
role_desc
|
||||
locationName_asc
|
||||
@ -29,8 +25,7 @@ type User {
|
||||
name: String
|
||||
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
|
||||
slug: String!
|
||||
avatar: String
|
||||
coverImg: String
|
||||
avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT")
|
||||
deleted: Boolean
|
||||
disabled: Boolean
|
||||
role: UserGroup!
|
||||
@ -161,8 +156,6 @@ type Query {
|
||||
email: String # admins need to search for a user sometimes
|
||||
name: String
|
||||
slug: String
|
||||
avatar: String
|
||||
coverImg: String
|
||||
role: UserGroup
|
||||
locationName: String
|
||||
about: String
|
||||
@ -198,9 +191,7 @@ type Mutation {
|
||||
name: String
|
||||
email: String
|
||||
slug: String
|
||||
avatar: String
|
||||
coverImg: String
|
||||
avatarUpload: Upload
|
||||
avatar: ImageInput
|
||||
locationName: String
|
||||
about: String
|
||||
termsAndConditionsAgreedVersion: String
|
||||
|
||||
@ -4498,14 +4498,14 @@ graphql-redis-subscriptions@^2.2.1:
|
||||
optionalDependencies:
|
||||
ioredis "^4.6.3"
|
||||
|
||||
graphql-shield@~7.2.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.2.0.tgz#81b26794370608ad78dfe3833473789fb471fbd8"
|
||||
integrity sha512-eLdD+gUIKYu77XRcuHs5ewZhiBuRFeWFGxPnJa+g9AkxB7Yi5RSEjEJEx0Drg9GuNvDYpHeW7nPff4v35AT2aQ==
|
||||
graphql-shield@~7.0.14:
|
||||
version "7.0.14"
|
||||
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.14.tgz#3cbbf2722f2e3393fed7f47d866a1324bc3ce76a"
|
||||
integrity sha512-YVedaL+4pITisSGRqMVeGX8ydOLSTQlHQN6o0Jly7z2cSy1wOzGJIRpfofETJtGLhBnPHHy1otINzuAyjGJO/g==
|
||||
dependencies:
|
||||
"@types/yup" "0.26.32"
|
||||
object-hash "^2.0.3"
|
||||
yup "^0.28.3"
|
||||
yup "^0.28.1"
|
||||
|
||||
graphql-subscriptions@^1.0.0:
|
||||
version "1.1.0"
|
||||
@ -6684,7 +6684,7 @@ nodemailer-html-to-text@^3.1.0:
|
||||
dependencies:
|
||||
html-to-text "^5.1.1"
|
||||
|
||||
nodemailer@^6.4.5:
|
||||
nodemailer@^6.4.4:
|
||||
version "6.4.5"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.5.tgz#45614c6454d1a947242105eeddae03df87e29916"
|
||||
integrity sha512-NH7aNVQyZLAvGr2+EOto7znvz+qJ02Cb/xpou98ApUt5tEAUSVUxhvHvgV/8I5dhjKTYqUw0nasoKzLNBJKrDQ==
|
||||
@ -7888,7 +7888,7 @@ sane@^4.0.3:
|
||||
minimist "^1.1.1"
|
||||
walker "~1.0.5"
|
||||
|
||||
sanitize-html@~1.22.1:
|
||||
sanitize-html@~1.22.0:
|
||||
version "1.22.1"
|
||||
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.22.1.tgz#5b36c92ab27917ddd2775396815c2bc1a6268310"
|
||||
integrity sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q==
|
||||
@ -9378,7 +9378,7 @@ yargs@^15.0.0:
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^16.1.0"
|
||||
|
||||
yup@^0.28.3:
|
||||
yup@^0.28.1:
|
||||
version "0.28.3"
|
||||
resolved "https://registry.yarnpkg.com/yup/-/yup-0.28.3.tgz#1ca607405a8adf24a5ac51f54bd09d527555f0ba"
|
||||
integrity sha512-amVkCgFWe5bGjrrUiODkbIzrSwtB8JpZrQYSrfj2YsbRdrV+tn9LquWdZDlfOx2HXyfEA8FGnlwidE/bFDxO7Q==
|
||||
|
||||
@ -3,8 +3,6 @@ import locales from '../../../webapp/locales'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
|
||||
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 => {
|
||||
var c="";
|
||||
@ -32,9 +30,11 @@ Then("my comment should be successfully created", () => {
|
||||
Then("I should see my comment", () => {
|
||||
cy.get("article.comment-card p")
|
||||
.should("contain", "Human Connection rocks")
|
||||
.get(".user-teaser span.slug")
|
||||
.should("contain", "@peter-pan") // specific enough
|
||||
.get(".user-avatar img")
|
||||
.should("have.attr", "src")
|
||||
.and("contain", narratorAvatar)
|
||||
.and("contain", 'https://') // some url
|
||||
.get(".user-teaser > .info > .text")
|
||||
.should("contain", "today at");
|
||||
});
|
||||
|
||||
@ -24,7 +24,6 @@ const narratorParams = {
|
||||
id: 'id-of-peter-pan',
|
||||
name: "Peter Pan",
|
||||
slug: "peter-pan",
|
||||
avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg",
|
||||
...termsAndConditionsAgreedVersion,
|
||||
};
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ Feature: Delete Teaser Image
|
||||
Given I have a user account
|
||||
Given I am logged in
|
||||
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 |
|
||||
|
||||
Scenario: Delete existing image
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "human-connection",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.1",
|
||||
"description": "Fullstack and API tests with cypress and cucumber for Human Connection",
|
||||
"author": "Human Connection gGmbh",
|
||||
"license": "MIT",
|
||||
|
||||
@ -3,12 +3,6 @@
|
||||
ROOT_DIR=$(dirname "$0")/../..
|
||||
tmp=$(mktemp)
|
||||
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
|
||||
do
|
||||
@ -22,13 +16,9 @@ do
|
||||
: # all good
|
||||
else
|
||||
exit_code=$?
|
||||
echo -e "${TEXT_BOLD}${TEXT_RED}>>> $(basename -- $locale_file) is not sorted by keys <<<${TEXT_RESET}"
|
||||
errors=1
|
||||
echo "$(basename -- $locale_file) is not sorted by keys"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$errors" = 1 ] && echo -e "${TEXT_BOLD}${TEXT_BLUE}Please run $0 --fix to sort your locale definitions!${TEXT_RESET}";
|
||||
|
||||
|
||||
exit $exit_code
|
||||
|
||||
@ -2,20 +2,29 @@ import { shallowMount } from '@vue/test-utils'
|
||||
import Badges from './Badges.vue'
|
||||
|
||||
describe('Badges.vue', () => {
|
||||
let wrapper
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallowMount(Badges, {})
|
||||
propsData = {}
|
||||
})
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.is('div')).toBe(true)
|
||||
})
|
||||
describe('shallowMount', () => {
|
||||
const Wrapper = () => {
|
||||
return shallowMount(Badges, { propsData })
|
||||
}
|
||||
|
||||
it('has class "hc-badges"', () => {
|
||||
expect(wrapper.contains('.hc-badges')).toBe(true)
|
||||
})
|
||||
it('has class "hc-badges"', () => {
|
||||
expect(Wrapper().contains('.hc-badges')).toBe(true)
|
||||
})
|
||||
|
||||
// TODO: add similar software tests for other components
|
||||
// TODO: add more test cases in this file
|
||||
describe('given a badge', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,8 +17,10 @@ const comment = {
|
||||
disabled: false,
|
||||
author: {
|
||||
id: '1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
slug: 'jenny-rostock',
|
||||
name: 'Rainer Unsinn',
|
||||
disabled: false,
|
||||
|
||||
@ -70,7 +70,7 @@ describe('ContributionForm.vue', () => {
|
||||
},
|
||||
url: 'someUrlToImage',
|
||||
}
|
||||
const image = '/uploads/1562010976466-avataaars'
|
||||
const image = { sensitive: false, url: '/uploads/1562010976466-avataaars', aspectRatio: 1 }
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
@ -199,10 +199,7 @@ describe('ContributionForm.vue', () => {
|
||||
language: 'en',
|
||||
id: null,
|
||||
categoryIds: ['cat12'],
|
||||
imageUpload: null,
|
||||
imageAspectRatio: null,
|
||||
image: null,
|
||||
imageBlurred: false,
|
||||
},
|
||||
}
|
||||
postTitleInput = wrapper.find('.ds-input')
|
||||
@ -233,8 +230,16 @@ describe('ContributionForm.vue', () => {
|
||||
})
|
||||
|
||||
it('supports adding a teaser image', async () => {
|
||||
const spy = jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {})
|
||||
expectedParams.variables.imageUpload = imageUpload
|
||||
expectedParams.variables.image = {
|
||||
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)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
|
||||
@ -317,7 +322,6 @@ describe('ContributionForm.vue', () => {
|
||||
name: 'Democracy & Politics',
|
||||
},
|
||||
],
|
||||
imageAspectRatio: 1,
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
@ -354,10 +358,9 @@ describe('ContributionForm.vue', () => {
|
||||
language: propsData.contribution.language,
|
||||
id: propsData.contribution.id,
|
||||
categoryIds: ['cat12'],
|
||||
image,
|
||||
imageUpload: null,
|
||||
imageAspectRatio: 1,
|
||||
imageBlurred: false,
|
||||
image: {
|
||||
sensitive: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -383,8 +386,7 @@ describe('ContributionForm.vue', () => {
|
||||
|
||||
it('supports deleting a teaser image', async () => {
|
||||
expectedParams.variables.image = null
|
||||
expectedParams.variables.imageAspectRatio = null
|
||||
propsData.contribution.image = '/uploads/someimage.png'
|
||||
propsData.contribution.image = { url: '/uploads/someimage.png' }
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('[data-test="delete-button"]').trigger('click')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
@ -106,27 +106,20 @@ export default {
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
image,
|
||||
imageAspectRatio,
|
||||
imageBlurred,
|
||||
language,
|
||||
categories,
|
||||
} = this.contribution
|
||||
const { title, content, image, language, categories } = this.contribution
|
||||
|
||||
const languageOptions = orderBy(locales, 'name').map(locale => {
|
||||
return { label: locale.name, value: locale.code }
|
||||
})
|
||||
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
title: title || '',
|
||||
content: content || '',
|
||||
image: image || null,
|
||||
imageAspectRatio: imageAspectRatio || null,
|
||||
imageBlurred: imageBlurred || false,
|
||||
imageAspectRatio,
|
||||
imageBlurred,
|
||||
language: languageOptions.find(option => option.value === language) || null,
|
||||
categoryIds: categories ? categories.map(category => category.id) : [],
|
||||
},
|
||||
@ -163,16 +156,28 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
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.$apollo
|
||||
.mutate({
|
||||
mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
|
||||
variables: {
|
||||
...this.formData,
|
||||
title,
|
||||
content,
|
||||
categoryIds,
|
||||
id: this.contribution.id || null,
|
||||
language: this.formData.language.value,
|
||||
image: this.imageUpload ? null : this.formData.image,
|
||||
imageUpload: this.imageUpload,
|
||||
image,
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
@ -198,10 +203,13 @@ export default {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({ target }) => {
|
||||
this.formData.image = target.result
|
||||
this.formData.image = {
|
||||
...this.formData.image,
|
||||
url: target.result,
|
||||
}
|
||||
}
|
||||
this.imageUpload = file
|
||||
reader.readAsDataURL(file)
|
||||
this.imageUpload = file
|
||||
}
|
||||
},
|
||||
addImageAspectRatio(aspectRatio) {
|
||||
|
||||
@ -16,8 +16,10 @@ export const post = {
|
||||
image: null,
|
||||
author: {
|
||||
id: 'u3',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
slug: 'jenny-rostock',
|
||||
name: 'Rainer Unsinn',
|
||||
disabled: false,
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
:lang="post.language"
|
||||
:class="{
|
||||
'disabled-content': post.disabled,
|
||||
'--blur-image': post.imageBlurred,
|
||||
'--blur-image': post.image && post.image.sensitive,
|
||||
}"
|
||||
:highlight="isPinned"
|
||||
>
|
||||
@ -93,8 +93,10 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { image } = this.post
|
||||
if (!image) return
|
||||
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')
|
||||
if (imageElement) {
|
||||
imageElement.style.height = `${height}px`
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<vue-dropzone
|
||||
id="customdropzone"
|
||||
:key="user.avatar"
|
||||
:key="avatarUrl"
|
||||
ref="el"
|
||||
:use-custom-slot="true"
|
||||
:options="dropzoneOptions"
|
||||
@ -41,6 +41,12 @@ export default {
|
||||
hover: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
avatarUrl() {
|
||||
const { avatar } = this.user
|
||||
return avatar && avatar.url
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
error() {
|
||||
const that = this
|
||||
@ -64,7 +70,9 @@ export default {
|
||||
.mutate({
|
||||
mutation: updateUserMutation(),
|
||||
variables: {
|
||||
avatarUpload,
|
||||
avatar: {
|
||||
upload: avatarUpload,
|
||||
},
|
||||
id: this.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
@ -12,7 +12,7 @@ describe('Upload', () => {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { UpdateUser: { id: 'upload1', avatar: '/upload/avatar.jpg' } },
|
||||
data: { UpdateUser: { id: 'upload1', avatar: { url: '/upload/avatar.jpg' } } },
|
||||
})
|
||||
.mockRejectedValue({
|
||||
message: 'File upload unsuccessful! Whatcha gonna do?',
|
||||
@ -27,7 +27,7 @@ describe('Upload', () => {
|
||||
|
||||
const propsData = {
|
||||
user: {
|
||||
avatar: '/api/generic.jpg',
|
||||
avatar: { url: '/api/generic.jpg' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,9 @@ export const user = {
|
||||
id: 'u6',
|
||||
slug: '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:
|
||||
'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,
|
||||
@ -28,7 +30,9 @@ export const user = {
|
||||
id: 'u3',
|
||||
slug: '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,
|
||||
deleted: false,
|
||||
followedByCount: 2,
|
||||
@ -83,7 +87,7 @@ storiesOf('UserTeaser', module)
|
||||
<template #dateTime>
|
||||
- HEY! I'm edited
|
||||
</template>
|
||||
</user>
|
||||
</user-teaser>
|
||||
`,
|
||||
}))
|
||||
.add('anonymous', () => ({
|
||||
|
||||
@ -66,7 +66,9 @@ describe('UserAvatar.vue', () => {
|
||||
propsData = {
|
||||
user: {
|
||||
name: 'Not Anonymous',
|
||||
avatar: '/avatar.jpg',
|
||||
avatar: {
|
||||
url: '/avatar.jpg',
|
||||
},
|
||||
},
|
||||
}
|
||||
wrapper = Wrapper()
|
||||
@ -82,7 +84,9 @@ describe('UserAvatar.vue', () => {
|
||||
propsData = {
|
||||
user: {
|
||||
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()
|
||||
|
||||
@ -69,32 +69,40 @@ export const searchResults = [
|
||||
{
|
||||
id: 'u1',
|
||||
__typename: 'User',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
name: 'Peter Lustig',
|
||||
slug: 'peter-lustig',
|
||||
},
|
||||
{
|
||||
id: 'cdbca762-0632-4564-b646-415a0c42d8b8',
|
||||
__typename: 'User',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
name: 'Herbert Schultz',
|
||||
slug: 'herbert-schultz',
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
__typename: 'User',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
name: 'Bob der Baumeister',
|
||||
slug: 'bob-der-baumeister',
|
||||
},
|
||||
{
|
||||
id: '7b654f72-f4da-4315-8bed-39de0859754b',
|
||||
__typename: 'User',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
avatar: {
|
||||
url:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
|
||||
},
|
||||
name: 'Tonya Mohr',
|
||||
slug: 'tonya-mohr',
|
||||
},
|
||||
|
||||
@ -17,7 +17,9 @@ export default i18n => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
shoutedCount
|
||||
@ -47,7 +49,9 @@ export default i18n => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
@ -67,7 +71,9 @@ export default i18n => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
shoutedCount
|
||||
|
||||
@ -12,7 +12,9 @@ export default app => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
shoutedCount
|
||||
|
||||
@ -5,7 +5,9 @@ export const userFragment = gql`
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
@ -44,14 +46,16 @@ export const postFragment = gql`
|
||||
disabled
|
||||
deleted
|
||||
slug
|
||||
image
|
||||
language
|
||||
imageBlurred
|
||||
image {
|
||||
url
|
||||
sensitive
|
||||
aspectRatio
|
||||
}
|
||||
author {
|
||||
...user
|
||||
}
|
||||
pinnedAt
|
||||
imageAspectRatio
|
||||
pinned
|
||||
}
|
||||
`
|
||||
|
||||
@ -8,25 +8,23 @@ export default () => {
|
||||
$content: String!
|
||||
$language: String
|
||||
$categoryIds: [ID]
|
||||
$imageUpload: Upload
|
||||
$imageBlurred: Boolean
|
||||
$imageAspectRatio: Float
|
||||
$image: ImageInput
|
||||
) {
|
||||
CreatePost(
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
categoryIds: $categoryIds
|
||||
imageUpload: $imageUpload
|
||||
imageBlurred: $imageBlurred
|
||||
imageAspectRatio: $imageAspectRatio
|
||||
image: $image
|
||||
) {
|
||||
title
|
||||
slug
|
||||
content
|
||||
contentExcerpt
|
||||
language
|
||||
imageBlurred
|
||||
image {
|
||||
sensitive
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
@ -36,22 +34,16 @@ export default () => {
|
||||
$title: String!
|
||||
$content: String!
|
||||
$language: String
|
||||
$imageUpload: Upload
|
||||
$image: ImageInput
|
||||
$categoryIds: [ID]
|
||||
$image: String
|
||||
$imageBlurred: Boolean
|
||||
$imageAspectRatio: Float
|
||||
) {
|
||||
UpdatePost(
|
||||
id: $id
|
||||
title: $title
|
||||
content: $content
|
||||
language: $language
|
||||
imageUpload: $imageUpload
|
||||
categoryIds: $categoryIds
|
||||
image: $image
|
||||
imageBlurred: $imageBlurred
|
||||
imageAspectRatio: $imageAspectRatio
|
||||
categoryIds: $categoryIds
|
||||
) {
|
||||
id
|
||||
title
|
||||
@ -59,13 +51,15 @@ export default () => {
|
||||
content
|
||||
contentExcerpt
|
||||
language
|
||||
imageBlurred
|
||||
image {
|
||||
sensitive
|
||||
aspectRatio
|
||||
}
|
||||
pinnedBy {
|
||||
id
|
||||
name
|
||||
role
|
||||
}
|
||||
imageAspectRatio
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@ -53,7 +53,9 @@ export const minimisedUserQuery = () => {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -223,7 +225,7 @@ export const updateUserMutation = () => {
|
||||
$allowEmbedIframes: Boolean
|
||||
$showShoutsPublicly: Boolean
|
||||
$termsAndConditionsAgreedVersion: String
|
||||
$avatarUpload: Upload
|
||||
$avatar: ImageInput
|
||||
) {
|
||||
UpdateUser(
|
||||
id: $id
|
||||
@ -234,7 +236,7 @@ export const updateUserMutation = () => {
|
||||
allowEmbedIframes: $allowEmbedIframes
|
||||
showShoutsPublicly: $showShoutsPublicly
|
||||
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
|
||||
avatarUpload: $avatarUpload
|
||||
avatar: $avatar
|
||||
) {
|
||||
id
|
||||
slug
|
||||
@ -245,7 +247,9 @@ export const updateUserMutation = () => {
|
||||
showShoutsPublicly
|
||||
locale
|
||||
termsAndConditionsAgreedVersion
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -7,7 +7,9 @@ export const blockedUsers = () => {
|
||||
id
|
||||
name
|
||||
slug
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
about
|
||||
disabled
|
||||
deleted
|
||||
|
||||
@ -7,7 +7,9 @@ export const mutedUsers = () => {
|
||||
id
|
||||
name
|
||||
slug
|
||||
avatar
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
about
|
||||
disabled
|
||||
deleted
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "human-connection-webapp",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.1",
|
||||
"description": "Human Connection Frontend",
|
||||
"authors": [
|
||||
"Grzegorz Leoniec (appinteractive)",
|
||||
@ -63,7 +63,7 @@
|
||||
"@nuxtjs/axios": "~5.9.5",
|
||||
"@nuxtjs/dotenv": "~1.4.1",
|
||||
"@nuxtjs/pwa": "^3.0.0-beta.20",
|
||||
"@nuxtjs/sentry": "^3.3.1",
|
||||
"@nuxtjs/sentry": "^3.2.4",
|
||||
"@nuxtjs/style-resources": "~1.0.0",
|
||||
"accounting": "~0.4.1",
|
||||
"apollo-cache-inmemory": "~1.6.5",
|
||||
@ -99,15 +99,15 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "~7.8.7",
|
||||
"@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-actions": "^5.3.17",
|
||||
"@storybook/addon-notes": "^5.3.17",
|
||||
"@storybook/vue": "~5.3.14",
|
||||
"@vue/cli-shared-utils": "~4.2.3",
|
||||
"@vue/eslint-config-prettier": "~6.0.0",
|
||||
"@vue/server-test-utils": "~1.0.0-beta.32",
|
||||
"@vue/test-utils": "~1.0.0-beta.32",
|
||||
"@vue/server-test-utils": "~1.0.0-beta.31",
|
||||
"@vue/test-utils": "~1.0.0-beta.31",
|
||||
"async-validator": "^3.2.4",
|
||||
"babel-core": "~7.0.0-bridge.0",
|
||||
"babel-eslint": "~10.1.0",
|
||||
@ -122,7 +122,7 @@
|
||||
"eslint-config-standard": "~14.1.0",
|
||||
"eslint-loader": "~3.0.3",
|
||||
"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-prettier": "~3.1.2",
|
||||
"eslint-plugin-promise": "~4.2.1",
|
||||
@ -130,7 +130,7 @@
|
||||
"eslint-plugin-vue": "~6.2.2",
|
||||
"faker": "^4.1.0",
|
||||
"flush-promises": "^1.0.2",
|
||||
"fuse.js": "^3.6.1",
|
||||
"fuse.js": "^3.4.6",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "~25.1.0",
|
||||
"mutation-observer": "^1.0.3",
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<masonry-grid-item
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
:imageAspectRatio="post.imageAspectRatio"
|
||||
:imageAspectRatio="post.image && post.image.aspectRatio"
|
||||
>
|
||||
<post-teaser
|
||||
:post="post"
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
>
|
||||
<template #heroImage v-if="post.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" />
|
||||
<base-button
|
||||
:icon="blurred ? 'eye' : 'eye-slash'"
|
||||
@ -235,8 +235,9 @@ export default {
|
||||
update({ Post }) {
|
||||
this.post = Post[0] || {}
|
||||
this.title = this.post.title
|
||||
this.blurred = this.post.imageBlurred
|
||||
const { image } = this.post
|
||||
this.postAuthor = this.post.author
|
||||
this.blurred = image && image.sensitive
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
|
||||
@ -237,7 +237,7 @@
|
||||
<masonry-grid-item
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
:imageAspectRatio="post.imageAspectRatio"
|
||||
:imageAspectRatio="post.image && post.image.aspectRatio"
|
||||
>
|
||||
<post-teaser
|
||||
:post="post"
|
||||
|
||||
@ -48,7 +48,7 @@ describe('blocked-users.vue', () => {
|
||||
|
||||
describe('given a list of blocked users', () => {
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ describe('muted-users.vue', () => {
|
||||
|
||||
describe('given a list of muted users', () => {
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
@ -81,7 +81,8 @@ export default ({ app = {} }) => {
|
||||
|
||||
return contentExcerpt
|
||||
},
|
||||
proxyApiUrl: url => {
|
||||
proxyApiUrl: input => {
|
||||
const url = input && (input.url || input)
|
||||
if (!url) return url
|
||||
return url.startsWith('/') ? url.replace('/', '/api/') : url
|
||||
},
|
||||
|
||||
@ -11,7 +11,9 @@ const currentUser = {
|
||||
name: 'Jenny Rostock',
|
||||
slug: 'jenny-rostock',
|
||||
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',
|
||||
locale: 'de',
|
||||
}
|
||||
@ -125,7 +127,9 @@ describe('actions', () => {
|
||||
name: 'Jenny Rostock',
|
||||
slug: 'jenny-rostock',
|
||||
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',
|
||||
locale: 'de',
|
||||
},
|
||||
|
||||
@ -820,7 +820,7 @@
|
||||
"@babel/helper-create-regexp-features-plugin" "^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"
|
||||
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.8.7.tgz#1fc7d89c7f75d2d70c2b6768de6c2e049b3cb9db"
|
||||
integrity sha512-BYftCVOdAYJk5ASsznKAUl53EMhfBbr8CJ1X+AJLfGPscQkwJFiaV/Wn9DPH/7fzm2v6iRYJKYHSqyynTGw0nw==
|
||||
@ -1738,7 +1738,7 @@
|
||||
jimp-compact "^0.8.0"
|
||||
workbox-cdn "^4.3.1"
|
||||
|
||||
"@nuxtjs/sentry@^3.3.1":
|
||||
"@nuxtjs/sentry@^3.2.4":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@nuxtjs/sentry/-/sentry-3.3.1.tgz#b3f21851103d5194b9da314a5eec7b154ed50cbe"
|
||||
integrity sha512-o7aGlI7OvaRDT0SsV73Ye1r5sfylKwRGG8EyRClOrhi6dpMIuqcmzgILHIIcHpFkrm61D3sORc7d7rhlHtY6DA==
|
||||
@ -3249,7 +3249,7 @@
|
||||
dependencies:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/@vue/server-test-utils/-/server-test-utils-1.0.0-beta.32.tgz#698424d5d76fea10ee3d2ec45f2416e31681f01e"
|
||||
integrity sha512-1dxJyrO805pr4tyNckAwRojxby3g37IHpmBURInz4yccsiwHsOhSi1tR23HovOocqmu1/NttiI5rHtv9MtL9Ig==
|
||||
@ -3257,7 +3257,7 @@
|
||||
"@types/cheerio" "^0.22.10"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.32.tgz#38c3947886236201a3f24b583c73598eb95ccc69"
|
||||
integrity sha512-ywhe7PATMAk/ZGdsrcuQIliQusOyfe0OOHjKKCCERqgHh1g/kqPtmSMT5Jx4sErx53SYbNucr8QOK6/u5ianAw==
|
||||
@ -7453,7 +7453,7 @@ eslint-plugin-import@~2.20.1:
|
||||
read-pkg-up "^2.0.0"
|
||||
resolve "^1.12.0"
|
||||
|
||||
eslint-plugin-jest@~23.8.2:
|
||||
eslint-plugin-jest@~23.8.1:
|
||||
version "23.8.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.8.2.tgz#6f28b41c67ef635f803ebd9e168f6b73858eb8d4"
|
||||
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"
|
||||
integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw==
|
||||
|
||||
fuse.js@^3.4.6, fuse.js@^3.6.1:
|
||||
fuse.js@^3.4.6:
|
||||
version "3.6.1"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c"
|
||||
integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user