This commit is contained in:
Wolfgang Huß 2021-01-22 11:21:19 +01:00
commit 9dbe8bd257
28 changed files with 588 additions and 366 deletions

View File

@ -69,6 +69,7 @@
"helmet": "~3.22.0",
"ioredis": "^4.16.1",
"jsonwebtoken": "~8.5.1",
"languagedetect": "^2.0.0",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.7",

View File

@ -5,6 +5,7 @@ import { hashSync } from 'bcryptjs'
import { Factory } from 'rosie'
import { getDriver, getNeode } from './neo4j'
import CONFIG from '../config/index.js'
import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js'
const neode = getNeode()
@ -205,7 +206,7 @@ const emailDefaults = {
}
Factory.define('emailAddress')
.attr(emailDefaults)
.attrs(emailDefaults)
.after((buildObject, options) => {
return neode.create('EmailAddress', buildObject)
})
@ -216,6 +217,28 @@ Factory.define('unverifiedEmailAddress')
return neode.create('UnverifiedEmailAddress', buildObject)
})
const inviteCodeDefaults = {
code: () => generateInviteCode(),
createdAt: () => new Date().toISOString(),
expiresAt: () => null,
}
Factory.define('inviteCode')
.attrs(inviteCodeDefaults)
.option('generatedById', null)
.option('generatedBy', ['generatedById'], (generatedById) => {
if (generatedById) return neode.find('User', generatedById)
return Factory.build('user')
})
.after(async (buildObject, options) => {
const [inviteCode, generatedBy] = await Promise.all([
neode.create('InviteCode', buildObject),
options.generatedBy,
])
await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
return inviteCode
})
Factory.define('location')
.attrs({
name: 'Germany',

View File

@ -541,6 +541,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
),
])
await Factory.build(
'inviteCode',
{
code: 'AAAAAA',
},
{
generatedBy: jennyRostock,
},
)
authenticatedUser = await louie.toJson()
const mention1 =
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?'

View File

@ -14,6 +14,7 @@ import notifications from './notifications/notificationsMiddleware'
import hashtags from './hashtags/hashtagsMiddleware'
import email from './email/emailMiddleware'
import sentry from './sentryMiddleware'
import languages from './languages/languages'
export default (schema) => {
const middlewares = {
@ -30,6 +31,7 @@ export default (schema) => {
softDelete,
includedFields,
orderBy,
languages,
}
let order = [
@ -39,6 +41,7 @@ export default (schema) => {
// 'activityPub', disabled temporarily
'validation',
'sluggify',
'languages',
'excerpt',
'email',
'notifications',

View File

@ -0,0 +1,28 @@
import LanguageDetect from 'languagedetect'
import sanitizeHtml from 'sanitize-html'
const removeHtmlTags = (input) => {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
})
}
const setPostLanguage = (text) => {
const lngDetector = new LanguageDetect()
lngDetector.setLanguageType('iso2')
return lngDetector.detect(removeHtmlTags(text), 1)[0][0]
}
export default {
Mutation: {
CreatePost: async (resolve, root, args, context, info) => {
args.language = await setPostLanguage(args.content)
return resolve(root, args, context, info)
},
UpdatePost: async (resolve, root, args, context, info) => {
args.language = await setPostLanguage(args.content)
return resolve(root, args, context, info)
},
},
}

View File

@ -0,0 +1,132 @@
import Factory, { cleanDatabase } from '../../db/factories'
import { gql } from '../../helpers/jest'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
let mutate
let authenticatedUser
let variables
const driver = getDriver()
const neode = getNeode()
beforeAll(async () => {
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
})
const createPostMutation = gql`
mutation($title: String!, $content: String!, $categoryIds: [ID]) {
CreatePost(title: $title, content: $content, categoryIds: $categoryIds) {
language
}
}
`
describe('languagesMiddleware', () => {
variables = {
title: 'Test post languages',
categoryIds: ['cat9'],
}
beforeAll(async () => {
await cleanDatabase()
const user = await Factory.build('user')
authenticatedUser = await user.toJson()
await Factory.build('category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
})
it('detects German', async () => {
variables = {
...variables,
content: 'Jeder sollte vor seiner eigenen Tür kehren.',
}
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
language: 'de',
},
},
})
})
it('detects English', async () => {
variables = {
...variables,
content: 'A journey of a thousand miles begins with a single step.',
}
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
language: 'en',
},
},
})
})
it('detects Spanish', async () => {
variables = {
...variables,
content: 'A caballo regalado, no le mires el diente.',
}
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
language: 'es',
},
},
})
})
it('detects German in between lots of html tags', async () => {
variables = {
...variables,
content:
'<strong>Jeder</strong> <strike>sollte</strike> <strong>vor</strong> <span>seiner</span> eigenen <blockquote>Tür</blockquote> kehren.',
}
await expect(
mutate({
mutation: createPostMutation,
variables,
}),
).resolves.toMatchObject({
data: {
CreatePost: {
language: 'de',
},
},
})
})
})

View File

@ -106,6 +106,8 @@ export default shield(
notifications: isAuthenticated,
Donations: isAuthenticated,
userData: isAuthenticated,
MyInviteCodes: isAuthenticated,
isValidInviteCode: allow,
},
Mutation: {
'*': deny,
@ -149,6 +151,7 @@ export default shield(
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
GenerateInviteCode: isAuthenticated,
},
User: {
email: or(isMyOwn, isAdmin),

View File

@ -2,6 +2,7 @@ import slugify from 'slug'
export default async function uniqueSlug(string, isUnique) {
const slug = slugify(string || 'anonymous', {
lower: true,
multicharmap: { Ä: 'AE', ä: 'ae', Ö: 'OE', ö: 'oe', Ü: 'UE', ü: 'ue', ß: 'ss' },
})
if (await isUnique(slug)) return slug

View File

@ -18,4 +18,16 @@ describe('uniqueSlug', () => {
const isUnique = jest.fn().mockResolvedValue(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
})
it('Converts umlaut to a two letter equivalent', async () => {
const umlaut = 'ÄÖÜäöüß'
const isUnique = jest.fn().mockResolvedValue(true)
await expect(uniqueSlug(umlaut, isUnique)).resolves.toEqual('aeoeueaeoeuess')
})
it('Removes Spanish enya and diacritics', async () => {
const diacritics = 'áàéèíìóòúùñçÁÀÉÈÍÌÓÒÚÙÑÇ'
const isUnique = jest.fn().mockResolvedValue(true)
await expect(uniqueSlug(diacritics, isUnique)).resolves.toEqual('aaeeiioouuncaaeeiioouunc')
})
})

View File

@ -2,8 +2,6 @@ import { UserInputError } from 'apollo-server'
const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three'
const USERNAME_MIN_LENGTH = 3
const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
@ -46,20 +44,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
return resolve(root, args, context, info)
}
const validatePost = async (resolve, root, args, context, info) => {
const { categoryIds } = args
if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) {
throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE)
}
return resolve(root, args, context, info)
}
const validateUpdatePost = async (resolve, root, args, context, info) => {
const { categoryIds } = args
if (typeof categoryIds === 'undefined') return resolve(root, args, context, info)
return validatePost(resolve, root, args, context, info)
}
const validateReport = async (resolve, root, args, context, info) => {
const { resourceId } = args
const { user } = context
@ -138,8 +122,6 @@ export default {
Mutation: {
CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment,
CreatePost: validatePost,
UpdatePost: validateUpdatePost,
UpdateUser: validateUpdateUser,
fileReport: validateReport,
review: validateReview,

View File

@ -30,27 +30,7 @@ const updateCommentMutation = gql`
}
}
`
const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) {
CreatePost(
id: $id
title: $title
content: $content
language: $language
categoryIds: $categoryIds
) {
id
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(
@ -227,104 +207,6 @@ describe('validateCreateComment', () => {
})
})
})
describe('validatePost', () => {
let createPostVariables
beforeEach(async () => {
createPostVariables = {
title: 'I am a title',
content: 'Some content',
}
authenticatedUser = await commentingUser.toJson()
})
describe('categories', () => {
describe('null', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: null }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('empty', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: [] }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('more than 3 categoryIds', () => {
it('throws UserInputError', async () => {
createPostVariables = {
...createPostVariables,
categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'],
}
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
})
})
describe('validateUpdatePost', () => {
describe('post created without categories somehow', () => {
let owner, updatePostVariables
beforeEach(async () => {
const postSomehowCreated = await neode.create('Post', {
id: 'how-was-this-created',
})
owner = await neode.create('User', {
id: 'author-of-post-without-category',
slug: 'hacker',
})
await postSomehowCreated.relateTo(owner, 'author')
authenticatedUser = await owner.toJson()
updatePostVariables = {
id: 'how-was-this-created',
title: 'I am a title',
content: 'Some content',
categoryIds: [],
}
})
it('requires at least one category for successful update', async () => {
await expect(
mutate({ mutation: updatePostMutation, variables: updatePostVariables }),
).resolves.toMatchObject({
data: { UpdatePost: null },
errors: [
{ message: 'You cannot save a post without at least one category or more than three' },
],
})
})
})
})
})
describe('validateReport', () => {

View File

@ -1,16 +1,17 @@
export default {
code: { type: 'string', primary: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
token: { type: 'string', primary: true, token: true },
generatedBy: {
expiresAt: { type: 'string', isoDate: true, default: null },
generated: {
type: 'relationship',
relationship: 'GENERATED',
target: 'User',
direction: 'in',
},
activated: {
redeemed: {
type: 'relationship',
relationship: 'ACTIVATED',
target: 'EmailAddress',
direction: 'out',
relationship: 'REDEEMED',
target: 'User',
direction: 'in',
},
}

View File

@ -100,6 +100,18 @@ export default {
target: 'User',
direction: 'in',
},
inviteCodes: {
type: 'relationship',
relationship: 'GENERATED',
target: 'InviteCode',
direction: 'out',
},
redeemedInviteCode: {
type: 'relationship',
relationship: 'REDEEMED',
target: 'InviteCode',
direction: 'out',
},
termsAndConditionsAgreedVersion: {
type: 'string',
allow: [null],

View File

@ -15,4 +15,5 @@ export default {
Donations: require('./Donations.js').default,
Report: require('./Report.js').default,
Migration: require('./Migration.js').default,
InviteCode: require('./InviteCode.js').default,
}

View File

@ -0,0 +1,8 @@
export default function generateInviteCode() {
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => {
// n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
// else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
return String.fromCharCode(n > 9 ? n + 55 : n + 48)
}).join('')
}

View File

@ -0,0 +1,109 @@
import generateInviteCode from './helpers/generateInviteCode'
import Resolver from './helpers/Resolver'
const uniqueInviteCode = async (session, code) => {
return session.readTransaction(async (txc) => {
const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, {
code,
})
return parseInt(String(result.records[0].get('count'))) === 0
})
}
export default {
Query: {
MyInviteCodes: async (_parent, args, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const result = await txc.run(
`MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
RETURN properties(ic) AS inviteCodes`,
{
userId,
},
)
return result.records.map((record) => record.get('inviteCodes'))
})
try {
const txResult = await readTxResultPromise
return txResult
} finally {
session.close()
}
},
isValidInviteCode: async (_parent, args, context, _resolveInfo) => {
const { code } = args
if (!code) return false
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const result = await txc.run(
`MATCH (ic:InviteCode { code: toUpper($code) })
RETURN
CASE
WHEN ic.expiresAt IS NULL THEN true
WHEN datetime(ic.expiresAt) >= datetime() THEN true
ELSE false END AS result`,
{
code,
},
)
return result.records.map((record) => record.get('result'))
})
try {
const txResult = await readTxResultPromise
return !!txResult[0]
} finally {
session.close()
}
},
},
Mutation: {
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
let code = generateInviteCode()
while (!(await uniqueInviteCode(session, code))) {
code = generateInviteCode()
}
const writeTxResultPromise = session.writeTransaction(async (txc) => {
const result = await txc.run(
`MATCH (user:User {id: $userId})
MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
ON CREATE SET
ic.createdAt = toString(datetime()),
ic.expiresAt = $expiresAt
RETURN ic AS inviteCode`,
{
userId,
code,
expiresAt: args.expiresAt,
},
)
return result.records.map((record) => record.get('inviteCode').properties)
})
try {
const txResult = await writeTxResultPromise
return txResult[0]
} finally {
session.close()
}
},
},
InviteCode: {
...Resolver('InviteCode', {
idAttribute: 'code',
undefinedToNull: ['expiresAt'],
hasOne: {
generatedBy: '<-[:GENERATED]-(related:User)',
},
hasMany: {
redeemedBy: '<-[:REDEEMED]-(related:User)',
},
}),
},
}

View File

@ -0,0 +1,200 @@
import Factory, { cleanDatabase } from '../../db/factories'
import { getDriver } from '../../db/neo4j'
import { gql } from '../../helpers/jest'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
let user
let query
let mutate
const driver = getDriver()
const generateInviteCodeMutation = gql`
mutation($expiresAt: String = null) {
GenerateInviteCode(expiresAt: $expiresAt) {
code
createdAt
expiresAt
}
}
`
const myInviteCodesQuery = gql`
query {
MyInviteCodes {
code
createdAt
expiresAt
}
}
`
const isValidInviteCodeQuery = gql`
query($code: ID!) {
isValidInviteCode(code: $code)
}
`
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
user,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
})
describe('inviteCodes', () => {
describe('as unauthenticated user', () => {
it('cannot generate invite codes', async () => {
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
expect.objectContaining({
extensions: { code: 'INTERNAL_SERVER_ERROR' },
}),
]),
data: {
GenerateInviteCode: null,
},
}),
)
})
it('cannot query invite codes', async () => {
await expect(query({ query: myInviteCodesQuery })).resolves.toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
expect.objectContaining({
extensions: { code: 'INTERNAL_SERVER_ERROR' },
}),
]),
data: {
MyInviteCodes: null,
},
}),
)
})
})
describe('as authenticated user', () => {
beforeAll(async () => {
const authenticatedUser = await Factory.build(
'user',
{
role: 'user',
},
{
email: 'user@example.org',
password: '1234',
},
)
user = await authenticatedUser.toJson()
})
it('generates an invite code without expiresAt', async () => {
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
expect.objectContaining({
errors: undefined,
data: {
GenerateInviteCode: {
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
expiresAt: null,
createdAt: expect.any(String),
},
},
}),
)
})
it('generates an invite code with expiresAt', async () => {
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + 7)
await expect(
mutate({
mutation: generateInviteCodeMutation,
variables: { expiresAt: nextWeek.toISOString() },
}),
).resolves.toEqual(
expect.objectContaining({
errors: undefined,
data: {
GenerateInviteCode: {
code: expect.stringMatching(/^[0-9A-Z]{6,6}$/),
expiresAt: nextWeek.toISOString(),
createdAt: expect.any(String),
},
},
}),
)
})
let inviteCodes
it('returns the created invite codes when queried', async () => {
const response = await query({ query: myInviteCodesQuery })
inviteCodes = response.data.MyInviteCodes
expect(inviteCodes).toHaveLength(2)
})
it('does not return the created invite codes of other users when queried', async () => {
await Factory.build('inviteCode')
const response = await query({ query: myInviteCodesQuery })
inviteCodes = response.data.MyInviteCodes
expect(inviteCodes).toHaveLength(2)
})
it('validates an invite code without expiresAt', async () => {
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
const result = await query({
query: isValidInviteCodeQuery,
variables: { code: unExpiringInviteCode },
})
expect(result.data.isValidInviteCode).toBeTruthy()
})
it('validates an invite code in lower case', async () => {
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
const result = await query({
query: isValidInviteCodeQuery,
variables: { code: unExpiringInviteCode.toLowerCase() },
})
expect(result.data.isValidInviteCode).toBeTruthy()
})
it('validates an invite code with expiresAt in the future', async () => {
const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code
const result = await query({
query: isValidInviteCodeQuery,
variables: { code: expiringInviteCode },
})
expect(result.data.isValidInviteCode).toBeTruthy()
})
it('does not validate an invite code which expired in the past', async () => {
const lastWeek = new Date()
lastWeek.setDate(lastWeek.getDate() - 7)
const inviteCode = await Factory.build('inviteCode', {
expiresAt: lastWeek.toISOString(),
})
const code = inviteCode.get('code')
const result = await query({ query: isValidInviteCodeQuery, variables: { code } })
expect(result.data.isValidInviteCode).toBeFalsy()
})
it('does not validate an invite code which does not exits', async () => {
const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } })
expect(result.data.isValidInviteCode).toBeFalsy()
})
})
})

View File

@ -76,7 +76,6 @@ export default {
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { image: imageInput } = params
delete params.categoryIds
delete params.image
@ -92,13 +91,9 @@ export default {
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
RETURN post {.*}
`,
{ userId: context.user.id, categoryIds, params },
{ userId: context.user.id, params },
)
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) {

View File

@ -317,19 +317,6 @@ describe('CreatePost', () => {
expected,
)
})
describe('language', () => {
beforeEach(() => {
variables = { ...variables, language: 'es' }
})
it('allows a user to set the language of the post', async () => {
const expected = { data: { CreatePost: { language: 'es' } } }
await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
})
})

View File

@ -293,6 +293,7 @@ export default {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
invitedBy: '<-[:INVITED]-(related:User)',
location: '-[:IS_IN]->(related:Location)',
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
@ -304,6 +305,7 @@ export default {
shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
},
}),
},

View File

@ -0,0 +1,17 @@
type InviteCode {
code: ID!
createdAt: String!
generatedBy: User @relation(name: "GENERATED", direction: "IN")
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
expiresAt: String
}
type Mutation {
GenerateInviteCode(expiresAt: String = null): InviteCode
}
type Query {
MyInviteCodes: [InviteCode]
isValidInviteCode(code: ID!): Boolean
}

View File

@ -136,7 +136,7 @@ type Post {
"""
)
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
commentsCount: Int!

View File

@ -56,6 +56,9 @@ type User {
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT")
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(
statement: """
@ -83,7 +86,7 @@ type User {
RETURN COUNT(user) >= 1
"""
)
# contributions: [WrittenPost]!
# contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
# @cypher(
@ -104,7 +107,7 @@ type User {
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")

View File

@ -6302,6 +6302,11 @@ knuth-shuffle-seeded@^1.0.6:
dependencies:
seed-random "~2.2.0"
languagedetect@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/languagedetect/-/languagedetect-2.0.0.tgz#4b8fa2b7593b2a3a02fb1100891041c53238936c"
integrity sha512-AZb/liiQ+6ZoTj4f1J0aE6OkzhCo8fyH+tuSaPfSo8YHCWLFJrdSixhtO2TYdIkjcDQNaR4RmGaV2A5FJklDMQ==
latest-version@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"

View File

@ -1,10 +1,8 @@
import { config, mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import MutationObserver from 'mutation-observer'
@ -17,45 +15,8 @@ config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
const categories = [
{
id: 'cat3',
slug: 'health-wellbeing',
icon: 'medkit',
},
{
id: 'cat12',
slug: 'it-internet-data-privacy',
icon: 'mouse-pointer',
},
{
id: 'cat9',
slug: 'democracy-politics',
icon: 'university',
},
{
id: 'cat15',
slug: 'consumption-sustainability',
icon: 'shopping-cart',
},
{
id: 'cat4',
slug: 'environment-nature',
icon: 'tree',
},
]
describe('ContributionForm.vue', () => {
let wrapper,
postTitleInput,
expectedParams,
cancelBtn,
mocks,
propsData,
categoryIds,
englishLanguage,
deutschLanguage,
dataPrivacyButton
let wrapper, postTitleInput, expectedParams, cancelBtn, mocks, propsData
const postTitle = 'this is a title for a post'
const postTitleTooShort = 'xx'
let postTitleTooLong = ''
@ -82,8 +43,6 @@ describe('ContributionForm.vue', () => {
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
categoryIds,
},
},
}),
@ -136,18 +95,9 @@ describe('ContributionForm.vue', () => {
describe('CreatePost', () => {
describe('invalid form submission', () => {
beforeEach(async () => {
wrapper.find(CategoriesSelect).setData({ categories })
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContent)
englishLanguage = wrapper
.findAll('li')
.filter((language) => language.text() === 'English')
englishLanguage.trigger('click')
dataPrivacyButton = await wrapper
.find(CategoriesSelect)
.find('[data-test="category-buttons-cat12"]')
dataPrivacyButton.trigger('click')
})
it('title cannot be empty', async () => {
@ -173,22 +123,6 @@ describe('ContributionForm.vue', () => {
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('has at least one category', async () => {
dataPrivacyButton = await wrapper
.find(CategoriesSelect)
.find('[data-test="category-buttons-cat12"]')
dataPrivacyButton.trigger('click')
wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('has no more than three categories', async () => {
wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27']
await Vue.nextTick()
wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
})
describe('valid form submission', () => {
@ -198,43 +132,20 @@ describe('ContributionForm.vue', () => {
variables: {
title: postTitle,
content: postContent,
language: 'en',
id: null,
categoryIds: ['cat12'],
image: null,
},
}
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find(CategoriesSelect).setData({ categories })
englishLanguage = wrapper
.findAll('li')
.filter((language) => language.text() === 'English')
englishLanguage.trigger('click')
await Vue.nextTick()
dataPrivacyButton = await wrapper
.find(CategoriesSelect)
.find('[data-test="category-buttons-cat12"]')
dataPrivacyButton.trigger('click')
await Vue.nextTick()
})
it('creates a post with valid title, content, and at least one category', async () => {
it('creates a post with valid title and content', async () => {
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports changing the language', async () => {
expectedParams.variables.language = 'de'
deutschLanguage = wrapper
.findAll('li')
.filter((language) => language.text() === 'Deutsch')
deutschLanguage.trigger('click')
wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports adding a teaser image', async () => {
expectedParams.variables.image = {
aspectRatio: null,
@ -292,18 +203,6 @@ describe('ContributionForm.vue', () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContent)
categoryIds = ['cat12']
wrapper.find(CategoriesSelect).setData({ categories })
englishLanguage = wrapper
.findAll('li')
.filter((language) => language.text() === 'English')
englishLanguage.trigger('click')
await Vue.nextTick()
dataPrivacyButton = await wrapper
.find(CategoriesSelect)
.find('[data-test="category-buttons-cat12"]')
dataPrivacyButton.trigger('click')
await Vue.nextTick()
})
it('shows an error toaster when apollo mutation rejects', async () => {
@ -322,14 +221,7 @@ describe('ContributionForm.vue', () => {
slug: 'dies-ist-ein-post',
title: 'dies ist ein Post',
content: 'auf Deutsch geschrieben',
language: 'de',
image,
categories: [
{
id: 'cat12',
name: 'Democracy & Politics',
},
],
},
}
wrapper = Wrapper()
@ -352,8 +244,6 @@ describe('ContributionForm.vue', () => {
slug: 'this-is-a-title-for-a-post',
content: postContent,
contentExcerpt: postContent,
language: 'en',
categoryIds,
},
},
})
@ -363,9 +253,7 @@ describe('ContributionForm.vue', () => {
variables: {
title: propsData.contribution.title,
content: propsData.contribution.content,
language: propsData.contribution.language,
id: propsData.contribution.id,
categoryIds: ['cat12'],
image: {
sensitive: false,
},
@ -380,18 +268,6 @@ describe('ContributionForm.vue', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports updating categories', async () => {
expectedParams.variables.categoryIds.push('cat3')
wrapper.find(CategoriesSelect).setData({ categories })
await Vue.nextTick()
const healthWellbeingButton = await wrapper
.find(CategoriesSelect)
.find('[data-test="category-buttons-cat3"]')
healthWellbeingButton.trigger('click')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
})
it('supports deleting a teaser image', async () => {
expectedParams.variables.image = null
propsData.contribution.image = { url: '/uploads/someimage.png' }

View File

@ -50,22 +50,6 @@
{{ contentLength }}
<base-icon v-if="errors && errors.content" name="warning" />
</ds-chip>
<categories-select model="categoryIds" :existingCategoryIds="formData.categoryIds" />
<ds-chip size="base" :color="errors && errors.categoryIds && 'danger'">
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<ds-select
model="language"
icon="globe"
class="select-field"
:options="languageOptions"
:placeholder="$t('contribution.languageSelectText')"
:label="$t('contribution.languageSelectLabel')"
/>
<ds-chip v-if="errors && errors.language" size="base" color="danger">
<base-icon name="warning" />
</ds-chip>
<div class="buttons">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()" danger>
{{ $t('actions.cancel') }}
@ -81,19 +65,15 @@
<script>
import gql from 'graphql-tag'
import orderBy from 'lodash/orderBy'
import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor'
import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/ImageUploader/ImageUploader'
import links from '~/constants/links.js'
export default {
components: {
HcEditor,
CategoriesSelect,
ImageUploader,
},
props: {
@ -103,11 +83,7 @@ export default {
},
},
data() {
const { title, content, image, language, categories } = this.contribution
const languageOptions = orderBy(locales, 'name').map((locale) => {
return { label: locale.name, value: locale.code }
})
const { title, content, image } = this.contribution
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {}
return {
@ -118,26 +94,12 @@ export default {
image: image || null,
imageAspectRatio,
imageBlurred,
language: languageOptions.find((option) => option.value === language) || null,
categoryIds: categories ? categories.map((category) => category.id) : [],
},
formSchema: {
title: { required: true, min: 3, max: 100 },
content: { required: true },
categoryIds: {
type: 'array',
required: true,
validator: (_, value = []) => {
if (value.length === 0 || value.length > 3) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
language: { required: true },
imageBlurred: { required: false },
},
languageOptions,
loading: false,
users: [],
hashtags: [],
@ -155,7 +117,7 @@ export default {
methods: {
submit() {
let image = null
const { title, content, categoryIds } = this.formData
const { title, content } = this.formData
if (this.formData.image) {
image = {
sensitive: this.formData.imageBlurred,
@ -172,9 +134,7 @@ export default {
variables: {
title,
content,
categoryIds,
id: this.contribution.id || null,
language: this.formData.language.value,
image,
},
})

View File

@ -3,20 +3,8 @@ import gql from 'graphql-tag'
export default () => {
return {
CreatePost: gql`
mutation(
$title: String!
$content: String!
$language: String
$categoryIds: [ID]
$image: ImageInput
) {
CreatePost(
title: $title
content: $content
language: $language
categoryIds: $categoryIds
image: $image
) {
mutation($title: String!, $content: String!, $categoryIds: [ID], $image: ImageInput) {
CreatePost(title: $title, content: $content, categoryIds: $categoryIds, image: $image) {
title
slug
content
@ -34,7 +22,6 @@ export default () => {
$id: ID!
$title: String!
$content: String!
$language: String
$image: ImageInput
$categoryIds: [ID]
) {
@ -42,7 +29,6 @@ export default () => {
id: $id
title: $title
content: $content
language: $language
image: $image
categoryIds: $categoryIds
) {

View File

@ -46,21 +46,6 @@
<content-viewer class="content hyphenate-text" :content="post.content" />
<!-- eslint-enable vue/no-v-html -->
<ds-space margin="xx-large" />
<!-- Categories -->
<div class="categories">
<ds-space margin="xx-small" />
<hc-category
v-for="category in post.categories"
:key="category.id"
:icon="category.icon"
:name="$t(`contribution.category.name.${category.slug}`)"
/>
<!-- Post language -->
<ds-tag v-if="post.language" class="category-tag language">
<base-icon name="globe" />
{{ post.language.toUpperCase() }}
</ds-tag>
</div>
<ds-space margin-bottom="small" />
<!-- Tags -->
<div v-if="post.tags && post.tags.length" class="tags">
@ -110,7 +95,6 @@
<script>
import ContentViewer from '~/components/Editor/ContentViewer'
import HcCategory from '~/components/Category'
import HcHashtag from '~/components/Hashtag/Hashtag'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
@ -134,7 +118,6 @@ export default {
mode: 'out-in',
},
components: {
HcCategory,
HcHashtag,
UserTeaser,
HcShoutButton,