Merge pull request #2263 from gradido/test-transaction-resolver

This commit is contained in:
jjimenezgarcia 2022-10-25 02:22:06 +02:00 committed by GitHub
commit 65247f56fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 449 additions and 26 deletions

View File

@ -74,10 +74,7 @@ export class TransactionLinkResolver {
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
// validate amount
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)
if (!sendBalance) {
throw new Error("user hasn't enough GDD or amount is < 0")
}
await calculateBalance(user.id, holdAvailableAmount, createdDate)
const transactionLink = dbTransactionLink.create()
transactionLink.userId = user.id

View File

@ -0,0 +1,366 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { EventProtocolType } from '@/event/EventProtocolType'
import { userFactory } from '@/seeds/factory/user'
import {
confirmContribution,
createContribution,
login,
sendCoins,
} from '@/seeds/graphql/mutations'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { EventProtocol } from '@entity/EventProtocol'
import { Transaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import { findUserByEmail } from './UserResolver'
let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment(logger)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
let bobData: any
let peterData: any
let user: User[]
describe('send coins', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await userFactory(testEnv, stephenHawking)
await userFactory(testEnv, garrickOllivander)
bobData = {
email: 'bob@baumeister.de',
password: 'Aa12345_',
}
peterData = {
email: 'peter@lustig.de',
password: 'Aa12345_',
}
user = await User.find({ relations: ['emailContact'] })
})
afterAll(async () => {
await cleanDB()
})
describe('unknown recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: bobData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'wrong@email.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`)
})
describe('deleted recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'stephen@hawking.uk',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account was deleted')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account was deleted: recipientUser=${user}`,
)
})
})
describe('recipient account not activated', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'garrick@ollivander.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account is not activated')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account is not activated: recipientUser=${user}`,
)
})
})
})
describe('errors in the transaction itself', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: bobData,
})
})
describe('sender and recipient are the same', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'bob@baumeister.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Sender and Recipient are the same.')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Sender and Recipient are the same.')
})
})
describe('memo text is too long', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=256 > 255')
})
})
describe('memo text is too short', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5')
})
})
describe('user has not enough GDD', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'testing',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`User has not received any GDD yet`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`No prior transaction found for user with id: ${user[1].id}`,
)
})
})
describe('sending negative amount', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: -50,
memo: 'testing negative',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Transaction amount must be greater than 0')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50')
})
})
})
describe('user has some GDD', () => {
beforeAll(async () => {
resetToken()
// login as bob again
await query({ mutation: login, variables: bobData })
// create contribution as user bob
const contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
})
// login as admin
await query({ mutation: login, variables: peterData })
// confirm the contribution
await mutate({
mutation: confirmContribution,
variables: { id: contribution.data.createContribution.id },
})
// login as bob again
await query({ mutation: login, variables: bobData })
})
describe('good transaction', () => {
it('sends the coins', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 50,
memo: 'unrepeatable memo',
},
}),
).toEqual(
expect.objectContaining({
data: {
sendCoins: 'true',
},
}),
)
})
it('stores the send transaction event in the database', async () => {
// Find the exact transaction (sent one is the one with user[1] as user)
const transaction = await Transaction.find({
userId: user[1].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_SEND,
userId: user[1].id,
transactionId: transaction[0].id,
xUserId: user[0].id,
}),
)
})
it('stores the receive event in the database', async () => {
// Find the exact transaction (received one is the one with user[0] as user)
const transaction = await Transaction.find({
userId: user[0].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_RECEIVE,
userId: user[0].id,
transactionId: transaction[0].id,
xUserId: user[1].id,
}),
)
})
})
})
})

View File

@ -37,6 +37,9 @@ import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { Decay } from '../model/Decay'
export const executeTransaction = async (
amount: Decimal,
@ -55,28 +58,19 @@ export const executeTransaction = async (
}
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
// validate amount
const receivedCallDate = new Date()
const sendBalance = await calculateBalance(
sender.id,
amount.mul(-1),
receivedCallDate,
transactionLink,
)
logger.debug(`calculated Balance=${sendBalance}`)
if (!sendBalance) {
logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`)
throw new Error("user hasn't enough GDD or amount is < 0")
}
const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
@ -106,7 +100,24 @@ export const executeTransaction = async (
transactionReceive.userId = recipient.id
transactionReceive.linkedUserId = sender.id
transactionReceive.amount = amount
const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
// state received balance
let receiveBalance: {
balance: Decimal
decay: Decay
lastTransactionId: number
} | null
// try received balance
try {
receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
} catch (e) {
logger.info(
`User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`,
)
receiveBalance = null
}
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
transactionReceive.balanceDate = receivedCallDate
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
@ -135,6 +146,20 @@ export const executeTransaction = async (
await queryRunner.commitTransaction()
logger.info(`commit Transaction successful...`)
const eventTransactionSend = new EventTransactionSend()
eventTransactionSend.userId = transactionSend.userId
eventTransactionSend.xUserId = transactionSend.linkedUserId
eventTransactionSend.transactionId = transactionSend.id
eventTransactionSend.amount = transactionSend.amount.mul(-1)
await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend))
const eventTransactionReceive = new EventTransactionReceive()
eventTransactionReceive.userId = transactionReceive.userId
eventTransactionReceive.xUserId = transactionReceive.linkedUserId
eventTransactionReceive.transactionId = transactionReceive.id
eventTransactionReceive.amount = transactionReceive.amount
await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive))
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Transaction was not successful: ${e}`)
@ -316,6 +341,10 @@ export class TransactionResolver {
}
*/
// const recipientUser = await dbUser.findOne({ id: emailContact.userId })
/* Code inside this if statement is unreachable (useless by so),
in findUserByEmail() an error is already thrown if the user is not found
*/
if (!recipientUser) {
logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('unknown recipient')

View File

@ -1,5 +1,17 @@
import Decimal from 'decimal.js-light'
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
return Object.keys(obj).map(function (key) {
return obj[key]
})
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalAddition = (a: Decimal, b: Decimal): Decimal => {
return a.add(b.toString())
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => {
return a.minus(b.toString())
}

View File

@ -5,6 +5,8 @@ import { Decay } from '@model/Decay'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { decimalSubtraction, decimalAddition } from './utilities'
import { backendLogger as logger } from '@/server/logger'
function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase()
@ -23,14 +25,26 @@ async function calculateBalance(
amount: Decimal,
time: Date,
transactionLink?: dbTransactionLink | null,
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> {
// negative or empty amount should not be allowed
if (amount.lessThanOrEqualTo(0)) {
logger.error(`Transaction amount must be greater than 0: ${amount}`)
throw new Error('Transaction amount must be greater than 0')
}
// check if user has prior transactions
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
if (!lastTransaction) return null
if (!lastTransaction) {
logger.error(`No prior transaction found for user with id: ${userId}`)
throw new Error('User has not received any GDD yet')
}
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
// TODO why we have to use toString() here?
const balance = decay.balance.add(amount.toString())
// new balance is the old balance minus the amount used
const balance = decimalSubtraction(decay.balance, amount)
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time)
@ -38,11 +52,16 @@ async function calculateBalance(
// else we cannot redeem links which are more or equal to half of what an account actually owns
const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0)
if (
balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0)
) {
return null
const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount)
if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) {
logger.error(
`Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`,
)
throw new Error('Not enough funds for transaction')
}
logger.debug(`calculated Balance=${balance}`)
return { balance, lastTransactionId: lastTransaction.id, decay }
}