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/model/TransactionList.ts b/backend/src/graphql/model/TransactionList.ts index c34a594f5..9e8356747 100644 --- a/backend/src/graphql/model/TransactionList.ts +++ b/backend/src/graphql/model/TransactionList.ts @@ -9,12 +9,14 @@ export class TransactionList { balance: Decimal, transactions: Transaction[], count: number, + linkCount: number, balanceGDT?: number | null, decayStartBlock: Date = CONFIG.DECAY_START_TIME, ) { this.balance = balance this.transactions = transactions this.count = count + this.linkCount = linkCount this.balanceGDT = balanceGDT || null this.decayStartBlock = decayStartBlock } @@ -25,6 +27,9 @@ export class TransactionList { @Field(() => Number) count: number + @Field(() => Number) + linkCount: number + @Field(() => Decimal) balance: Decimal diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index d2bfd7f28..f64ba19e9 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -170,7 +170,7 @@ export class TransactionResolver { } if (!lastTransaction) { - return new TransactionList(new Decimal(0), [], 0, balanceGDT) + return new TransactionList(new Decimal(0), [], 0, 0, balanceGDT) } // find transactions @@ -204,7 +204,7 @@ export class TransactionResolver { const transactions: Transaction[] = [] const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) - const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate } = + const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } = await transactionLinkRepository.summary(user.id, now) // decay & link transactions @@ -217,9 +217,9 @@ export class TransactionResolver { transactions.push( virtualLinkTransaction( lastTransaction.balance.minus(sumHoldAvailableAmount.toString()), - sumAmount, - sumHoldAvailableAmount, - sumHoldAvailableAmount.minus(sumAmount.toString()), + sumAmount.mul(-1), + sumHoldAvailableAmount.mul(-1), + sumHoldAvailableAmount.minus(sumAmount.toString()).mul(-1), firstDate || now, lastDate || now, self, @@ -244,6 +244,7 @@ export class TransactionResolver { ), transactions, userTransactionsCount, + transactionLinkcount, balanceGDT, ) } 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 99586fda4..285384332 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -184,7 +184,7 @@ const getOptInCode = async (loginUserId: number): Promise => { emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD, }) - // Check for 10 minute delay + // Check for `CONFIG.EMAIL_CODE_VALID_TIME` minute delay if (optInCode) { const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() if (timeElapsed <= CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000) { @@ -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 } @@ -480,7 +490,7 @@ export class UserResolver { throw new Error('Could not login with emailVerificationCode') }) - // Code is only valid for 10minutes + // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() if (timeElapsed > CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000) { throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) @@ -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') + */ } } @@ -558,7 +570,7 @@ export class UserResolver { @Query(() => Boolean) async queryOptIn(@Arg('optIn') optIn: string): Promise { const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) - // Code is only valid for 10minutes + // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() if (timeElapsed > CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000) { throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) diff --git a/backend/src/typeorm/repository/TransactionLink.ts b/backend/src/typeorm/repository/TransactionLink.ts index 2ce937d8d..46926673a 100644 --- a/backend/src/typeorm/repository/TransactionLink.ts +++ b/backend/src/typeorm/repository/TransactionLink.ts @@ -12,13 +12,15 @@ export class TransactionLinkRepository extends Repository { sumAmount: Decimal lastDate: Date | null firstDate: Date | null + transactionLinkcount: number }> { - const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate } = + const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, count } = await this.createQueryBuilder('transactionLinks') .select('SUM(transactionLinks.holdAvailableAmount)', 'sumHoldAvailableAmount') .addSelect('SUM(transactionLinks.amount)', 'sumAmount') .addSelect('MAX(transactionLinks.validUntil)', 'lastDate') .addSelect('MIN(transactionLinks.createdAt)', 'firstDate') + .addSelect('COUNT(*)', 'count') .where('transactionLinks.userId = :userId', { userId }) .andWhere('transactionLinks.redeemedAt is NULL') .andWhere('transactionLinks.validUntil > :date', { date }) @@ -31,6 +33,7 @@ export class TransactionLinkRepository extends Repository { sumAmount: sumAmount ? new Decimal(sumAmount) : new Decimal(0), lastDate: lastDate || null, firstDate: firstDate || null, + transactionLinkcount: count || 0, } } } 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/DecayInformations/CollapseLinksList.vue b/frontend/src/components/DecayInformations/CollapseLinksList.vue new file mode 100644 index 000000000..3c6bab053 --- /dev/null +++ b/frontend/src/components/DecayInformations/CollapseLinksList.vue @@ -0,0 +1,14 @@ + + diff --git a/frontend/src/components/DecayInformations/DecayInformation-Short.vue b/frontend/src/components/DecayInformations/DecayInformation-Short.vue index 3bed4b9cc..1cd0a2d09 100644 --- a/frontend/src/components/DecayInformations/DecayInformation-Short.vue +++ b/frontend/src/components/DecayInformations/DecayInformation-Short.vue @@ -1,6 +1,6 @@ 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/components/GddTransactionList.vue b/frontend/src/components/GddTransactionList.vue index 3b4cd961d..9c7013ec5 100644 --- a/frontend/src/components/GddTransactionList.vue +++ b/frontend/src/components/GddTransactionList.vue @@ -41,6 +41,14 @@ :decayStartBlock="decayStartBlock" /> + +
@@ -63,6 +71,7 @@ import TransactionDecay from '@/components/Transactions/TransactionDecay' import TransactionSend from '@/components/Transactions/TransactionSend' import TransactionReceive from '@/components/Transactions/TransactionReceive' import TransactionCreation from '@/components/Transactions/TransactionCreation' +import TransactionLink from '@/components/Transactions/TransactionLink' export default { name: 'gdd-transaction-list', @@ -73,6 +82,7 @@ export default { TransactionSend, TransactionReceive, TransactionCreation, + TransactionLink, }, data() { return { @@ -85,6 +95,7 @@ export default { pageSize: { type: Number, default: 25 }, timestamp: { type: Number, default: 0 }, transactionCount: { type: Number, default: 0 }, + transactionLinkCount: { type: Number, default: 0 }, showPagination: { type: Boolean, default: false }, }, methods: { diff --git a/frontend/src/components/TransactionRows/LinkCountRow.vue b/frontend/src/components/TransactionRows/LinkCountRow.vue new file mode 100644 index 000000000..5535efde1 --- /dev/null +++ b/frontend/src/components/TransactionRows/LinkCountRow.vue @@ -0,0 +1,23 @@ + + diff --git a/frontend/src/components/Transactions/TransactionCreation.vue b/frontend/src/components/Transactions/TransactionCreation.vue index 33b048c07..43178e5f2 100644 --- a/frontend/src/components/Transactions/TransactionCreation.vue +++ b/frontend/src/components/Transactions/TransactionCreation.vue @@ -60,42 +60,37 @@ export default { props: { amount: { type: String, - }, - balance: { - type: String, + required: true, }, balanceDate: { type: String, + required: true, }, decay: { type: Object, - }, - id: { - type: Number, + required: true, }, linkedUser: { type: Object, + required: true, }, memo: { type: String, + required: true, }, typeId: { type: String, + required: true, }, - properties: { - type: Object, + decayStartBlock: { + type: Date, + required: true, }, - decayStartBlock: { type: Date }, }, data() { return { visible: false, } }, - computed: { - isStartBlock() { - return new Date(this.decay.start).getTime() === this.decayStartBlock.getTime() - }, - }, } diff --git a/frontend/src/components/Transactions/TransactionDecay.vue b/frontend/src/components/Transactions/TransactionDecay.vue index d6c210d9d..4038e782f 100644 --- a/frontend/src/components/Transactions/TransactionDecay.vue +++ b/frontend/src/components/Transactions/TransactionDecay.vue @@ -43,12 +43,15 @@ export default { props: { amount: { type: String, + required: true, }, balance: { type: String, + required: true, }, decay: { type: Object, + required: true, }, }, data() { diff --git a/frontend/src/components/Transactions/TransactionLink.vue b/frontend/src/components/Transactions/TransactionLink.vue new file mode 100644 index 000000000..5c261adbf --- /dev/null +++ b/frontend/src/components/Transactions/TransactionLink.vue @@ -0,0 +1,70 @@ + + diff --git a/frontend/src/components/Transactions/TransactionReceive.vue b/frontend/src/components/Transactions/TransactionReceive.vue index 8766fbfa3..e9dc23cdb 100644 --- a/frontend/src/components/Transactions/TransactionReceive.vue +++ b/frontend/src/components/Transactions/TransactionReceive.vue @@ -61,39 +61,36 @@ export default { props: { amount: { type: String, - }, - balance: { - type: String, + required: true, }, balanceDate: { type: String, + required: true, }, decay: { type: Object, - }, - id: { - type: Number, + required: true, }, linkedUser: { type: Object, + required: true, }, memo: { type: String, + required: true, }, typeId: { type: String, }, - decayStartBlock: { type: Date }, + decayStartBlock: { + type: Date, + required: true, + }, }, data() { return { visible: false, } }, - computed: { - isStartBlock() { - return new Date(this.decay.start).getTime() === this.decayStartBlock.getTime() - }, - }, } diff --git a/frontend/src/components/Transactions/TransactionSend.vue b/frontend/src/components/Transactions/TransactionSend.vue index 0ae8495f5..18112f8e1 100644 --- a/frontend/src/components/Transactions/TransactionSend.vue +++ b/frontend/src/components/Transactions/TransactionSend.vue @@ -61,29 +61,32 @@ export default { props: { amount: { type: String, - }, - balance: { - type: String, + required: true, }, balanceDate: { type: String, + required: true, }, decay: { type: Object, - }, - id: { - type: Number, + required: true, }, linkedUser: { type: Object, + required: true, }, memo: { type: String, + required: true, }, typeId: { type: String, + required: true, + }, + decayStartBlock: { + type: Date, + required: true, }, - decayStartBlock: { type: Date }, }, data() { return { 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..5af878756 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -57,6 +57,7 @@ export const transactionsQuery = gql` ) { balanceGDT count + linkCount balance decayStartBlock transactions { @@ -133,3 +134,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/layouts/DashboardLayout_gdd.spec.js b/frontend/src/layouts/DashboardLayout_gdd.spec.js index 8765c95e7..2a8b7bf42 100644 --- a/frontend/src/layouts/DashboardLayout_gdd.spec.js +++ b/frontend/src/layouts/DashboardLayout_gdd.spec.js @@ -155,6 +155,7 @@ describe('DashboardLayoutGdd', () => { transactionList: { balanceGDT: 100, count: 4, + linkCount: 8, balance: 1450, decay: 1250, transactions: ['transaction', 'transaction', 'transaction', 'transaction'], @@ -198,6 +199,10 @@ describe('DashboardLayoutGdd', () => { it('updates transaction count', () => { expect(wrapper.vm.transactionCount).toBe(4) }) + + it('updates transaction link count', () => { + expect(wrapper.vm.transactionLinkCount).toBe(8) + }) }) describe('update transactions returns error', () => { diff --git a/frontend/src/layouts/DashboardLayout_gdd.vue b/frontend/src/layouts/DashboardLayout_gdd.vue index 66221993f..e35faab2a 100755 --- a/frontend/src/layouts/DashboardLayout_gdd.vue +++ b/frontend/src/layouts/DashboardLayout_gdd.vue @@ -24,6 +24,7 @@ :gdt-balance="GdtBalance" :transactions="transactions" :transactionCount="transactionCount" + :transactionLinkCount="transactionLinkCount" :pending="pending" :decayStartBlock="decayStartBlock" @update-balance="updateBalance" @@ -59,6 +60,7 @@ export default { transactions: [], bookedBalance: 0, transactionCount: 0, + transactionLinkCount: 0, pending: true, visible: false, decayStartBlock: new Date(), @@ -99,6 +101,7 @@ export default { this.transactions = transactionList.transactions this.balance = Number(transactionList.balance) this.transactionCount = transactionList.count + this.transactionLinkCount = transactionList.linkCount this.decayStartBlock = new Date(transactionList.decayStartBlock) this.pending = false }) diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 80c4c88c2..1d4b216a1 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,21 @@ }, "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", + "links_count": "Aktive Links", + "links_sum": "Summe deiner versendeten Gradidos", + "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", @@ -118,6 +134,9 @@ }, "imprint": "Impressum", "language": "Sprache", + "links-list": { + "header": "Liste deiner aktiven Links" + }, "login": "Anmeldung", "logout": "Abmelden", "members_area": "Mitgliederbereich", @@ -129,6 +148,8 @@ "publisherId": "Publisher-Id" }, "send": "Senden", + "send_gdd": "GDD versenden", + "send_per_link": "GDD versenden per Link", "settings": { "coinanimation": { "coinanimation": "Münzanimation", @@ -222,6 +243,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..5873f3cdf 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,21 @@ }, "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", + "links_count": "Active links", + "links_sum": "Total of your sent Gradidos", + "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", @@ -118,6 +134,9 @@ }, "imprint": "Legal notice", "language": "Language", + "links-list": { + "header": "List of your active links" + }, "login": "Login", "logout": "Logout", "members_area": "Members area", @@ -129,6 +148,8 @@ "publisherId": "PublisherID" }, "send": "Send", + "send_gdd": "GDD send", + "send_per_link": "GDD send via link", "settings": { "coinanimation": { "coinanimation": "Coin animation", @@ -222,6 +243,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/Overview.vue b/frontend/src/pages/Overview.vue index 842f6d2d5..93344b3ee 100644 --- a/frontend/src/pages/Overview.vue +++ b/frontend/src/pages/Overview.vue @@ -20,6 +20,7 @@ :timestamp="timestamp" :decayStartBlock="decayStartBlock" :transaction-count="transactionCount" + :transactionLinkCount="transactionLinkCount" @update-transactions="updateTransactions" /> @@ -51,6 +52,7 @@ export default { default: () => [], }, transactionCount: { type: Number, default: 0 }, + transactionLinkCount: { type: Number, default: 0 }, pending: { type: Boolean, default: 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/pages/Transactions.vue b/frontend/src/pages/Transactions.vue index 31094e454..6fc588b10 100644 --- a/frontend/src/pages/Transactions.vue +++ b/frontend/src/pages/Transactions.vue @@ -7,6 +7,7 @@ [], }, transactionCount: { type: Number, default: 0 }, + transactionLinkCount: { type: Number, default: 0 }, decayStartBlock: { type: Date }, }, data() { 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 }, ]