Merge pull request #478 from Human-Connection/2019/kw15/User_can_change_its_username_to_emptystring

2019/kw15/user can change its username to emptystring
This commit is contained in:
Ulf Gebhardt 2019-05-09 15:01:54 +02:00 committed by GitHub
commit 82570bef7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 253 additions and 61 deletions

View File

@ -15,7 +15,7 @@
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
"test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", "test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --",
"test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:cucumber": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
"db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js",

View File

@ -40,10 +40,13 @@ describe('authorization', () => {
}) })
it('does not expose the owner\'s email address', async () => { it('does not expose the owner\'s email address', async () => {
let response = {}
try { try {
await action() await action()
} catch (error) { } catch (error) {
expect(error.response.data).toEqual({ User: [ { email: null } ] }) response = error.response.data
} finally {
expect(response).toEqual({ User: [ null ] })
} }
}) })
}) })
@ -74,11 +77,13 @@ describe('authorization', () => {
}) })
it('does not expose the owner\'s email address', async () => { it('does not expose the owner\'s email address', async () => {
let response
try { try {
await action() await action()
} catch (error) { } catch (error) {
expect(error.response.data).toEqual({ User: [ { email: null } ] }) response = error.response.data
} }
expect(response).toEqual({ User: [ null ] })
}) })
}) })
}) })

View File

@ -1,6 +1,6 @@
import slugify from 'slug' import slugify from 'slug'
export default async function uniqueSlug (string, isUnique) { export default async function uniqueSlug (string, isUnique) {
let slug = slugify(string, { let slug = slugify(string || 'anonymous', {
lower: true lower: true
}) })
if (await isUnique(slug)) return slug if (await isUnique(slug)) return slug

View File

@ -15,4 +15,11 @@ describe('uniqueSlug', () => {
.mockResolvedValueOnce(true) .mockResolvedValueOnce(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1') expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1')
}) })
it('slugify null string', () => {
const string = null
const isUnique = jest.fn()
.mockResolvedValue(true)
expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous')
})
}) })

View File

@ -77,7 +77,7 @@ describe('slugify', () => {
describe('CreateUser', () => { describe('CreateUser', () => {
const action = async (mutation, params) => { const action = async (mutation, params) => {
return authenticatedClient.request(`mutation { return authenticatedClient.request(`mutation {
${mutation}(password: "yo", ${params}) { slug } ${mutation}(password: "yo", email: "123@123.de", ${params}) { slug }
}`) }`)
} }
it('generates a slug based on name', async () => { it('generates a slug based on name', async () => {

View File

@ -1,5 +1,7 @@
import createOrUpdateLocations from './nodes/locations'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { UserInputError } from 'apollo-server'
import createOrUpdateLocations from './nodes/locations'
dotenv.config() dotenv.config()
@ -11,6 +13,10 @@ export default {
return result return result
}, },
UpdateUser: async (resolve, root, args, context, info) => { UpdateUser: async (resolve, root, args, context, info) => {
const USERNAME_MIN_LENGTH = 3 // TODO move to the correct place
if (!args.name || args.name.length < USERNAME_MIN_LENGTH) {
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} characters long!`)
}
const result = await resolve(root, args, context, info) const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver) await createOrUpdateLocations(args.id, args.locationName, context.driver)
return result return result

View File

@ -0,0 +1,81 @@
import { GraphQLClient } from 'graphql-request'
import { host } from '../jest/helpers'
import Factory from '../seed/factories'
const factory = Factory()
let client
afterAll(async () => {
await factory.cleanDatabase()
})
describe('userMiddleware', () => {
describe('create User', () => {
const mutation = `
mutation($id: ID, $password: String!, $email: String!) {
CreateUser(id: $id, password: $password, email: $email) {
id
}
}
`
client = new GraphQLClient(host)
it('with password and email', async () => {
const variables = {
password: '123',
email: '123@123.de'
}
const expected = {
CreateUser: {
id: expect.any(String)
}
}
await expect(client.request(mutation, variables))
.resolves.toEqual(expected)
})
})
describe('update User', () => {
const mutation = `
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
}
}
`
client = new GraphQLClient(host)
// TODO why is this failing - it returns { UpdateUser: null } - that should not be
/* it('name within specifications', async () => {
const variables = {
id: 'u1',
name: 'Peter Lustig'
}
const expected = {
UpdateUser: {
name: 'Peter Lustig'
}
}
await expect(client.request(mutation, variables))
.resolves.toEqual(expected)
}) */
it('with no name', async () => {
const variables = {
id: 'u1'
}
const expected = 'Username must be at least 3 characters long!'
await expect(client.request(mutation, variables))
.rejects.toThrow(expected)
})
it('with too short name', async () => {
const variables = {
id: 'u1'
}
const expected = 'Username must be at least 3 characters long!'
await expect(client.request(mutation, variables))
.rejects.toThrow(expected)
})
})
})

View File

@ -339,7 +339,7 @@ describe('do not expose private RSA key', () => {
email: 'apfel-strudel@test.org' email: 'apfel-strudel@test.org'
} }
await client.request(gql` await client.request(gql`
mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String) { mutation($id: ID, $password: String!, $slug: String, $name: String, $email: String!) {
CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) { CreateUser(id: $id, password: $password, slug: $slug, name: $name, email: $email) {
id id
} }

View File

@ -95,7 +95,7 @@ type User {
id: ID! id: ID!
actorId: String actorId: String
name: String name: String
email: String email: String!
slug: String slug: String
password: String! password: String!
avatar: String avatar: String

View File

@ -20,7 +20,7 @@ export default function create (params) {
$name: String $name: String
$slug: String $slug: String
$password: String! $password: String!
$email: String $email: String!
$avatar: String $avatar: String
$about: String $about: String
$role: UserGroupEnum $role: UserGroupEnum

View File

@ -51,8 +51,8 @@ describe('User', () => {
it('renders anonymous user', () => { it('renders anonymous user', () => {
const wrapper = Wrapper() const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton') expect(wrapper.text()).toBe('')
expect(wrapper.text()).toMatch('Anonymus') expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
}) })
describe('given an user', () => { describe('given an user', () => {
@ -65,7 +65,7 @@ describe('User', () => {
it('renders user name', () => { it('renders user name', () => {
const wrapper = Wrapper() const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous') expect(mocks.$t).not.toHaveBeenCalledWith('profile.userAnonym')
expect(wrapper.text()).toMatch('Tilda Swinton') expect(wrapper.text()).toMatch('Tilda Swinton')
}) })
@ -77,7 +77,7 @@ describe('User', () => {
it('renders anonymous user', () => { it('renders anonymous user', () => {
const wrapper = Wrapper() const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton') expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(wrapper.text()).toMatch('Anonymus') expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
}) })
describe('current user is a moderator', () => { describe('current user is a moderator', () => {

View File

@ -12,7 +12,7 @@
<b <b
class="username" class="username"
style="vertical-align: middle;" style="vertical-align: middle;"
>Anonymus</b> >{{ $t('profile.userAnonym') }}</b>
</div> </div>
</div> </div>
<dropdown <dropdown
@ -38,7 +38,7 @@
> >
<ds-avatar <ds-avatar
:image="user.avatar" :image="user.avatar"
:name="user.name" :name="userName"
style="display: inline-block; vertical-align: middle;" style="display: inline-block; vertical-align: middle;"
size="32px" size="32px"
/> />
@ -47,7 +47,7 @@
<b <b
class="username" class="username"
style="vertical-align: middle;" style="vertical-align: middle;"
>{{ user.name | truncate(trunc, 18) }}</b> >{{ userName | truncate(18) }}</b>
</div> </div>
<!-- Time --> <!-- Time -->
<div <div
@ -141,8 +141,8 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import HcRelativeDateTime from '~/components/RelativeDateTime' import HcRelativeDateTime from '~/components/RelativeDateTime'
import HcFollowButton from '~/components/FollowButton.vue' import HcFollowButton from '~/components/FollowButton'
import HcBadges from '~/components/Badges.vue' import HcBadges from '~/components/Badges'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
export default { export default {
@ -173,6 +173,10 @@ export default {
const { id, slug } = this.user const { id, slug } = this.user
if (!(id && slug)) return '' if (!(id && slug)) return ''
return { name: 'profile-id-slug', params: { slug, id } } return { name: 'profile-id-slug', params: { slug, id } }
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
} }
} }
} }

View File

@ -61,7 +61,8 @@
slot-scope="{closeMenu}" slot-scope="{closeMenu}"
> >
<div class="avatar-menu-popover"> <div class="avatar-menu-popover">
{{ $t('login.hello') }} <b>{{ user.name }}</b> {{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'"> <template v-if="user.role !== 'user'">
<ds-text <ds-text
color="softer" color="softer"
@ -83,7 +84,8 @@
:parents="item.parents" :parents="item.parents"
@click.native="closeMenu(false)" @click.native="closeMenu(false)"
> >
<ds-icon :name="item.route.icon" /> {{ item.route.name }} <ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item> </ds-menu-item>
</ds-menu> </ds-menu>
<hr> <hr>
@ -91,7 +93,8 @@
class="logout-link" class="logout-link"
:to="{ name: 'logout'}" :to="{ name: 'logout'}"
> >
<ds-icon name="sign-out" /> {{ $t('login.logout') }} <ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link> </nuxt-link>
</div> </div>
</template> </template>
@ -146,6 +149,10 @@ export default {
quickSearchResults: 'search/quickResults', quickSearchResults: 'search/quickResults',
quickSearchPending: 'search/quickPending' quickSearchPending: 'search/quickPending'
}), }),
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
routes() { routes() {
if (!this.user.slug) { if (!this.user.slug) {
return [] return []

View File

@ -21,6 +21,7 @@
"following": "Folgt", "following": "Folgt",
"shouted": "Empfohlen", "shouted": "Empfohlen",
"commented": "Kommentiert", "commented": "Kommentiert",
"userAnonym": "Anonymus",
"socialMedia": "Wo sonst finde ich" "socialMedia": "Wo sonst finde ich"
}, },
"notifications": { "notifications": {
@ -38,8 +39,10 @@
"data": { "data": {
"name": "Deine Daten", "name": "Deine Daten",
"labelName": "Dein Name", "labelName": "Dein Name",
"namePlaceholder": "Petra Lustig",
"labelCity": "Deine Stadt oder Region", "labelCity": "Deine Stadt oder Region",
"labelBio": "Über dich" "labelBio": "Über dich",
"success": "Deine Daten wurden erfolgreich aktualisiert!"
}, },
"security": { "security": {
"name": "Sicherheit", "name": "Sicherheit",
@ -224,4 +227,4 @@
"shoutButton": { "shoutButton": {
"shouted": "empfohlen" "shouted": "empfohlen"
} }
} }

View File

@ -21,6 +21,7 @@
"following": "Following", "following": "Following",
"shouted": "Shouted", "shouted": "Shouted",
"commented": "Commented", "commented": "Commented",
"userAnonym": "Anonymous",
"socialMedia": "Where else can I find" "socialMedia": "Where else can I find"
}, },
"notifications": { "notifications": {
@ -38,8 +39,10 @@
"data": { "data": {
"name": "Your data", "name": "Your data",
"labelName": "Your Name", "labelName": "Your Name",
"namePlaceholder": "Femanon Funny",
"labelCity": "Your City or Region", "labelCity": "Your City or Region",
"labelBio": "About You" "labelBio": "About You",
"success": "Your data was successfully updated!"
}, },
"security": { "security": {
"name": "Security", "name": "Security",
@ -224,4 +227,4 @@
"shoutButton": { "shoutButton": {
"shouted": "shouted" "shouted": "shouted"
} }
} }

View File

@ -15,13 +15,15 @@
"followers": "Seguenti", "followers": "Seguenti",
"following": "Seguendo", "following": "Seguendo",
"shouted": "Gridato", "shouted": "Gridato",
"commented": "Commentato" "commented": "Commentato",
"userAnonym": "Anonymous"
}, },
"settings": { "settings": {
"name": "Impostazioni", "name": "Impostazioni",
"data": { "data": {
"name": "I tuoi dati", "name": "I tuoi dati",
"labelName": "Nome", "labelName": "Nome",
"namePlaceholder": "Anonymous",
"labelCity": "La tua città o regione", "labelCity": "La tua città o regione",
"labelBio": "Su di te" "labelBio": "Su di te"
}, },

View File

@ -15,13 +15,15 @@
"followers": "Obserwujący", "followers": "Obserwujący",
"following": "Obserwowani", "following": "Obserwowani",
"shouted": "Krzyknij", "shouted": "Krzyknij",
"commented": "Skomentuj" "commented": "Skomentuj",
"userAnonym": "Anonymous"
}, },
"settings": { "settings": {
"name": "Ustawienia", "name": "Ustawienia",
"data": { "data": {
"name": "Twoje dane", "name": "Twoje dane",
"labelName": "Twoje dane", "labelName": "Twoje dane",
"namePlaceholder": "Anonymous",
"labelCity": "Twoje miasto lub region", "labelCity": "Twoje miasto lub region",
"labelBio": "O Tobie" "labelBio": "O Tobie"
}, },

View File

@ -15,13 +15,15 @@
"followers": "Seguidores", "followers": "Seguidores",
"following": "Seguindo", "following": "Seguindo",
"shouted": "Aclamou", "shouted": "Aclamou",
"commented": "Comentou" "commented": "Comentou",
"userAnonym": "Anonymous"
}, },
"settings": { "settings": {
"name": "Configurações", "name": "Configurações",
"data": { "data": {
"name": "Seus dados", "name": "Seus dados",
"labelName": "Seu nome", "labelName": "Seu nome",
"namePlaceholder": "Anonymous",
"labelCity": "Sua cidade ou estado", "labelCity": "Sua cidade ou estado",
"labelBio": "Sobre você" "labelBio": "Sobre você"
}, },

View File

@ -16,7 +16,7 @@
> >
<ds-avatar <ds-avatar
:image="user.avatar" :image="user.avatar"
:name="user.name || 'Anonymus'" :name="userName"
class="profile-avatar" class="profile-avatar"
size="120px" size="120px"
/> />
@ -35,7 +35,7 @@
align="center" align="center"
no-margin no-margin
> >
{{ user.name }} {{ userName }}
</ds-heading> </ds-heading>
<ds-text <ds-text
v-if="user.location" v-if="user.location"
@ -123,7 +123,7 @@
tag="h5" tag="h5"
color="soft" color="soft"
> >
Wem folgt {{ user.name | truncate(15) }}? Wem folgt {{ userName | truncate(15) }}?
</ds-text> </ds-text>
</ds-space> </ds-space>
<template v-if="user.following && user.following.length"> <template v-if="user.following && user.following.length">
@ -154,7 +154,7 @@
</template> </template>
<template v-else> <template v-else>
<p style="text-align: center; opacity: .5;"> <p style="text-align: center; opacity: .5;">
{{ user.name }} folgt niemandem {{ userName }} folgt niemandem
</p> </p>
</template> </template>
</ds-card> </ds-card>
@ -168,7 +168,7 @@
tag="h5" tag="h5"
color="soft" color="soft"
> >
Wer folgt {{ user.name | truncate(15) }}? Wer folgt {{ userName | truncate(15) }}?
</ds-text> </ds-text>
</ds-space> </ds-space>
<template v-if="user.followedBy && user.followedBy.length"> <template v-if="user.followedBy && user.followedBy.length">
@ -199,18 +199,16 @@
</template> </template>
<template v-else> <template v-else>
<p style="text-align: center; opacity: .5;"> <p style="text-align: center; opacity: .5;">
niemand folgt {{ user.name }} niemand folgt {{ userName }}
</p> </p>
</template> </template>
</ds-card> </ds-card>
<ds-space <ds-space
v-if="user.socialMedia && user.socialMedia.length" v-if="user.socialMedia && user.socialMedia.length"
margin="large" margin="large"
> >
<ds-card style="position: relative; height: auto;"> <ds-card style="position: relative; height: auto;">
<ds-space <ds-space margin="x-small">
margin="x-small"
>
<ds-text <ds-text
tag="h5" tag="h5"
color="soft" color="soft"
@ -224,9 +222,7 @@
margin="x-small" margin="x-small"
> >
<a :href="link.url"> <a :href="link.url">
<ds-avatar <ds-avatar :image="link.favicon" />
:image="link.favicon"
/>
{{ link.username }} {{ link.username }}
</a> </a>
</ds-space> </ds-space>
@ -393,6 +389,10 @@ export default {
const username = url.split('/').pop() const username = url.split('/').pop()
return { url, username, favicon } return { url, username, favicon }
}) })
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
} }
}, },
watch: { watch: {

View File

@ -0,0 +1,73 @@
import { mount, createLocalVue } from '@vue/test-utils'
import index from './index.vue'
import Vue from 'vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('index.vue', () => {
let Wrapper
let store
let mocks
let getters
beforeEach(() => {
mocks = {
$t: jest.fn(),
$apollo: {
mutate: jest
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: {
UpdateUser: {
id: 'u1',
name: 'Peter',
locationName: 'Berlin',
about: 'Smth'
}
}
})
},
$toast: {
error: jest.fn(),
success: jest.fn()
}
}
getters = {
'auth/user': () => {
return {}
}
}
})
describe('mount', () => {
const Wrapper = () => {
store = new Vuex.Store({
getters
})
return mount(index, { store, mocks, localVue })
}
it('renders', () => {
expect(Wrapper().contains('div')).toBe(true)
})
describe('given a new username and hitting submit', () => {
it('calls updateUser mutation', () => {
const wrapper = Wrapper()
const input = wrapper.find('#name')
const submitForm = wrapper.find('.ds-form')
input.setValue('Peter')
submitForm.trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
})
})
})

View File

@ -9,7 +9,7 @@
model="name" model="name"
icon="user" icon="user"
:label="$t('settings.data.labelName')" :label="$t('settings.data.labelName')"
:placeholder="$t('settings.data.labelName')" :placeholder="$t('settings.data.namePlaceholder')"
/> />
<!-- eslint-disable vue/use-v-on-exact --> <!-- eslint-disable vue/use-v-on-exact -->
<ds-select <ds-select
@ -19,7 +19,7 @@
:options="cities" :options="cities"
:label="$t('settings.data.labelCity')" :label="$t('settings.data.labelCity')"
:placeholder="$t('settings.data.labelCity')" :placeholder="$t('settings.data.labelCity')"
:loading="loading" :loading="loadingGeo"
@input.native="handleCityInput" @input.native="handleCityInput"
/> />
<!-- eslint-enable vue/use-v-on-exact --> <!-- eslint-enable vue/use-v-on-exact -->
@ -36,7 +36,7 @@
style="float: right;" style="float: right;"
icon="check" icon="check"
type="submit" type="submit"
:loading="sending" :loading="loadingData"
primary primary
> >
{{ $t('actions.save') }} {{ $t('actions.save') }}
@ -88,8 +88,8 @@ export default {
return { return {
axiosSource: null, axiosSource: null,
cities: [], cities: [],
sending: false, loadingData: false,
loading: false, loadingGeo: false,
formData: {} formData: {}
} }
}, },
@ -111,13 +111,13 @@ export default {
...mapMutations({ ...mapMutations({
setCurrentUser: 'auth/SET_USER' setCurrentUser: 'auth/SET_USER'
}), }),
submit() { async submit() {
this.sending = true this.loadingData = true
const { name, about } = this.formData const { name, about } = this.formData
let { locationName } = this.formData let { locationName } = this.formData
locationName = locationName && (locationName['label'] || locationName) locationName = locationName && (locationName['label'] || locationName)
this.$apollo try {
.mutate({ const { data } = await this.$apollo.mutate({
mutation, mutation,
variables: { variables: {
id: this.currentUser.id, id: this.currentUser.id,
@ -135,15 +135,12 @@ export default {
}) })
} }
}) })
.then(data => { this.$toast.success(this.$t('settings.data.success'))
this.$toast.success('Updated user') } catch (err) {
}) this.$toast.error(err.message)
.catch(err => { } finally {
this.$toast.error(err.message) this.loadingData = false
}) }
.finally(() => {
this.sending = false
})
}, },
handleCityInput(value) { handleCityInput(value) {
clearTimeout(timeout) clearTimeout(timeout)
@ -181,7 +178,7 @@ export default {
return return
} }
this.loading = true this.loadingGeo = true
this.axiosSource = CancelToken.source() this.axiosSource = CancelToken.source()
const place = encodeURIComponent(value) const place = encodeURIComponent(value)
@ -198,7 +195,7 @@ export default {
this.cities = this.processCityResults(res) this.cities = this.processCityResults(res)
}) })
.finally(() => { .finally(() => {
this.loading = false this.loadingGeo = false
}) })
} }
} }