diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 72cef4093..4bab080ca 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -4,7 +4,7 @@ module.exports = { id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests actorId: { type: 'string', allow: [null] }, name: { type: 'string', disallow: [null], min: 3 }, - slug: 'string', + slug: { type: 'string', regex: /^[a-z0-9_-]+$/, lowercase: true }, encryptedPassword: 'string', avatar: { type: 'string', allow: [null] }, coverImg: { type: 'string', allow: [null] }, diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index e00136970..7c4a26c55 100644 --- a/backend/src/models/User.spec.js +++ b/backend/src/models/User.spec.js @@ -18,3 +18,67 @@ describe('role', () => { ) }) }) + +describe('slug', () => { + it('normalizes to lowercase letters', async () => { + const user = await instance.create('User', { slug: 'Matt' }) + await expect(user.toJson()).resolves.toEqual( + expect.objectContaining({ + slug: 'matt', + }), + ) + }) + + it('must be unique', async done => { + await instance.create('User', { slug: 'Matt' }) + try { + await expect(instance.create('User', { slug: 'Matt' })).rejects.toThrow('already exists') + done() + } catch (error) { + throw new Error(` + ${error} + + Probably your database has no unique constraints! + + To see all constraints go to http://localhost:7474/browser/ and + paste the following: + \`\`\` + CALL db.constraints(); + \`\`\` + + Learn how to setup the database here: + https://docs.human-connection.org/human-connection/neo4j + `) + } + }) + + describe('characters', () => { + const createUser = attrs => { + return instance.create('User', attrs).then(user => user.toJson()) + } + + it('-', async () => { + await expect(createUser({ slug: 'matt-rider' })).resolves.toMatchObject({ + slug: 'matt-rider', + }) + }) + + it('_', async () => { + await expect(createUser({ slug: 'matt_rider' })).resolves.toMatchObject({ + slug: 'matt_rider', + }) + }) + + it(' ', async () => { + await expect(createUser({ slug: 'matt rider' })).rejects.toThrow( + /fails to match the required pattern/, + ) + }) + + it('ä', async () => { + await expect(createUser({ slug: 'mätt' })).rejects.toThrow( + /fails to match the required pattern/, + ) + }) + }) +}) diff --git a/cypress/integration/common/settings.js b/cypress/integration/common/settings.js index b32924f6a..563d6a733 100644 --- a/cypress/integration/common/settings.js +++ b/cypress/integration/common/settings.js @@ -18,6 +18,8 @@ When('I save {string} as my new name', name => { cy.get('[type=submit]') .click() .not('[disabled]') + cy.get('.iziToast-message') + .should('contain', 'Your data was successfully updated') }) When('I save {string} as my location', location => { @@ -28,6 +30,8 @@ When('I save {string} as my location', location => { cy.get('[type=submit]') .click() .not('[disabled]') + cy.get('.iziToast-message') + .should('contain', 'Your data was successfully updated') myLocation = location }) @@ -38,6 +42,8 @@ When('I have the following self-description:', text => { cy.get('[type=submit]') .click() .not('[disabled]') + cy.get('.iziToast-message') + .should('contain', 'Your data was successfully updated') aboutMeText = text }) diff --git a/webapp/components/utils/UniqueSlugForm.js b/webapp/components/utils/UniqueSlugForm.js new file mode 100644 index 000000000..c363fa608 --- /dev/null +++ b/webapp/components/utils/UniqueSlugForm.js @@ -0,0 +1,36 @@ +import { debounce } from 'lodash' +import { checkSlugAvailableQuery } from '~/graphql/User.js' + +export default function UniqueSlugForm({ translate, apollo, currentUser }) { + return { + formSchema: { + slug: [ + { + type: 'string', + required: true, + pattern: /^[a-z0-9_-]+$/, + message: translate('settings.validation.slug.regex'), + }, + { + asyncValidator(rule, value, callback) { + debounce(() => { + const variables = { slug: value } + apollo.query({ query: checkSlugAvailableQuery, variables }).then(response => { + const { + data: { User }, + } = response + const existingSlug = User && User[0] && User[0].slug + const available = !existingSlug || existingSlug === currentUser.slug + if (!available) { + callback(new Error(translate('settings.validation.slug.alreadyTaken'))) + } else { + callback() + } + }) + }, 500)() + }, + }, + ], + }, + } +} diff --git a/webapp/components/utils/UniqueSlugForm.spec.js b/webapp/components/utils/UniqueSlugForm.spec.js new file mode 100644 index 000000000..de0e3fee6 --- /dev/null +++ b/webapp/components/utils/UniqueSlugForm.spec.js @@ -0,0 +1,80 @@ +import UniqueSlugForm from './UniqueSlugForm' +import Schema from 'async-validator' + +let translate +let apollo +let currentUser + +beforeEach(() => { + translate = jest.fn(() => 'Validation error') + apollo = { + query: jest.fn().mockResolvedValue({ data: { User: [] } }), + } + currentUser = null +}) + +describe('UniqueSlugForm', () => { + let validate = object => { + const { formSchema } = UniqueSlugForm({ translate, apollo, currentUser }) + const validator = new Schema(formSchema) + return validator.validate(object, { suppressWarning: true }).catch(({ errors }) => { + throw new Error(errors[0].message) + }) + } + + describe('regex', () => { + describe('non URL-safe characters, e.g. whitespaces', () => { + it('rejects', async () => { + await expect(validate({ slug: 'uh oh' })).rejects.toThrow('Validation error') + }) + }) + + describe('alphanumeric, hyphens or underscores', () => { + it('validates', async () => { + await expect(validate({ slug: '_all-right_' })).resolves.toBeUndefined() + }) + }) + }) + + describe('given a currentUser with a slug', () => { + beforeEach(() => { + currentUser = { slug: 'current-user' } + }) + + describe('backend returns no user for given slug', () => { + beforeEach(() => { + apollo.query.mockResolvedValue({ + data: { User: [] }, + }) + }) + + it('validates', async () => { + await expect(validate({ slug: 'slug' })).resolves.toBeUndefined() + }) + }) + + describe('backend returns user', () => { + let slug + beforeEach(() => { + slug = 'already-taken' + apollo.query.mockResolvedValue({ + data: { User: [{ slug: 'already-taken' }] }, + }) + }) + + it('rejects', async () => { + await expect(validate({ slug: 'uh oh' })).rejects.toThrow('Validation error') + }) + + describe('but it is the current user', () => { + beforeEach(() => { + currentUser = { slug: 'already-taken' } + }) + + it('validates', async () => { + await expect(validate({ slug })).resolves.toBeUndefined() + }) + }) + }) + }) +}) diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 27b3785ae..dbc1997af 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -134,3 +134,11 @@ export const unfollowUserMutation = i18n => { } ` } + +export const checkSlugAvailableQuery = gql` + query($slug: String!) { + User(slug: $slug) { + slug + } + } +` diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 5b83821f5..a784cd5f7 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -151,11 +151,18 @@ "data": { "name": "Deine Daten", "labelName": "Dein Name", + "labelSlug": "Dein eindeutiger Benutzername", "namePlaceholder": "Petra Lustig", "labelCity": "Deine Stadt oder Region", "labelBio": "Über dich", "success": "Deine Daten wurden erfolgreich aktualisiert!" }, + "validation": { + "slug": { + "regex": "Es sind nur Kleinbuchstaben, Zahlen, Unterstriche oder Bindestriche erlaubt.", + "alreadyTaken": "Dieser Benutzername ist schon vergeben." + } + }, "security": { "name": "Sicherheit", "change-password": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 39220d318..502aedd67 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -152,11 +152,18 @@ "data": { "name": "Your data", "labelName": "Your Name", + "labelSlug": "Your unique user name", "namePlaceholder": "Femanon Funny", "labelCity": "Your City or Region", "labelBio": "About You", "success": "Your data was successfully updated!" }, + "validation": { + "slug": { + "regex": "Allowed characters are only lowercase letters, numbers, underscores and hyphens.", + "alreadyTaken": "This user name is already taken." + } + }, "security": { "name": "Security", "change-password": { diff --git a/webapp/package.json b/webapp/package.json index adea8fd6d..c38b9e099 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -98,6 +98,7 @@ "@vue/eslint-config-prettier": "~5.0.0", "@vue/server-test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29", + "async-validator": "^3.1.0", "babel-core": "~7.0.0-bridge.0", "babel-eslint": "~10.0.3", "babel-jest": "~24.9.0", diff --git a/webapp/pages/settings/index.spec.js b/webapp/pages/settings/index.spec.js index f0eff0641..1040f2ad0 100644 --- a/webapp/pages/settings/index.spec.js +++ b/webapp/pages/settings/index.spec.js @@ -24,6 +24,7 @@ describe('index.vue', () => { data: { UpdateUser: { id: 'u1', + slug: 'peter', name: 'Peter', locationName: 'Berlin', about: 'Smth', @@ -37,34 +38,67 @@ describe('index.vue', () => { }, } getters = { - 'auth/user': () => { - return {} - }, + 'auth/user': () => ({}), } }) describe('mount', () => { + let options const Wrapper = () => { store = new Vuex.Store({ getters, }) - return mount(index, { store, mocks, localVue }) + return mount(index, { store, mocks, localVue, ...options }) } + beforeEach(() => { + options = {} + }) + it('renders', () => { expect(Wrapper().contains('div')).toBe(true) }) - describe('given a new username and hitting submit', () => { - it('calls updateUser mutation', () => { + describe('given form validation errors', () => { + beforeEach(() => { + options = { + ...options, + computed: { + formSchema: () => ({ + slug: [ + (_rule, _value, callback) => { + callback(new Error('Ouch!')) + }, + ], + }), + }, + } + }) + + it('cannot call updateUser mutation', () => { const wrapper = Wrapper() - const input = wrapper.find('#name') - const submitForm = wrapper.find('.ds-form') - input.setValue('Peter') - submitForm.trigger('submit') + wrapper.find('#name').setValue('Peter') + wrapper.find('.ds-form').trigger('submit') - expect(mocks.$apollo.mutate).toHaveBeenCalled() + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + }) + + describe('no form validation errors', () => { + beforeEach(() => { + options = { ...options, computed: { formSchema: () => ({}) } } + }) + + describe('given a new username and hitting submit', () => { + it('calls updateUser mutation', () => { + const wrapper = Wrapper() + + wrapper.find('#name').setValue('Peter') + wrapper.find('.ds-form').trigger('submit') + + expect(mocks.$apollo.mutate).toHaveBeenCalled() + }) }) }) }) diff --git a/webapp/pages/settings/index.vue b/webapp/pages/settings/index.vue index 5c99f4b8b..d32d9a91b 100644 --- a/webapp/pages/settings/index.vue +++ b/webapp/pages/settings/index.vue @@ -1,39 +1,49 @@ @@ -42,6 +52,7 @@ import gql from 'graphql-tag' import { mapGetters, mapMutations } from 'vuex' import { CancelToken } from 'axios' +import UniqueSlugForm from '~/components/utils/UniqueSlugForm' let timeout const mapboxToken = process.env.MAPBOX_TOKEN @@ -60,9 +71,10 @@ const query = gql` */ const mutation = gql` - mutation($id: ID!, $name: String, $locationName: String, $about: String) { - UpdateUser(id: $id, name: $name, locationName: $locationName, about: $about) { + mutation($id: ID!, $slug: String, $name: String, $locationName: String, $about: String) { + UpdateUser(id: $id, slug: $slug, name: $name, locationName: $locationName, about: $about) { id + slug name locationName about @@ -84,10 +96,20 @@ export default { ...mapGetters({ currentUser: 'auth/user', }), + formSchema() { + const uniqueSlugForm = UniqueSlugForm({ + apollo: this.$apollo, + currentUser: this.currentUser, + translate: this.$t, + }) + return { + ...uniqueSlugForm.formSchema, + } + }, form: { get: function() { - const { name, locationName, about } = this.currentUser - return { name, locationName, about } + const { name, slug, locationName, about } = this.currentUser + return { name, slug, locationName, about } }, set: function(formData) { this.formData = formData @@ -100,7 +122,7 @@ export default { }), async submit() { this.loadingData = true - const { name, about } = this.formData + const { name, slug, about } = this.formData let { locationName } = this.formData || this.currentUser locationName = locationName && (locationName['label'] || locationName) try { @@ -109,14 +131,16 @@ export default { variables: { id: this.currentUser.id, name, + slug, locationName, about, }, update: (store, { data: { UpdateUser } }) => { - const { name, locationName, about } = UpdateUser + const { name, slug, locationName, about } = UpdateUser this.setCurrentUser({ ...this.currentUser, name, + slug, locationName, about, }) diff --git a/webapp/yarn.lock b/webapp/yarn.lock index ff2ba5dbe..a0d53d690 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -3661,6 +3661,11 @@ async-retry@^1.2.1: dependencies: retry "0.12.0" +async-validator@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-3.1.0.tgz#447db5eb003cbb47e650f040037a29fc3881ce92" + integrity sha512-XyAHGwtpx3Y3aHIOaGXXFo4tiulnrh+mXBU9INxig6Q8rtmtmBxDuCxb60j7EIGbAsQg9cxfJ2jrUZ+fIqEnBQ== + async@^2.1.4: version "2.6.2" resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"