feat: Introduce graphql image type (#3043)

* refactor(graphql): Introduce image type

* Undo changes to .travis.yml

* chore: Upgrade travis to node LTS

- URL is available since v10

* chore: use lts

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

View File

@ -4,33 +4,6 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [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

View File

@ -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",

View File

@ -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')

View File

@ -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')

View File

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

View File

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

View File

@ -389,13 +389,15 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
{
id: 'p0',
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(),
}),
},
),
),

View File

@ -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 },

View File

@ -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',
})
})

View File

@ -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', () => {

View File

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

View File

@ -4,6 +4,12 @@ export default {
id: { type: 'string', primary: true, default: uuid },
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',

View File

@ -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' },

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { 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:

View File

@ -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,
},

View File

@ -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!')

View File

@ -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 },
)

View File

@ -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',

View File

@ -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)',
},

View File

@ -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)
})
})
})

View File

@ -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)

View File

@ -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

View File

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

View File

@ -40,7 +40,6 @@ input _PostFilter {
content_not_starts_with: String
content_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

View File

@ -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

View File

@ -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==

View File

@ -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");
});

View File

@ -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,
};

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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)
})
})
})
})

View File

@ -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,

View File

@ -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')

View File

@ -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) {

View File

@ -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,

View File

@ -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`

View File

@ -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,
},
})

View File

@ -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' },
},
}

View File

@ -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', () => ({

View File

@ -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()

View File

@ -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',
},

View File

@ -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

View File

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

View File

@ -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
}
`

View File

@ -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
}
}
`,

View File

@ -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
}
}
}
`

View File

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

View File

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

View File

@ -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",

View File

@ -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"

View File

@ -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',
},

View File

@ -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"

View File

@ -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 })
})

View File

@ -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 })
})

View File

@ -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
},

View File

@ -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',
},

View File

@ -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==