diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0739729b5..e9762b4bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -528,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 53 + min_coverage: 54 token: ${{ github.token }} ########################################################################## diff --git a/backend/src/graphql/directive/isAuthorized.ts b/backend/src/graphql/directive/isAuthorized.ts index aa407c95f..159a1614c 100644 --- a/backend/src/graphql/directive/isAuthorized.ts +++ b/backend/src/graphql/directive/isAuthorized.ts @@ -13,35 +13,33 @@ import { ServerUser } from '@entity/ServerUser' const isAuthorized: AuthChecker = async ({ context }, rights) => { context.role = ROLE_UNAUTHORIZED // unauthorized user + // is rights an inalienable right? + if ((rights).reduce((acc, right) => acc && INALIENABLE_RIGHTS.includes(right), true)) + return true + // Do we have a token? - if (context.token) { - // Decode the token - const decoded = decode(context.token) - if (!decoded) { - // Are all rights requested public? - const isInalienable = (rights).reduce( - (acc, right) => acc && INALIENABLE_RIGHTS.includes(right), - true, - ) - if (isInalienable) { - // If public dont throw and permit access - return true - } else { - // Throw on a protected route - throw new Error('403.13 - Client certificate revoked') - } - } - // Set context pubKey - context.pubKey = Buffer.from(decoded.pubKey).toString('hex') - // set new header token - // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests - // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey - const userRepository = await getCustomRepository(UserRepository) + if (!context.token) { + throw new Error('401 Unauthorized') + } + + // Decode the token + const decoded = decode(context.token) + if (!decoded) { + throw new Error('403.13 - Client certificate revoked') + } + // Set context pubKey + context.pubKey = Buffer.from(decoded.pubKey).toString('hex') + + // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests + // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey + const userRepository = await getCustomRepository(UserRepository) + try { const user = await userRepository.findByPubkeyHex(context.pubKey) const countServerUsers = await ServerUser.count({ email: user.email }) context.role = countServerUsers > 0 ? ROLE_ADMIN : ROLE_USER - - context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) + } catch { + // in case the database query fails (user deleted) + throw new Error('401 Unauthorized') } // check for correct rights @@ -50,6 +48,8 @@ const isAuthorized: AuthChecker = async ({ context }, rights) => { throw new Error('401 Unauthorized') } + // set new header token + context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) }) return true } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index fd0936b9a..947636aa4 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { testEnvironment, createUser, headerPushMock, cleanDB } from '@test/helpers' +import { testEnvironment, createUser, headerPushMock, cleanDB, resetToken } from '@test/helpers' import { createUserMutation, setPasswordMutation } from '@test/graphql' import gql from 'graphql-tag' import { GraphQLError } from 'graphql' @@ -31,6 +31,24 @@ jest.mock('@/apis/KlicktippController', () => { let mutate: any, query: any, con: any +const loginQuery = gql` + query ($email: String!, $password: String!, $publisherId: Int) { + login(email: $email, password: $password, publisherId: $publisherId) { + email + firstName + lastName + language + coinanimation + klickTipp { + newsletterState + } + hasElopage + publisherId + isAdmin + } + } +` + beforeAll(async () => { const testEnv = await testEnvironment() mutate = testEnv.mutate @@ -284,24 +302,6 @@ describe('UserResolver', () => { }) describe('login', () => { - const loginQuery = gql` - query ($email: String!, $password: String!, $publisherId: Int) { - login(email: $email, password: $password, publisherId: $publisherId) { - email - firstName - lastName - language - coinanimation - klickTipp { - newsletterState - } - hasElopage - publisherId - isAdmin - } - } - ` - const variables = { email: 'peter@lustig.de', password: 'Aa12345_', @@ -328,7 +328,7 @@ describe('UserResolver', () => { }) }) - describe('user is in database', () => { + describe('user is in database and correct login data', () => { beforeAll(async () => { await createUser(mutate, { email: 'peter@lustig.de', @@ -370,5 +370,81 @@ describe('UserResolver', () => { expect(headerPushMock).toBeCalledWith({ key: 'token', value: expect.any(String) }) }) }) + + describe('user is in database and wrong password', () => { + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns an error', () => { + expect( + query({ query: loginQuery, variables: { ...variables, password: 'wrong' } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No user with this credentials')], + }), + ) + }) + }) + }) + + describe('logout', () => { + const logoutQuery = gql` + query { + logout + } + ` + + describe('unauthenticated', () => { + it('throws an error', async () => { + resetToken() + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + const variables = { + email: 'peter@lustig.de', + password: 'Aa12345_', + } + + beforeAll(async () => { + await createUser(mutate, { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + }) + await query({ query: loginQuery, variables }) + }) + + afterAll(async () => { + await cleanDB() + }) + + it('returns true', async () => { + await expect(query({ query: logoutQuery })).resolves.toEqual( + expect.objectContaining({ + data: { logout: 'true' }, + errors: undefined, + }), + ) + }) + }) }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index d36dc3918..8b34bfc5d 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -369,6 +369,8 @@ export class UserResolver { /{code}/g, emailOptIn.verificationCode.toString(), ) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName, @@ -376,11 +378,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Account confirmation link: ${activationLink}`) } + */ await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() @@ -410,6 +414,7 @@ export class UserResolver { emailOptIn.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ link: activationLink, firstName: user.firstName, @@ -417,11 +422,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Account confirmation link: ${activationLink}`) } + */ await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() @@ -446,6 +453,7 @@ export class UserResolver { optInCode.verificationCode.toString(), ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmail({ link, firstName: user.firstName, @@ -453,11 +461,13 @@ export class UserResolver { email, }) + /* uncomment this, when you need the activation link on the console // In case EMails are disabled log the activation link for the user if (!emailSent) { // eslint-disable-next-line no-console console.log(`Reset password link: ${link}`) } + */ return true } @@ -547,7 +557,9 @@ export class UserResolver { } catch { // TODO is this a problem? // eslint-disable-next-line no-console + /* uncomment this, when you need the activation link on the console console.log('Could not subscribe to klicktipp') + */ } } diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 1048b16b7..edb4eb3e4 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -3,18 +3,18 @@ import { createTestClient } from 'apollo-server-testing' import createServer from '../src/server/createServer' -import { resetDB, initialize } from '@dbTools/helpers' +import { initialize } from '@dbTools/helpers' import { createUserMutation, setPasswordMutation } from './graphql' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import { entities } from '@entity/index' -let token = '' - -export const headerPushMock = jest.fn((t) => (token = t.value)) +export const headerPushMock = jest.fn((t) => { + context.token = t.value +}) const context = { - token, + token: '', setHeaders: { push: headerPushMock, forEach: jest.fn(), @@ -35,12 +35,11 @@ export const testEnvironment = async () => { const mutate = testClient.mutate const query = testClient.query await initialize() - await resetDB() return { mutate, query, con } } export const resetEntity = async (entity: any) => { - const items = await entity.find() + const items = await entity.find({ withDeleted: true }) if (items.length > 0) { const ids = items.map((i: any) => i.id) await entity.delete(ids) @@ -48,13 +47,18 @@ export const resetEntity = async (entity: any) => { } export const createUser = async (mutate: any, user: any) => { + // resetToken() await mutate({ mutation: createUserMutation, variables: user }) const dbUser = await User.findOne({ where: { email: user.email } }) if (!dbUser) throw new Error('Ups, no user found') - const optin = await LoginEmailOptIn.findOne(dbUser.id) + const optin = await LoginEmailOptIn.findOne({ where: { userId: dbUser.id } }) if (!optin) throw new Error('Ups, no optin found') await mutate({ mutation: setPasswordMutation, variables: { password: 'Aa12345_', code: optin.verificationCode }, }) } + +export const resetToken = () => { + context.token = '' +} diff --git a/frontend/src/assets/scss/gradido.scss b/frontend/src/assets/scss/gradido.scss index abb491206..53a296713 100644 --- a/frontend/src/assets/scss/gradido.scss +++ b/frontend/src/assets/scss/gradido.scss @@ -94,6 +94,11 @@ border-color: #5e72e4; } +.gradido-font-large { + font-size: large; + height: auto !important; +} + a, .copyright { color: #5a7b02; @@ -201,10 +206,6 @@ a, background-color: #fff; } -.gradido-font-large { - font-size: large; -} - .gradido-font-15rem { font-size: 1.5rem; } diff --git a/frontend/src/components/ClipboardCopy.vue b/frontend/src/components/ClipboardCopy.vue new file mode 100644 index 000000000..b952dbd8c --- /dev/null +++ b/frontend/src/components/ClipboardCopy.vue @@ -0,0 +1,37 @@ + + diff --git a/frontend/src/components/GddSend.vue b/frontend/src/components/GddSend.vue index cfab0899a..e0d7c6eba 100644 --- a/frontend/src/components/GddSend.vue +++ b/frontend/src/components/GddSend.vue @@ -1,18 +1,26 @@ diff --git a/frontend/src/components/GddSend/TransactionConfirmationLink.spec.js b/frontend/src/components/GddSend/TransactionConfirmationLink.spec.js new file mode 100644 index 000000000..a28c2d185 --- /dev/null +++ b/frontend/src/components/GddSend/TransactionConfirmationLink.spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import TransactionConfirmationLink from './TransactionConfirmationLink' + +const localVue = global.localVue + +describe('GddSend confirm', () => { + let wrapper + + const mocks = { + $t: jest.fn((t) => t), + $i18n: { + locale: jest.fn(() => 'en'), + }, + } + + const propsData = { + balance: 1234, + email: 'user@example.org', + amount: 12.34, + memo: 'Pessimisten stehen im Regen, Optimisten duschen unter den Wolken.', + loading: false, + selected: 'send', + } + + const Wrapper = () => { + return mount(TransactionConfirmationLink, { localVue, mocks, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the component div.transaction-confirm-link', () => { + expect(wrapper.find('div.transaction-confirm-link').exists()).toBeTruthy() + }) + + describe('has selected "link"', () => { + beforeEach(async () => { + await wrapper.setProps({ + selected: 'link', + }) + }) + + it('renders the component div.confirm-box-link', () => { + expect(wrapper.findAll('div.confirm-box-link').at(0).exists()).toBeTruthy() + }) + }) + }) +}) diff --git a/frontend/src/components/GddSend/TransactionConfirmationLink.vue b/frontend/src/components/GddSend/TransactionConfirmationLink.vue new file mode 100644 index 000000000..18eb0f25d --- /dev/null +++ b/frontend/src/components/GddSend/TransactionConfirmationLink.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/GddSend/TransactionConfirmationSend.spec.js b/frontend/src/components/GddSend/TransactionConfirmationSend.spec.js new file mode 100644 index 000000000..38dd82866 --- /dev/null +++ b/frontend/src/components/GddSend/TransactionConfirmationSend.spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import TransactionConfirmationSend from './TransactionConfirmationSend' + +const localVue = global.localVue + +describe('GddSend confirm', () => { + let wrapper + + const mocks = { + $t: jest.fn((t) => t), + $i18n: { + locale: jest.fn(() => 'en'), + }, + } + + const propsData = { + balance: 1234, + email: 'user@example.org', + amount: 12.34, + memo: 'Pessimisten stehen im Regen, Optimisten duschen unter den Wolken.', + loading: false, + selected: 'send', + } + + const Wrapper = () => { + return mount(TransactionConfirmationSend, { localVue, mocks, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the component div.transaction-confirm-send', () => { + expect(wrapper.find('div.transaction-confirm-send').exists()).toBeTruthy() + }) + + describe('has selected "send"', () => { + beforeEach(async () => { + await wrapper.setProps({ + selected: 'send', + }) + }) + + it('renders the component div.confirm-box-send', () => { + expect(wrapper.find('div.confirm-box-send').exists()).toBeTruthy() + }) + }) + }) +}) diff --git a/frontend/src/components/GddSend/TransactionConfirmation.vue b/frontend/src/components/GddSend/TransactionConfirmationSend.vue similarity index 75% rename from frontend/src/components/GddSend/TransactionConfirmation.vue rename to frontend/src/components/GddSend/TransactionConfirmationSend.vue index 4acb95dcd..e05d42269 100644 --- a/frontend/src/components/GddSend/TransactionConfirmation.vue +++ b/frontend/src/components/GddSend/TransactionConfirmationSend.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/GddSend/TransactionForm.spec.js b/frontend/src/components/GddSend/TransactionForm.spec.js index 25683e6df..49b2174e0 100644 --- a/frontend/src/components/GddSend/TransactionForm.spec.js +++ b/frontend/src/components/GddSend/TransactionForm.spec.js @@ -55,180 +55,203 @@ describe('GddSend', () => { }) }) - describe('transaction form', () => { - beforeEach(() => { - wrapper.setProps({ balance: 100.0 }) - }) - describe('transaction form show because balance 100,0 GDD', () => { - it('has no warning message ', () => { - expect(wrapper.find('.text-danger').exists()).toBeFalsy() - }) - it('has a reset button', () => { - expect(wrapper.find('.test-buttons').findAll('button').at(0).attributes('type')).toBe( - 'reset', - ) - }) - it('has a submit button', () => { - expect(wrapper.find('.test-buttons').findAll('button').at(1).attributes('type')).toBe( - 'submit', - ) - }) + describe('is selected: "send"', () => { + beforeEach(async () => { + // await wrapper.setData({ + // selected: 'send', + // }) + await wrapper.findAll('input[type="radio"]').at(0).setChecked() }) - describe('email field', () => { - it('has an input field of type email', () => { - expect(wrapper.find('#input-group-1').find('input').attributes('type')).toBe('email') + describe('transaction form', () => { + beforeEach(() => { + wrapper.setProps({ balance: 100.0 }) + }) + describe('transaction form show because balance 100,0 GDD', () => { + it('has no warning message ', () => { + expect(wrapper.find('.errors').exists()).toBeFalsy() + }) + it('has a reset button', () => { + expect(wrapper.find('.test-buttons').findAll('button').at(0).attributes('type')).toBe( + 'reset', + ) + }) + it('has a submit button', () => { + expect(wrapper.find('.test-buttons').findAll('button').at(1).attributes('type')).toBe( + 'submit', + ) + }) }) - it('has an envelope icon', () => { - expect(wrapper.find('#input-group-1').find('svg').attributes('aria-label')).toBe( - 'envelope', - ) + describe('email field', () => { + it('has an input field of type email', () => { + expect(wrapper.find('#input-group-1').find('input').attributes('type')).toBe('email') + }) + + it('has an envelope icon', () => { + expect(wrapper.find('#input-group-1').find('svg').attributes('aria-label')).toBe( + 'envelope', + ) + }) + + it('has a label form.receiver', () => { + expect(wrapper.find('label.input-1').text()).toBe('form.recipient') + }) + + it('has a placeholder "E-Mail"', () => { + expect(wrapper.find('#input-group-1').find('input').attributes('placeholder')).toBe( + 'E-Mail', + ) + }) + + it('flushes an error message when no valid email is given', async () => { + await wrapper.find('#input-group-1').find('input').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('validations.messages.email') + }) + + it('trims the email after blur', async () => { + await wrapper.find('#input-group-1').find('input').setValue(' valid@email.com ') + await wrapper.find('#input-group-1').find('input').trigger('blur') + await flushPromises() + expect(wrapper.vm.form.email).toBe('valid@email.com') + }) }) - it('has a label form.receiver', () => { - expect(wrapper.find('label.input-1').text()).toBe('form.recipient') + describe('amount field', () => { + it('has an input field of type text', () => { + expect(wrapper.find('#input-group-2').find('input').attributes('type')).toBe('text') + }) + + it('has an GDD text icon', () => { + expect(wrapper.find('#input-group-2').find('div.m-1').text()).toBe('GDD') + }) + + it('has a label form.amount', () => { + expect(wrapper.find('label.input-2').text()).toBe('form.amount') + }) + + it('has a placeholder "0.01"', () => { + expect(wrapper.find('#input-group-2').find('input').attributes('placeholder')).toBe( + '0.01', + ) + }) + + it('does not update form amount when invalid', async () => { + await wrapper.find('#input-group-2').find('input').setValue('invalid') + await wrapper.find('#input-group-2').find('input').trigger('blur') + await flushPromises() + expect(wrapper.vm.form.amountValue).toBe(0) + }) + + it('flushes an error message when no valid amount is given', async () => { + await wrapper.find('#input-group-2').find('input').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') + }) + + it('flushes an error message when amount is too high', async () => { + await wrapper.find('#input-group-2').find('input').setValue('123.34') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') + }) + + it('flushes no errors when amount is valid', async () => { + await wrapper.find('#input-group-2').find('input').setValue('87.34') + await flushPromises() + expect(wrapper.find('span.errors').exists()).toBeFalsy() + }) }) - it('has a placeholder "E-Mail"', () => { - expect(wrapper.find('#input-group-1').find('input').attributes('placeholder')).toBe( - 'E-Mail', - ) + describe('message text box', () => { + it('has an textarea field', () => { + expect(wrapper.find('#input-group-3').find('textarea').exists()).toBeTruthy() + }) + + it('has an chat-right-text icon', () => { + expect(wrapper.find('#input-group-3').find('svg').attributes('aria-label')).toBe( + 'chat right text', + ) + }) + + it('has a label form.message', () => { + expect(wrapper.find('label.input-3').text()).toBe('form.message') + }) + + it('flushes an error message when memo is less than 5 characters', async () => { + await wrapper.find('#input-group-3').find('textarea').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('validations.messages.min') + }) + + it('flushes no error message when memo is valid', async () => { + await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') + await flushPromises() + expect(wrapper.find('span.errors').exists()).toBeFalsy() + }) }) - it('flushes an error message when no valid email is given', async () => { - await wrapper.find('#input-group-1').find('input').setValue('a') - await flushPromises() - expect(wrapper.find('span.errors').text()).toBe('validations.messages.email') + describe('cancel button', () => { + it('has a cancel button', () => { + expect(wrapper.find('button[type="reset"]').exists()).toBeTruthy() + }) + + it('has the text "form.cancel"', () => { + expect(wrapper.find('button[type="reset"]').text()).toBe('form.reset') + }) + + it('clears all fields on click', async () => { + await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') + await wrapper.find('#input-group-2').find('input').setValue('87.23') + await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') + await flushPromises() + expect(wrapper.vm.form.email).toBe('someone@watches.tv') + expect(wrapper.vm.form.amount).toBe('87.23') + expect(wrapper.vm.form.memo).toBe('Long enough') + await wrapper.find('button[type="reset"]').trigger('click') + await flushPromises() + expect(wrapper.vm.form.email).toBe('') + expect(wrapper.vm.form.amount).toBe('') + expect(wrapper.vm.form.memo).toBe('') + }) }) - it('trims the email after blur', async () => { - await wrapper.find('#input-group-1').find('input').setValue(' valid@email.com ') - await wrapper.find('#input-group-1').find('input').trigger('blur') - await flushPromises() - expect(wrapper.vm.form.email).toBe('valid@email.com') + describe('submit', () => { + beforeEach(async () => { + await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') + await wrapper.find('#input-group-2').find('input').setValue('87.23') + await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') + await wrapper.find('form').trigger('submit') + await flushPromises() + }) + + it('emits set-transaction', async () => { + expect(wrapper.emitted('set-transaction')).toBeTruthy() + expect(wrapper.emitted('set-transaction')).toEqual([ + [ + { + email: 'someone@watches.tv', + amount: 87.23, + memo: 'Long enough', + selected: 'send', + }, + ], + ]) + }) }) }) + }) - describe('amount field', () => { - it('has an input field of type text', () => { - expect(wrapper.find('#input-group-2').find('input').attributes('type')).toBe('text') - }) - - it('has an GDD text icon', () => { - expect(wrapper.find('#input-group-2').find('div.m-1').text()).toBe('GDD') - }) - - it('has a label form.amount', () => { - expect(wrapper.find('label.input-2').text()).toBe('form.amount') - }) - - it('has a placeholder "0.01"', () => { - expect(wrapper.find('#input-group-2').find('input').attributes('placeholder')).toBe( - '0.01', - ) - }) - - it('does not update form amount when invalid', async () => { - await wrapper.find('#input-group-2').find('input').setValue('invalid') - await wrapper.find('#input-group-2').find('input').trigger('blur') - await flushPromises() - expect(wrapper.vm.form.amountValue).toBe(0) - }) - - it('flushes an error message when no valid amount is given', async () => { - await wrapper.find('#input-group-2').find('input').setValue('a') - await flushPromises() - expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') - }) - - it('flushes an error message when amount is too high', async () => { - await wrapper.find('#input-group-2').find('input').setValue('123.34') - await flushPromises() - expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') - }) - - it('flushes no errors when amount is valid', async () => { - await wrapper.find('#input-group-2').find('input').setValue('87.34') - await flushPromises() - expect(wrapper.find('span.errors').exists()).toBeFalsy() - }) + describe('is selected: "link"', () => { + beforeEach(async () => { + // await wrapper.setData({ + // selected: 'link', + // }) + await wrapper.findAll('input[type="radio"]').at(1).setChecked() }) - describe('message text box', () => { - it('has an textarea field', () => { - expect(wrapper.find('#input-group-3').find('textarea').exists()).toBeTruthy() - }) - - it('has an chat-right-text icon', () => { - expect(wrapper.find('#input-group-3').find('svg').attributes('aria-label')).toBe( - 'chat right text', - ) - }) - - it('has a label form.message', () => { - expect(wrapper.find('label.input-3').text()).toBe('form.message') - }) - - it('flushes an error message when memo is less than 5 characters', async () => { - await wrapper.find('#input-group-3').find('textarea').setValue('a') - await flushPromises() - expect(wrapper.find('span.errors').text()).toBe('validations.messages.min') - }) - - it('flushes no error message when memo is valid', async () => { - await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') - await flushPromises() - expect(wrapper.find('span.errors').exists()).toBeFalsy() - }) - }) - - describe('cancel button', () => { - it('has a cancel button', () => { - expect(wrapper.find('button[type="reset"]').exists()).toBeTruthy() - }) - - it('has the text "form.cancel"', () => { - expect(wrapper.find('button[type="reset"]').text()).toBe('form.reset') - }) - - it('clears all fields on click', async () => { - await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') - await wrapper.find('#input-group-2').find('input').setValue('87.23') - await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') - await flushPromises() - expect(wrapper.vm.form.email).toBe('someone@watches.tv') - expect(wrapper.vm.form.amount).toBe('87.23') - expect(wrapper.vm.form.memo).toBe('Long enough') - await wrapper.find('button[type="reset"]').trigger('click') - await flushPromises() - expect(wrapper.vm.form.email).toBe('') - expect(wrapper.vm.form.amount).toBe('') - expect(wrapper.vm.form.memo).toBe('') - }) - }) - - describe('submit', () => { - beforeEach(async () => { - await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') - await wrapper.find('#input-group-2').find('input').setValue('87.23') - await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') - await wrapper.find('form').trigger('submit') - await flushPromises() - }) - - it('emits set-transaction', async () => { - expect(wrapper.emitted('set-transaction')).toBeTruthy() - expect(wrapper.emitted('set-transaction')).toEqual([ - [ - { - email: 'someone@watches.tv', - amount: 87.23, - memo: 'Long enough', - }, - ], - ]) - }) + it('has no input field of id input-group-1', () => { + expect(wrapper.find('#input-group-1').isVisible()).toBeFalsy() }) }) }) diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index 850bb3b07..991e165ce 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -2,26 +2,39 @@ - - + + + + {{ $t('send_gdd') }} + + + + + {{ $t('send_per_link') }} + + + +
+

{{ $t('gdd_per_link.header') }}

+
+ {{ $t('gdd_per_link.sentence_1') }} +
+
- +
-
{{ $t('form.no_gdd_available') }} @@ -125,7 +137,7 @@ - {{ $t('form.send_now') }} + {{ selected === sendTypes.send ? $t('form.send_now') : $t('form.generate_now') }} @@ -138,16 +150,13 @@ diff --git a/frontend/src/components/GddSend/TransactionResultLink.vue b/frontend/src/components/GddSend/TransactionResultLink.vue new file mode 100644 index 000000000..66b5529d6 --- /dev/null +++ b/frontend/src/components/GddSend/TransactionResultLink.vue @@ -0,0 +1,33 @@ + + diff --git a/frontend/src/components/GddSend/TransactionResult.vue b/frontend/src/components/GddSend/TransactionResultSendError.vue similarity index 70% rename from frontend/src/components/GddSend/TransactionResult.vue rename to frontend/src/components/GddSend/TransactionResultSendError.vue index 64a6749bd..ba6b4a238 100644 --- a/frontend/src/components/GddSend/TransactionResult.vue +++ b/frontend/src/components/GddSend/TransactionResultSendError.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/GddTransactionList.spec.js b/frontend/src/components/GddTransactionList.spec.js index 320fd094f..ab8f3658c 100644 --- a/frontend/src/components/GddTransactionList.spec.js +++ b/frontend/src/components/GddTransactionList.spec.js @@ -19,7 +19,7 @@ describe('GddTransactionList', () => { $t: jest.fn((t) => t), $d: jest.fn((d) => d), $i18n: { - locale: () => 'de', + locale: () => 'en', }, } @@ -96,13 +96,13 @@ describe('GddTransactionList', () => { typeId: 'DECAY', amount: '-0.16778637075575395772595', balance: '31.59320453982945549519405', - balanceDate: '2022-03-03T08:54:54.020Z', + balanceDate: '2022-03-03T08:54:54', memo: '', linkedUser: null, decay: { decay: '-0.16778637075575395772595', - start: '2022-02-28T13:55:47.000Z', - end: '2022-03-03T08:54:54.020Z', + start: '2022-02-28T13:55:47', + end: '2022-03-03T08:54:54', duration: 241147.02, }, }, @@ -111,7 +111,7 @@ describe('GddTransactionList', () => { typeId: 'SEND', amount: '1', balance: '31.76099091058520945292', - balanceDate: '2022-02-28T13:55:47.000Z', + balanceDate: '2022-02-28T13:55:47', memo: 'adasd adada', linkedUser: { firstName: 'Bibi', @@ -119,8 +119,8 @@ describe('GddTransactionList', () => { }, decay: { decay: '-0.2038314055482643084', - start: '2022-02-25T07:29:26.000Z', - end: '2022-02-28T13:55:47.000Z', + start: '2022-02-25T07:29:26', + end: '2022-02-28T13:55:47', duration: 282381, }, }, @@ -129,7 +129,7 @@ describe('GddTransactionList', () => { typeId: 'CREATION', amount: '1000', balance: '32.96482231613347376132', - balanceDate: '2022-02-25T07:29:26.000Z', + balanceDate: '2022-02-25T07:29:26', memo: 'asd adada dad', linkedUser: { firstName: 'Gradido', @@ -137,8 +137,8 @@ describe('GddTransactionList', () => { }, decay: { decay: '-0.03517768386652623868', - start: '2022-02-23T10:55:30.000Z', - end: '2022-02-25T07:29:26.000Z', + start: '2022-02-23T10:55:30', + end: '2022-02-25T07:29:26', duration: 160436, }, }, @@ -147,7 +147,7 @@ describe('GddTransactionList', () => { typeId: 'RECEIVE', amount: '10', balance: '10', - balanceDate: '2022-02-23T10:55:30.000Z', + balanceDate: '2022-02-23T10:55:30', memo: 'asd adaaad adad addad ', linkedUser: { firstName: 'Bibi', @@ -256,7 +256,7 @@ describe('GddTransactionList', () => { it('shows the date of the transaction', () => { expect(transaction.findAll('.gdd-transaction-list-item-date').at(0).text()).toContain( - 'Mon Feb 28 2022 13:55:47', + 'Mon Feb 28 2022 13:55:47 GMT+0000', ) }) @@ -326,7 +326,7 @@ describe('GddTransactionList', () => { it('shows the date of the transaction', () => { expect(transaction.findAll('.gdd-transaction-list-item-date').at(0).text()).toContain( - 'Fri Feb 25 2022 07:29:26', + 'Fri Feb 25 2022 07:29:26 GMT+0000', ) }) }) @@ -388,7 +388,7 @@ describe('GddTransactionList', () => { it('shows the date of the transaction', () => { expect(transaction.findAll('.gdd-transaction-list-item-date').at(0).text()).toContain( - 'Wed Feb 23 2022 10:55:30', + 'Wed Feb 23 2022 10:55:30 GMT+0000', ) }) diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index c8f5455d7..d4bf8c1da 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -61,3 +61,11 @@ export const sendCoins = gql` sendCoins(email: $email, amount: $amount, memo: $memo) } ` + +export const createTransactionLink = gql` + mutation($amount: Decimal!, $memo: String!) { + createTransactionLink(amount: $amount, memo: $memo) { + code + } + } +` diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 7d9498f43..78be94271 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -133,3 +133,18 @@ export const queryOptIn = gql` queryOptIn(optIn: $optIn) } ` + +export const queryTransactionLink = gql` + query($code: String!) { + queryTransactionLink(code: $code) { + amount + memo + createdAt + validUntil + user { + firstName + publisherId + } + } + } +` diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 80c4c88c2..c6ddcbe7c 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -67,6 +67,7 @@ "email": "E-Mail", "firstname": "Vorname", "from": "Von", + "generate_now": "jetzt generieren", "lastname": "Nachname", "memo": "Nachricht", "message": "Nachricht", @@ -99,6 +100,19 @@ }, "your_amount": "Dein Betrag" }, + "gdd_per_link": { + "copy": "kopieren", + "created": "Der Link wurde erstellt!", + "decay-14-day": "Vergänglichkeit für 14 Tage", + "header": "Gradidos versenden per Link", + "link-copied": "Link wurde in die Zwischenablage kopiert", + "not-copied": "Konnte den Link nicht kopieren: {err}", + "sentence_1": "Wähle einen Betrag aus, welchen du per Link versenden möchtest. Du kannst auch noch eine Nachricht eintragen. Beim Klick „jetzt generieren“ wird ein Link erstellt, den du versenden kannst.", + "sentence_2": "Der Link ist 14 Tage gültig!", + "sentence_3": "Wird der Link nicht innerhalb der 14 Tage Frist abgerufen, wird er automatisch gelöscht. Der Betrag wird dann wieder deinem Konto gutgeschrieben.", + "sentence_4": "Wer den Link aktiviert, erhält die Zahlung von deinem Konto. Wer noch nicht Mitglied bei Gradido ist, wird durch den Registrierungsprozess geleitet und bekommt den GDD Betrag nach Registrierung / Bestätigung seines Gradido Kontos gut geschrieben.", + "sentence_5": "Der Vergänglichkeitsbetrag wird von dir getragen und für die maximale Gültigkeit deinem Betrag aufgerechnet. Dir wird aber nur der Betrag als Vergänglichkeit berechnet, je nachdem wie viel Tage bis zum Einlösen des Links vergangen sind. Bis zum Einlösen wird die Vergänglichkeit für den gesamten Zeitraum der Gültigkeit vorgehalten." + }, "gdt": { "action": "Aktion", "calculation": "Berechnung der GradidoTransform", @@ -129,6 +143,8 @@ "publisherId": "Publisher-Id" }, "send": "Senden", + "send_gdd": "GDD versenden", + "send_per_link": "GDD versenden per Link", "settings": { "coinanimation": { "coinanimation": "Münzanimation", @@ -222,6 +238,10 @@ "receiverNotFound": "Empfänger nicht gefunden", "show_all": "Alle {count} Transaktionen ansehen" }, + "transaction-link": { + "button": "einlösen", + "send_you": "sendet dir" + }, "transactions": "Transaktionen", "whitepaper": "Whitepaper" } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 506b8c40d..5a6c559b4 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -67,6 +67,7 @@ "email": "Email", "firstname": "Firstname", "from": "from", + "generate_now": "Generate now", "lastname": "Lastname", "memo": "Message", "message": "Message", @@ -99,6 +100,19 @@ }, "your_amount": "Your amount" }, + "gdd_per_link": { + "copy": "copy", + "created": "Link was created!", + "decay-14-day": "Decay for 14 days", + "header": "Send Gradidos via link", + "link-copied": "Link copied to clipboard", + "not-copied": "Could not copy link: {err}", + "sentence_1": "Select an amount that you would like to send via link. You can also enter a message. Click 'Generate now' to create a link that you can share.", + "sentence_2": "The link is valid for 14 days!", + "sentence_3": "If the link is not redeemed within the 14-day validity period, it will be invalidated automatically. The amount will then be available to your account again.", + "sentence_4": "Whoever activates the link will receive the payment from your account. If the recipient is not yet a member of Gradido, he will be guided through the registration process and will get the GDD amount credited after registration / confirmation of the newly created Gradido account.", + "sentence_5": "The decay amount will be blocked for your account for the whole duration of the link. However, you will only be charged the amount of decay for the time which has actually passed until the link is redeemed. The remaining amount will then be available to your account again." + }, "gdt": { "action": "Action", "calculation": "Calculation of GradidoTransform", @@ -129,6 +143,8 @@ "publisherId": "PublisherID" }, "send": "Send", + "send_gdd": "GDD send", + "send_per_link": "GDD send via link", "settings": { "coinanimation": { "coinanimation": "Coin animation", @@ -222,6 +238,10 @@ "receiverNotFound": "Recipient not found", "show_all": "View all {count} transactions." }, + "transaction-link": { + "button": "redeem", + "send_you": "wants to send you" + }, "transactions": "Transactions", "whitepaper": "Whitepaper" } diff --git a/frontend/src/mixins/toaster.js b/frontend/src/mixins/toaster.js index 4464a2cc9..68fd78ff9 100644 --- a/frontend/src/mixins/toaster.js +++ b/frontend/src/mixins/toaster.js @@ -13,7 +13,7 @@ export const toasters = { }) }, toast(message, options) { - message = message.replace(/^GraphQL error: /, '') + if (message.replace) message = message.replace(/^GraphQL error: /, '') this.$bvToast.toast(message, { autoHideDelay: 5000, appendToast: true, diff --git a/frontend/src/pages/Send.spec.js b/frontend/src/pages/Send.spec.js index c724d965f..cc18bed59 100644 --- a/frontend/src/pages/Send.spec.js +++ b/frontend/src/pages/Send.spec.js @@ -1,13 +1,16 @@ import { mount } from '@vue/test-utils' -import Send from './Send' +import Send, { SEND_TYPES } from './Send' +import { toastErrorSpy, toastSuccessSpy } from '@test/testSetup' +import { TRANSACTION_STEPS } from '@/components/GddSend.vue' +import { sendCoins, createTransactionLink } from '@/graphql/mutations.js' -const sendMock = jest.fn() -sendMock.mockResolvedValue('success') +const apolloMutationMock = jest.fn() +apolloMutationMock.mockResolvedValue('success') + +const navigatorClipboardMock = jest.fn() const localVue = global.localVue -// window.scrollTo = jest.fn() - describe('Send', () => { let wrapper @@ -16,6 +19,7 @@ describe('Send', () => { GdtBalance: 1234.56, transactions: [{ balance: 0.1 }], pending: true, + currentTransactionStep: TRANSACTION_STEPS.transactionConfirmationSend, } const mocks = { @@ -27,7 +31,7 @@ describe('Send', () => { }, }, $apollo: { - mutate: sendMock, + mutate: apolloMutationMock, }, } @@ -44,38 +48,42 @@ describe('Send', () => { expect(wrapper.find('div.gdd-send').exists()).toBeTruthy() }) - describe('transaction form', () => { + /* SEND */ + describe('transaction form send', () => { beforeEach(async () => { wrapper.findComponent({ name: 'TransactionForm' }).vm.$emit('set-transaction', { email: 'user@example.org', amount: 23.45, memo: 'Make the best of it!', + selected: SEND_TYPES.send, }) }) it('steps forward in the dialog', () => { - expect(wrapper.findComponent({ name: 'TransactionConfirmation' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'TransactionConfirmationSend' }).exists()).toBe(true) }) }) - describe('confirm transaction', () => { + describe('confirm transaction if selected: SEND_TYPES.send', () => { beforeEach(() => { wrapper.setData({ - currentTransactionStep: 1, + currentTransactionStep: TRANSACTION_STEPS.transactionConfirmationSend, transactionData: { email: 'user@example.org', amount: 23.45, memo: 'Make the best of it!', + selected: SEND_TYPES.send, }, }) }) it('resets the transaction process when on-reset is emitted', async () => { - await wrapper.findComponent({ name: 'TransactionConfirmation' }).vm.$emit('on-reset') + await wrapper.findComponent({ name: 'TransactionConfirmationSend' }).vm.$emit('on-reset') expect(wrapper.findComponent({ name: 'TransactionForm' }).exists()).toBeTruthy() expect(wrapper.vm.transactionData).toEqual({ email: 'user@example.org', amount: 23.45, memo: 'Make the best of it!', + selected: SEND_TYPES.send, }) }) @@ -83,17 +91,19 @@ describe('Send', () => { beforeEach(async () => { jest.clearAllMocks() await wrapper - .findComponent({ name: 'TransactionConfirmation' }) + .findComponent({ name: 'TransactionConfirmationSend' }) .vm.$emit('send-transaction') }) it('calls the API when send-transaction is emitted', async () => { - expect(sendMock).toBeCalledWith( + expect(apolloMutationMock).toBeCalledWith( expect.objectContaining({ + mutation: sendCoins, variables: { email: 'user@example.org', amount: 23.45, memo: 'Make the best of it!', + selected: SEND_TYPES.send, }, }), ) @@ -104,7 +114,7 @@ describe('Send', () => { expect(wrapper.emitted('update-balance')).toEqual([[23.45]]) }) - it('shows the succes page', () => { + it('shows the success page', () => { expect(wrapper.find('div.card-body').text()).toContain('form.send_transaction_success') }) }) @@ -112,9 +122,9 @@ describe('Send', () => { describe('transaction is confirmed and server response is error', () => { beforeEach(async () => { jest.clearAllMocks() - sendMock.mockRejectedValue({ message: 'recipient not known' }) + apolloMutationMock.mockRejectedValue({ message: 'recipient not known' }) await wrapper - .findComponent({ name: 'TransactionConfirmation' }) + .findComponent({ name: 'TransactionConfirmationSend' }) .vm.$emit('send-transaction') }) @@ -131,5 +141,115 @@ describe('Send', () => { }) }) }) + + /* LINK */ + + describe('transaction form link', () => { + beforeEach(async () => { + apolloMutationMock.mockResolvedValue({ + data: { createTransactionLink: { code: '0123456789' } }, + }) + await wrapper.findComponent({ name: 'TransactionForm' }).vm.$emit('set-transaction', { + amount: 56.78, + memo: 'Make the best of it link!', + selected: SEND_TYPES.link, + }) + }) + it('steps forward in the dialog', () => { + expect(wrapper.findComponent({ name: 'TransactionConfirmationLink' }).exists()).toBe(true) + }) + + describe('transaction is confirmed and server response is success', () => { + beforeEach(async () => { + jest.clearAllMocks() + await wrapper + .findComponent({ name: 'TransactionConfirmationLink' }) + .vm.$emit('send-transaction') + }) + + it('calls the API when send-transaction is emitted', async () => { + expect(apolloMutationMock).toBeCalledWith( + expect.objectContaining({ + mutation: createTransactionLink, + variables: { + amount: 56.78, + memo: 'Make the best of it link!', + }, + }), + ) + }) + + it.skip('emits update-balance', () => { + expect(wrapper.emitted('update-balance')).toBeTruthy() + expect(wrapper.emitted('update-balance')).toEqual([[56.78]]) + }) + + it('find components ClipBoard', () => { + expect(wrapper.findComponent({ name: 'ClipboardCopy' }).exists()).toBe(true) + }) + + it('shows the success message', () => { + expect(wrapper.find('div.card-body').text()).toContain('gdd_per_link.created') + }) + + it('shows the close button', () => { + expect(wrapper.find('div.card-body').text()).toContain('form.close') + }) + + describe('Copy link to Clipboard', () => { + const navigatorClipboard = navigator.clipboard + beforeAll(() => { + delete navigator.clipboard + navigator.clipboard = { writeText: navigatorClipboardMock } + }) + afterAll(() => { + navigator.clipboard = navigatorClipboard + }) + + describe('copy with success', () => { + beforeEach(async () => { + navigatorClipboardMock.mockResolvedValue() + await wrapper.findAll('button').at(0).trigger('click') + }) + + it('toasts success message', () => { + expect(toastSuccessSpy).toBeCalledWith('gdd_per_link.link-copied') + }) + }) + + describe('copy with error', () => { + beforeEach(async () => { + navigatorClipboardMock.mockRejectedValue() + await wrapper.findAll('button').at(0).trigger('click') + }) + + it('toasts error message', () => { + expect(toastErrorSpy).toBeCalledWith('gdd_per_link.not-copied') + }) + }) + }) + + describe('close button click', () => { + beforeEach(async () => { + await wrapper.findAll('button').at(1).trigger('click') + }) + + it('Shows the TransactionForm', () => { + expect(wrapper.findComponent({ name: 'TransactionForm' }).exists()).toBe(true) + }) + }) + }) + + describe('send apollo if transaction link with error', () => { + beforeEach(() => { + apolloMutationMock.mockRejectedValue({ message: 'OUCH!' }) + wrapper.find('button.btn-success').trigger('click') + }) + + it('toasts an error message', () => { + expect(toastErrorSpy).toBeCalledWith({ message: 'OUCH!' }) + }) + }) + }) }) }) diff --git a/frontend/src/pages/Send.vue b/frontend/src/pages/Send.vue index 072342d7c..89eb1bbe2 100644 --- a/frontend/src/pages/Send.vue +++ b/frontend/src/pages/Send.vue @@ -2,27 +2,45 @@
- diff --git a/frontend/src/routes/router.test.js b/frontend/src/routes/router.test.js index 665848136..4c454a9b4 100644 --- a/frontend/src/routes/router.test.js +++ b/frontend/src/routes/router.test.js @@ -49,8 +49,8 @@ describe('router', () => { expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' }) }) - it('has sixteen routes defined', () => { - expect(routes).toHaveLength(16) + it('has seventeen routes defined', () => { + expect(routes).toHaveLength(17) }) describe('overview', () => { diff --git a/frontend/src/routes/routes.js b/frontend/src/routes/routes.js index 3fc8dc766..ea7539190 100755 --- a/frontend/src/routes/routes.js +++ b/frontend/src/routes/routes.js @@ -82,6 +82,10 @@ const routes = [ path: '/checkEmail/:optin', component: () => import('@/pages/ResetPassword.vue'), }, + { + path: '/redeem/:code', + component: () => import('@/pages/ShowTransactionLinkInformations.vue'), + }, { path: '*', component: NotFound }, ]