diff --git a/backend/jest.config.js b/backend/jest.config.js index c9fbd6e81..1529fad55 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -7,7 +7,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 89, + lines: 86, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c96f60d17..636600b9d 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -126,6 +126,11 @@ const federation = { Number(process.env.FEDERATION_VALIDATE_COMMUNITY_TIMER) || 60000, FEDERATION_XCOM_SENDCOINS_ENABLED: process.env.FEDERATION_XCOM_SENDCOINS_ENABLED === 'true' || false, + // default value for community-uuid is equal uuid of stage-3 + FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID: + process.env.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID ?? '56a55482-909e-46a4-bfa2-cd025e894ebc', + FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS: + process.env.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS ?? 3, } export const CONFIG = { diff --git a/backend/src/federation/client/1_0/SendCoinsClient.ts b/backend/src/federation/client/1_0/SendCoinsClient.ts new file mode 100644 index 000000000..c57ca4823 --- /dev/null +++ b/backend/src/federation/client/1_0/SendCoinsClient.ts @@ -0,0 +1,93 @@ +import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' +import { GraphQLClient } from 'graphql-request' + +import { LogError } from '@/server/LogError' +import { backendLogger as logger } from '@/server/logger' + +import { SendCoinsArgs } from './model/SendCoinsArgs' +import { revertSendCoins } from './query/revertSendCoins' +import { voteForSendCoins } from './query/voteForSendCoins' + +// eslint-disable-next-line camelcase +export class SendCoinsClient { + dbCom: DbFederatedCommunity + endpoint: string + client: GraphQLClient + + constructor(dbCom: DbFederatedCommunity) { + this.dbCom = dbCom + this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${ + dbCom.apiVersion + }/` + this.client = new GraphQLClient(this.endpoint, { + method: 'GET', + jsonSerializer: { + parse: JSON.parse, + stringify: JSON.stringify, + }, + }) + } + + voteForSendCoins = async (args: SendCoinsArgs): Promise => { + logger.debug('X-Com: voteForSendCoins against endpoint=', this.endpoint) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { data } = await this.client.rawRequest(voteForSendCoins, { args }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!data?.voteForSendCoins?.voteForSendCoins) { + logger.warn( + 'X-Com: voteForSendCoins failed with: ', + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + data.voteForSendCoins.voteForSendCoins, + ) + return + } + logger.debug( + 'X-Com: voteForSendCoins successful with result=', + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + data.voteForSendCoins, + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return data.voteForSendCoins.voteForSendCoins + } catch (err) { + throw new LogError(`X-Com: voteForSendCoins failed for endpoint=${this.endpoint}:`, err) + } + } + + revertSendCoins = async (args: SendCoinsArgs): Promise => { + logger.debug('X-Com: revertSendCoins against endpoint=', this.endpoint) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { data } = await this.client.rawRequest(revertSendCoins, { args }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!data?.revertSendCoins?.revertSendCoins) { + logger.warn('X-Com: revertSendCoins without response data from endpoint', this.endpoint) + return false + } + logger.debug(`X-Com: revertSendCoins successful from endpoint=${this.endpoint}`) + return true + } catch (err) { + logger.error(`X-Com: revertSendCoins failed for endpoint=${this.endpoint}`, err) + return false + } + } + + /* + commitSendCoins = async (args: SendCoinsArgs): Promise => { + logger.debug(`X-Com: commitSendCoins against endpoint='${this.endpoint}'...`) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { data } = await this.client.rawRequest(commitSendCoins, { args }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!data?.commitSendCoins?.acknowledged) { + logger.warn('X-Com: commitSendCoins without response data from endpoint', this.endpoint) + return false + } + logger.debug(`X-Com: commitSendCoins successful from endpoint=${this.endpoint}`) + return true + } catch (err) { + throw new LogError(`X-Com: commitSendCoins failed for endpoint=${this.endpoint}`, err) + } + } + */ +} diff --git a/backend/src/federation/client/1_0/model/SendCoinsArgs.ts b/backend/src/federation/client/1_0/model/SendCoinsArgs.ts new file mode 100644 index 000000000..545aab822 --- /dev/null +++ b/backend/src/federation/client/1_0/model/SendCoinsArgs.ts @@ -0,0 +1,29 @@ +import { Decimal } from 'decimal.js-light' +import { ArgsType, Field } from 'type-graphql' + +@ArgsType() +export class SendCoinsArgs { + @Field(() => String) + communityReceiverIdentifier: string + + @Field(() => String) + userReceiverIdentifier: string + + @Field(() => String) + creationDate: string + + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + communitySenderIdentifier: string + + @Field(() => String) + userSenderIdentifier: string + + @Field(() => String) + userSenderName: string +} diff --git a/backend/src/federation/client/1_0/model/SendCoinsResult.ts b/backend/src/federation/client/1_0/model/SendCoinsResult.ts new file mode 100644 index 000000000..1897410cc --- /dev/null +++ b/backend/src/federation/client/1_0/model/SendCoinsResult.ts @@ -0,0 +1,17 @@ +import { ArgsType, Field } from 'type-graphql' + +@ArgsType() +export class SendCoinsResult { + constructor() { + this.vote = false + } + + @Field(() => Boolean) + vote: boolean + + @Field(() => String) + receiverFirstName: string + + @Field(() => String) + receiverLastName: string +} diff --git a/backend/src/federation/client/1_0/query/revertSendCoins.ts b/backend/src/federation/client/1_0/query/revertSendCoins.ts new file mode 100644 index 000000000..881107cb4 --- /dev/null +++ b/backend/src/federation/client/1_0/query/revertSendCoins.ts @@ -0,0 +1,25 @@ +import { gql } from 'graphql-request' + +export const revertSendCoins = gql` + mutation ( + $communityReceiverIdentifier: String! + $userReceiverIdentifier: String! + $creationDate: String! + $amount: Decimal! + $memo: String! + $communitySenderIdentifier: String! + $userSenderIdentifier: String! + $userSenderName: String! + ) { + revertSendCoins( + communityReceiverIdentifier: $communityReceiverIdentifier + userReceiverIdentifier: $userReceiverIdentifier + creationDate: $creationDate + amount: $amount + memo: $memo + communitySenderIdentifier: $communitySenderIdentifier + userSenderIdentifier: $userSenderIdentifier + userSenderName: $userSenderName + ) + } +` diff --git a/backend/src/federation/client/1_0/query/voteForSendCoins.ts b/backend/src/federation/client/1_0/query/voteForSendCoins.ts new file mode 100644 index 000000000..f0f75198f --- /dev/null +++ b/backend/src/federation/client/1_0/query/voteForSendCoins.ts @@ -0,0 +1,25 @@ +import { gql } from 'graphql-request' + +export const voteForSendCoins = gql` + mutation ( + $communityReceiverIdentifier: String! + $userReceiverIdentifier: String! + $creationDate: String! + $amount: Decimal! + $memo: String! + $communitySenderIdentifier: String! + $userSenderIdentifier: String! + $userSenderName: String! + ) { + voteForSendCoins( + communityReceiverIdentifier: $communityReceiverIdentifier + userReceiverIdentifier: $userReceiverIdentifier + creationDate: $creationDate + amount: $amount + memo: $memo + communitySenderIdentifier: $communitySenderIdentifier + userSenderIdentifier: $userSenderIdentifier + userSenderName: $userSenderName + ) + } +` diff --git a/backend/src/federation/client/1_1/SendCoinsClient.ts b/backend/src/federation/client/1_1/SendCoinsClient.ts new file mode 100644 index 000000000..586f8561e --- /dev/null +++ b/backend/src/federation/client/1_1/SendCoinsClient.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line camelcase +import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient' + +// eslint-disable-next-line camelcase +export class SendCoinsClient extends V1_0_SendCoinsClient {} diff --git a/backend/src/federation/client/SendCoinsClientFactory.ts b/backend/src/federation/client/SendCoinsClientFactory.ts new file mode 100644 index 000000000..2c7b90f01 --- /dev/null +++ b/backend/src/federation/client/SendCoinsClientFactory.ts @@ -0,0 +1,62 @@ +import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' + +// eslint-disable-next-line camelcase +import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient' +// eslint-disable-next-line camelcase +import { SendCoinsClient as V1_1_SendCoinsClient } from '@/federation/client/1_1/SendCoinsClient' +import { ApiVersionType } from '@/federation/enum/apiVersionType' + +// eslint-disable-next-line camelcase +type SendCoinsClient = V1_0_SendCoinsClient | V1_1_SendCoinsClient + +interface SendCoinsClientInstance { + id: number + // eslint-disable-next-line no-use-before-define + client: SendCoinsClient +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class SendCoinsClientFactory { + private static instanceArray: SendCoinsClientInstance[] = [] + + /** + * The Singleton's constructor should always be private to prevent direct + * construction calls with the `new` operator. + */ + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} + + private static createSendCoinsClient = (dbCom: DbFederatedCommunity) => { + switch (dbCom.apiVersion) { + case ApiVersionType.V1_0: + return new V1_0_SendCoinsClient(dbCom) + case ApiVersionType.V1_1: + return new V1_1_SendCoinsClient(dbCom) + default: + return null + } + } + + /** + * The static method that controls the access to the singleton instance. + * + * This implementation let you subclass the Singleton class while keeping + * just one instance of each subclass around. + */ + public static getInstance(dbCom: DbFederatedCommunity): SendCoinsClient | null { + const instance = SendCoinsClientFactory.instanceArray.find( + (instance) => instance.id === dbCom.id, + ) + if (instance) { + return instance.client + } + const client = SendCoinsClientFactory.createSendCoinsClient(dbCom) + if (client) { + SendCoinsClientFactory.instanceArray.push({ + id: dbCom.id, + client, + } as SendCoinsClientInstance) + } + return client + } +} diff --git a/backend/src/graphql/resolver/util/processXComSendCoins.ts b/backend/src/graphql/resolver/util/processXComSendCoins.ts new file mode 100644 index 000000000..b39985aaa --- /dev/null +++ b/backend/src/graphql/resolver/util/processXComSendCoins.ts @@ -0,0 +1,116 @@ +import { Community as DbCommunity } from '@entity/Community' +import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity' +import { PendingTransaction as DbPendingTransaction } from '@entity/PendingTransaction' +import { User as dbUser } from '@entity/User' +import { Decimal } from 'decimal.js-light' + +import { CONFIG } from '@/config' +import { SendCoinsArgs } from '@/federation/client/1_0/model/SendCoinsArgs' +// eslint-disable-next-line camelcase +import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient' +import { SendCoinsClientFactory } from '@/federation/client/SendCoinsClientFactory' +import { PendingTransactionState } from '@/graphql/enum/PendingTransactionState' +import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' +import { LogError } from '@/server/LogError' +import { backendLogger as logger } from '@/server/logger' +import { calculateSenderBalance } from '@/util/calculateSenderBalance' +import { fullName } from '@/util/utilities' + +export async function processXComPendingSendCoins( + receiverFCom: DbFederatedCommunity, + receiverCom: DbCommunity, + senderCom: DbCommunity, + creationDate: Date, + amount: Decimal, + memo: string, + sender: dbUser, + recipient: dbUser, +): Promise { + try { + logger.debug( + `XCom: processXComPendingSendCoins...`, + receiverFCom, + receiverCom, + senderCom, + creationDate, + amount, + memo, + sender, + recipient, + ) + // first calculate the sender balance and check if the transaction is allowed + const senderBalance = await calculateSenderBalance(sender.id, amount.mul(-1), creationDate) + if (!senderBalance) { + throw new LogError('User has not enough GDD or amount is < 0', senderBalance) + } + logger.debug(`X-Com: calculated senderBalance = `, senderBalance) + + const client = SendCoinsClientFactory.getInstance(receiverFCom) + // eslint-disable-next-line camelcase + if (client instanceof V1_0_SendCoinsClient) { + const args = new SendCoinsArgs() + args.communityReceiverIdentifier = receiverCom.communityUuid + ? receiverCom.communityUuid + : CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID + args.userReceiverIdentifier = recipient.gradidoID + args.creationDate = creationDate.toISOString() + args.amount = amount + args.memo = memo + args.communitySenderIdentifier = senderCom.communityUuid + ? senderCom.communityUuid + : 'homeCom-UUID' + args.userSenderIdentifier = sender.gradidoID + args.userSenderName = fullName(sender.firstName, sender.lastName) + logger.debug(`X-Com: ready for voteForSendCoins with args=`, args) + const recipientName = await client.voteForSendCoins(args) + logger.debug(`X-Com: returnd from voteForSendCoins:`, recipientName) + if (recipientName) { + // writing the pending transaction on receiver-side was successfull, so now write the sender side + try { + const pendingTx = DbPendingTransaction.create() + pendingTx.amount = amount.mul(-1) + pendingTx.balance = senderBalance ? senderBalance.balance : new Decimal(0) + pendingTx.balanceDate = creationDate + pendingTx.decay = senderBalance ? senderBalance.decay.decay : new Decimal(0) + pendingTx.decayStart = senderBalance ? senderBalance.decay.start : null + pendingTx.linkedUserCommunityUuid = receiverCom.communityUuid + ? receiverCom.communityUuid + : CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID + pendingTx.linkedUserGradidoID = recipient.gradidoID + pendingTx.linkedUserName = recipientName + pendingTx.memo = memo + pendingTx.previous = senderBalance ? senderBalance.lastTransactionId : null + pendingTx.state = PendingTransactionState.NEW + pendingTx.typeId = TransactionTypeId.SEND + if (senderCom.communityUuid) pendingTx.userCommunityUuid = senderCom.communityUuid + pendingTx.userGradidoID = sender.gradidoID + pendingTx.userName = fullName(sender.firstName, sender.lastName) + logger.debug(`X-Com: initialized sender pendingTX=`, pendingTx) + + await DbPendingTransaction.insert(pendingTx) + logger.debug(`X-Com: sender pendingTx successfully inserted...`) + } catch (err) { + logger.error(`Error in writing sender pending transaction: `, err) + // revert the existing pending transaction on receiver side + let revertCount = 0 + logger.debug(`X-Com: first try to revertSendCoins of receiver`) + do { + if (await client.revertSendCoins(args)) { + logger.debug(`revertSendCoins()-1_0... successfull after revertCount=`, revertCount) + // treat revertingSendCoins as an error of the whole sendCoins-process + throw new LogError('Error in writing sender pending transaction: `, err') + } + } while (CONFIG.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS > revertCount++) + throw new LogError( + `Error in reverting receiver pending transaction even after revertCount=`, + revertCount, + ) + } + logger.debug(`voteForSendCoins()-1_0... successfull`) + } + } + } catch (err) { + logger.error(`Error:`, err) + } + return true +} diff --git a/backend/src/util/calculateSenderBalance.ts b/backend/src/util/calculateSenderBalance.ts new file mode 100644 index 000000000..89e417d35 --- /dev/null +++ b/backend/src/util/calculateSenderBalance.ts @@ -0,0 +1,21 @@ +import { Decimal } from 'decimal.js-light' + +import { Decay } from '@model/Decay' + +import { getLastTransaction } from '@/graphql/resolver/util/getLastTransaction' + +import { calculateDecay } from './decay' + +export async function calculateSenderBalance( + userId: number, + amount: Decimal, + time: Date, +): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { + const lastTransaction = await getLastTransaction(userId) + if (!lastTransaction) return null + + const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) + + const balance = decay.balance.add(amount.toString()) + return { balance, lastTransactionId: lastTransaction.id, decay } +} diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index 29458d006..74a53ed1b 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -1,24 +1,22 @@ // ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env) +import { Decimal } from 'decimal.js-light' import dotenv from 'dotenv' dotenv.config() -/* -import Decimal from 'decimal.js-light' Decimal.set({ precision: 25, rounding: Decimal.ROUND_HALF_UP, }) -*/ const constants = { DB_VERSION: '0071-add-pending_transactions-table', - // DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 + DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info LOG_LEVEL: process.env.LOG_LEVEL || 'info', CONFIG_VERSION: { DEFAULT: 'DEFAULT', - EXPECTED: 'v1.2023-01-09', + EXPECTED: 'v2.2023-08-24', CURRENT: '', }, } diff --git a/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts b/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts new file mode 100644 index 000000000..6a614be96 --- /dev/null +++ b/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts @@ -0,0 +1,14 @@ +import { registerEnumType } from 'type-graphql' + +export enum PendingTransactionState { + NEW = 1, + WAIT_ON_PENDING = 2, + PENDING = 3, + WAIT_ON_CONFIRM = 4, + CONFIRMED = 5, +} + +registerEnumType(PendingTransactionState, { + name: 'PendingTransactionState', // this one is mandatory + description: 'State of the PendingTransaction', // this one is optional +}) diff --git a/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts b/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts new file mode 100644 index 000000000..a7e39eebc --- /dev/null +++ b/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts @@ -0,0 +1,15 @@ +import { registerEnumType } from 'type-graphql' + +export enum TransactionTypeId { + CREATION = 1, + SEND = 2, + RECEIVE = 3, + // This is a virtual property, never occurring on the database + DECAY = 4, + LINK_SUMMARY = 5, +} + +registerEnumType(TransactionTypeId, { + name: 'TransactionTypeId', // this one is mandatory + description: 'Type of the transaction', // this one is optional +}) diff --git a/federation/src/graphql/api/1_0/model/Decay.ts b/federation/src/graphql/api/1_0/model/Decay.ts new file mode 100644 index 000000000..0b710c234 --- /dev/null +++ b/federation/src/graphql/api/1_0/model/Decay.ts @@ -0,0 +1,41 @@ +import { Decimal } from 'decimal.js-light' +import { ObjectType, Field, Int } from 'type-graphql' + +interface DecayInterface { + balance: Decimal + decay: Decimal + roundedDecay: Decimal + start: Date | null + end: Date | null + duration: number | null +} + +@ObjectType() +export class Decay { + constructor({ balance, decay, roundedDecay, start, end, duration }: DecayInterface) { + this.balance = balance + this.decay = decay + this.roundedDecay = roundedDecay + this.start = start + this.end = end + this.duration = duration + } + + @Field(() => Decimal) + balance: Decimal + + @Field(() => Decimal) + decay: Decimal + + @Field(() => Decimal) + roundedDecay: Decimal + + @Field(() => Date, { nullable: true }) + start: Date | null + + @Field(() => Date, { nullable: true }) + end: Date | null + + @Field(() => Int, { nullable: true }) + duration: number | null +} diff --git a/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts b/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts new file mode 100644 index 000000000..545aab822 --- /dev/null +++ b/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts @@ -0,0 +1,29 @@ +import { Decimal } from 'decimal.js-light' +import { ArgsType, Field } from 'type-graphql' + +@ArgsType() +export class SendCoinsArgs { + @Field(() => String) + communityReceiverIdentifier: string + + @Field(() => String) + userReceiverIdentifier: string + + @Field(() => String) + creationDate: string + + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + communitySenderIdentifier: string + + @Field(() => String) + userSenderIdentifier: string + + @Field(() => String) + userSenderName: string +} diff --git a/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.test.ts b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.test.ts new file mode 100644 index 000000000..7b580b240 --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.test.ts @@ -0,0 +1,356 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { ApolloServerTestClient } from 'apollo-server-testing' +import { Community as DbCommunity } from '@entity/Community' +import CONFIG from '@/config' +import { User as DbUser } from '@entity/User' +import { fullName } from '@/graphql/util/fullName' +import { GraphQLError } from 'graphql' +import { cleanDB, testEnvironment } from '@test/helpers' +import { logger } from '@test/testSetup' +import { Connection } from '@dbTools/typeorm' + +let mutate: ApolloServerTestClient['mutate'], con: Connection +// let query: ApolloServerTestClient['query'] + +let testEnv: { + mutate: ApolloServerTestClient['mutate'] + query: ApolloServerTestClient['query'] + con: Connection +} + +CONFIG.FEDERATION_API = '1_0' + +beforeAll(async () => { + testEnv = await testEnvironment(logger) + mutate = testEnv.mutate + // query = testEnv.query + con = testEnv.con + + // const server = await createServer() + // con = server.con + // query = createTestClient(server.apollo).query + // mutate = createTestClient(server.apollo).mutate + // DbCommunity.clear() + // DbUser.clear() + await cleanDB() +}) + +afterAll(async () => { + // await cleanDB() + await con.destroy() +}) + +describe('SendCoinsResolver', () => { + const voteForSendCoinsMutation = ` + mutation ( + $communityReceiverIdentifier: String! + $userReceiverIdentifier: String! + $creationDate: String! + $amount: Decimal! + $memo: String! + $communitySenderIdentifier: String! + $userSenderIdentifier: String! + $userSenderName: String! + ) { + voteForSendCoins( + communityReceiverIdentifier: $communityReceiverIdentifier + userReceiverIdentifier: $userReceiverIdentifier + creationDate: $creationDate + amount: $amount + memo: $memo + communitySenderIdentifier: $communitySenderIdentifier + userSenderIdentifier: $userSenderIdentifier + userSenderName: $userSenderName + ) + } +` + const revertSendCoinsMutation = ` + mutation ( + $communityReceiverIdentifier: String! + $userReceiverIdentifier: String! + $creationDate: String! + $amount: Decimal! + $memo: String! + $communitySenderIdentifier: String! + $userSenderIdentifier: String! + $userSenderName: String! + ) { + revertSendCoins( + communityReceiverIdentifier: $communityReceiverIdentifier + userReceiverIdentifier: $userReceiverIdentifier + creationDate: $creationDate + amount: $amount + memo: $memo + communitySenderIdentifier: $communitySenderIdentifier + userSenderIdentifier: $userSenderIdentifier + userSenderName: $userSenderName + ) + } +` + + describe('voteForSendCoins', () => { + let homeCom: DbCommunity + let foreignCom: DbCommunity + let sendUser: DbUser + let recipUser: DbUser + + beforeEach(async () => { + await cleanDB() + homeCom = DbCommunity.create() + homeCom.foreign = false + homeCom.url = 'homeCom-url' + homeCom.name = 'homeCom-Name' + homeCom.description = 'homeCom-Description' + homeCom.creationDate = new Date() + homeCom.publicKey = Buffer.from('homeCom-publicKey') + homeCom.communityUuid = 'homeCom-UUID' + await DbCommunity.insert(homeCom) + + foreignCom = DbCommunity.create() + foreignCom.foreign = true + foreignCom.url = 'foreignCom-url' + foreignCom.name = 'foreignCom-Name' + foreignCom.description = 'foreignCom-Description' + foreignCom.creationDate = new Date() + foreignCom.publicKey = Buffer.from('foreignCom-publicKey') + foreignCom.communityUuid = 'foreignCom-UUID' + await DbCommunity.insert(foreignCom) + + sendUser = DbUser.create() + sendUser.alias = 'sendUser-alias' + sendUser.firstName = 'sendUser-FirstName' + sendUser.gradidoID = 'sendUser-GradidoID' + sendUser.lastName = 'sendUser-LastName' + await DbUser.insert(sendUser) + + recipUser = DbUser.create() + recipUser.alias = 'recipUser-alias' + recipUser.firstName = 'recipUser-FirstName' + recipUser.gradidoID = 'recipUser-GradidoID' + recipUser.lastName = 'recipUser-LastName' + await DbUser.insert(recipUser) + }) + + describe('unknown recipient community', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: voteForSendCoinsMutation, + variables: { + communityReceiverIdentifier: 'invalid foreignCom', + userReceiverIdentifier: recipUser.gradidoID, + creationDate: new Date().toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('voteForSendCoins with wrong communityReceiverIdentifier')], + }), + ) + }) + }) + + describe('unknown recipient user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: voteForSendCoinsMutation, + variables: { + communityReceiverIdentifier: foreignCom.communityUuid, + userReceiverIdentifier: 'invalid recipient', + creationDate: new Date().toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'voteForSendCoins with unknown userReceiverIdentifier in the community=', + ), + ], + }), + ) + }) + }) + + describe('valid X-Com-TX voted', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: voteForSendCoinsMutation, + variables: { + communityReceiverIdentifier: foreignCom.communityUuid, + userReceiverIdentifier: recipUser.gradidoID, + creationDate: new Date().toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + data: { + voteForSendCoins: 'recipUser-FirstName recipUser-LastName', + }, + }), + ) + }) + }) + }) + + describe('revertSendCoins', () => { + let homeCom: DbCommunity + let foreignCom: DbCommunity + let sendUser: DbUser + let recipUser: DbUser + const creationDate = new Date() + + beforeEach(async () => { + await cleanDB() + homeCom = DbCommunity.create() + homeCom.foreign = false + homeCom.url = 'homeCom-url' + homeCom.name = 'homeCom-Name' + homeCom.description = 'homeCom-Description' + homeCom.creationDate = new Date() + homeCom.publicKey = Buffer.from('homeCom-publicKey') + homeCom.communityUuid = 'homeCom-UUID' + await DbCommunity.insert(homeCom) + + foreignCom = DbCommunity.create() + foreignCom.foreign = true + foreignCom.url = 'foreignCom-url' + foreignCom.name = 'foreignCom-Name' + foreignCom.description = 'foreignCom-Description' + foreignCom.creationDate = new Date() + foreignCom.publicKey = Buffer.from('foreignCom-publicKey') + foreignCom.communityUuid = 'foreignCom-UUID' + await DbCommunity.insert(foreignCom) + + sendUser = DbUser.create() + sendUser.alias = 'sendUser-alias' + sendUser.firstName = 'sendUser-FirstName' + sendUser.gradidoID = 'sendUser-GradidoID' + sendUser.lastName = 'sendUser-LastName' + await DbUser.insert(sendUser) + + recipUser = DbUser.create() + recipUser.alias = 'recipUser-alias' + recipUser.firstName = 'recipUser-FirstName' + recipUser.gradidoID = 'recipUser-GradidoID' + recipUser.lastName = 'recipUser-LastName' + await DbUser.insert(recipUser) + + await mutate({ + mutation: voteForSendCoinsMutation, + variables: { + communityReceiverIdentifier: foreignCom.communityUuid, + userReceiverIdentifier: recipUser.gradidoID, + creationDate: creationDate.toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }) + }) + + describe('unknown recipient community', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: revertSendCoinsMutation, + variables: { + communityReceiverIdentifier: 'invalid foreignCom', + userReceiverIdentifier: recipUser.gradidoID, + creationDate: creationDate.toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('revertSendCoins with wrong communityReceiverIdentifier')], + }), + ) + }) + }) + + describe('unknown recipient user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: revertSendCoinsMutation, + variables: { + communityReceiverIdentifier: foreignCom.communityUuid, + userReceiverIdentifier: 'invalid recipient', + creationDate: creationDate.toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'revertSendCoins with unknown userReceiverIdentifier in the community=', + ), + ], + }), + ) + }) + }) + + describe('valid X-Com-TX reverted', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: revertSendCoinsMutation, + variables: { + communityReceiverIdentifier: foreignCom.communityUuid, + userReceiverIdentifier: recipUser.gradidoID, + creationDate: creationDate.toISOString(), + amount: 100, + memo: 'X-Com-TX memo', + communitySenderIdentifier: homeCom.communityUuid, + userSenderIdentifier: sendUser.gradidoID, + userSenderName: fullName(sendUser.firstName, sendUser.lastName), + }, + }), + ).toEqual( + expect.objectContaining({ + data: { + revertSendCoins: true, + }, + }), + ) + }) + }) + }) +}) diff --git a/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts new file mode 100644 index 000000000..11222a3ce --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts @@ -0,0 +1,161 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Args, Mutation, Resolver } from 'type-graphql' +import { federationLogger as logger } from '@/server/logger' +import { Community as DbCommunity } from '@entity/Community' +import { PendingTransaction as DbPendingTransaction } from '@entity/PendingTransaction' +import { SendCoinsArgs } from '../model/SendCoinsArgs' +import { User as DbUser } from '@entity/User' +import { LogError } from '@/server/LogError' +import { PendingTransactionState } from '../enum/PendingTransactionState' +import { TransactionTypeId } from '../enum/TransactionTypeId' +import { calculateRecipientBalance } from '@/graphql/util/calculateRecipientBalance' +import Decimal from 'decimal.js-light' +import { fullName } from '@/graphql/util/fullName' + +@Resolver() +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class SendCoinsResolver { + @Mutation(() => String) + async voteForSendCoins( + @Args() + { + communityReceiverIdentifier, + userReceiverIdentifier, + creationDate, + amount, + memo, + communitySenderIdentifier, + userSenderIdentifier, + userSenderName, + }: SendCoinsArgs, + ): Promise { + logger.debug(`voteForSendCoins() via apiVersion=1_0 ...`) + let result: string | null = null + // first check if receiver community is correct + const homeCom = await DbCommunity.findOneBy({ + communityUuid: communityReceiverIdentifier, + }) + if (!homeCom) { + throw new LogError( + `voteForSendCoins with wrong communityReceiverIdentifier`, + communityReceiverIdentifier, + ) + } + // second check if receiver user exists in this community + const receiverUser = await DbUser.findOneBy({ gradidoID: userReceiverIdentifier }) + if (!receiverUser) { + throw new LogError( + `voteForSendCoins with unknown userReceiverIdentifier in the community=`, + homeCom.name, + ) + } + try { + const txDate = new Date(creationDate) + const receiveBalance = await calculateRecipientBalance(receiverUser.id, amount, txDate) + const pendingTx = DbPendingTransaction.create() + pendingTx.amount = amount + pendingTx.balance = receiveBalance ? receiveBalance.balance : new Decimal(0) + pendingTx.balanceDate = txDate + pendingTx.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) + pendingTx.decayStart = receiveBalance ? receiveBalance.decay.start : null + pendingTx.creationDate = new Date() + pendingTx.linkedUserCommunityUuid = communitySenderIdentifier + pendingTx.linkedUserGradidoID = userSenderIdentifier + pendingTx.linkedUserName = userSenderName + pendingTx.memo = memo + pendingTx.previous = receiveBalance ? receiveBalance.lastTransactionId : null + pendingTx.state = PendingTransactionState.NEW + pendingTx.typeId = TransactionTypeId.RECEIVE + pendingTx.userId = receiverUser.id + pendingTx.userCommunityUuid = communityReceiverIdentifier + pendingTx.userGradidoID = userReceiverIdentifier + pendingTx.userName = fullName(receiverUser.firstName, receiverUser.lastName) + + await DbPendingTransaction.insert(pendingTx) + result = pendingTx.userName + logger.debug(`voteForSendCoins()-1_0... successfull`) + } catch (err) { + throw new LogError(`Error in voteForSendCoins: `, err) + } + return result + } + + @Mutation(() => Boolean) + async revertSendCoins( + @Args() + { + communityReceiverIdentifier, + userReceiverIdentifier, + creationDate, + amount, + memo, + communitySenderIdentifier, + userSenderIdentifier, + userSenderName, + }: SendCoinsArgs, + ): Promise { + logger.debug(`revertSendCoins() via apiVersion=1_0 ...`) + // first check if receiver community is correct + const homeCom = await DbCommunity.findOneBy({ + communityUuid: communityReceiverIdentifier, + }) + if (!homeCom) { + throw new LogError( + `revertSendCoins with wrong communityReceiverIdentifier`, + communityReceiverIdentifier, + ) + } + // second check if receiver user exists in this community + const receiverUser = await DbUser.findOneBy({ gradidoID: userReceiverIdentifier }) + if (!receiverUser) { + throw new LogError( + `revertSendCoins with unknown userReceiverIdentifier in the community=`, + homeCom.name, + ) + } + try { + const pendingTx = await DbPendingTransaction.findOneBy({ + userCommunityUuid: communityReceiverIdentifier, + userGradidoID: userReceiverIdentifier, + state: PendingTransactionState.NEW, + typeId: TransactionTypeId.RECEIVE, + balanceDate: new Date(creationDate), + linkedUserCommunityUuid: communitySenderIdentifier, + linkedUserGradidoID: userSenderIdentifier, + }) + logger.debug('XCom: revertSendCoins found pendingTX=', pendingTx) + if (pendingTx && pendingTx.amount.toString() === amount.toString()) { + logger.debug('XCom: revertSendCoins matching pendingTX for remove...') + try { + await pendingTx.remove() + logger.debug('XCom: revertSendCoins pendingTX for remove successfully') + } catch (err) { + throw new LogError('Error in revertSendCoins on removing pendingTx of receiver: ', err) + } + } else { + logger.debug( + 'XCom: revertSendCoins NOT matching pendingTX for remove:', + pendingTx?.amount, + amount, + ) + throw new LogError( + `Can't find in revertSendCoins the pending receiver TX for args=`, + communityReceiverIdentifier, + userReceiverIdentifier, + PendingTransactionState.NEW, + TransactionTypeId.RECEIVE, + creationDate, + amount, + memo, + communitySenderIdentifier, + userSenderIdentifier, + userSenderName, + ) + } + logger.debug(`revertSendCoins()-1_0... successfull`) + return true + } catch (err) { + throw new LogError(`Error in revertSendCoins: `, err) + } + } +} diff --git a/federation/src/graphql/scalar/Decimal.ts.unused b/federation/src/graphql/scalar/Decimal.ts similarity index 74% rename from federation/src/graphql/scalar/Decimal.ts.unused rename to federation/src/graphql/scalar/Decimal.ts index da5a99e0c..96804bdfa 100644 --- a/federation/src/graphql/scalar/Decimal.ts.unused +++ b/federation/src/graphql/scalar/Decimal.ts @@ -1,7 +1,8 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { Decimal } from 'decimal.js-light' import { GraphQLScalarType, Kind } from 'graphql' -import Decimal from 'decimal.js-light' -export default new GraphQLScalarType({ +export const DecimalScalar = new GraphQLScalarType({ name: 'Decimal', description: 'The `Decimal` scalar type to represent currency values', diff --git a/federation/src/graphql/schema.ts b/federation/src/graphql/schema.ts index 015ea124f..0951c1000 100644 --- a/federation/src/graphql/schema.ts +++ b/federation/src/graphql/schema.ts @@ -2,15 +2,15 @@ import { GraphQLSchema } from 'graphql' import { buildSchema } from 'type-graphql' // import isAuthorized from './directive/isAuthorized' -// import DecimalScalar from './scalar/Decimal' -// import Decimal from 'decimal.js-light' +import { DecimalScalar } from './scalar/Decimal' +import { Decimal } from 'decimal.js-light' import { getApiResolvers } from './api/schema' const schema = async (): Promise => { return await buildSchema({ resolvers: [getApiResolvers()], // authChecker: isAuthorized, - // scalarsMap: [{ type: Decimal, scalar: DecimalScalar }], + scalarsMap: [{ type: Decimal, scalar: DecimalScalar }], }) } diff --git a/federation/src/graphql/util/calculateRecipientBalance.ts b/federation/src/graphql/util/calculateRecipientBalance.ts new file mode 100644 index 000000000..2a9c2aa1c --- /dev/null +++ b/federation/src/graphql/util/calculateRecipientBalance.ts @@ -0,0 +1,20 @@ +import { Decimal } from 'decimal.js-light' + +import { getLastTransaction } from './getLastTransaction' +import { calculateDecay } from './decay' +import { Decay } from '../api/1_0/model/Decay' + +export async function calculateRecipientBalance( + userId: number, + amount: Decimal, + time: Date, +): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { + const lastTransaction = await getLastTransaction(userId) + if (!lastTransaction) return null + + const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) + + const balance = decay.balance.add(amount.toString()) + + return { balance, lastTransactionId: lastTransaction.id, decay } +} diff --git a/federation/src/graphql/util/decay.test.ts b/federation/src/graphql/util/decay.test.ts new file mode 100644 index 000000000..1d4ebab3b --- /dev/null +++ b/federation/src/graphql/util/decay.test.ts @@ -0,0 +1,42 @@ +import { Decimal } from 'decimal.js-light' + +import { decayFormula, calculateDecay } from './decay' + +describe('utils/decay', () => { + describe('decayFormula', () => { + it('has base 0.99999997802044727', () => { + const amount = new Decimal(1.0) + const seconds = 1 + // TODO: toString() was required, we could not compare two decimals + expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012') + }) + it('has correct backward calculation', () => { + const amount = new Decimal(1.0) + const seconds = -1 + expect(decayFormula(amount, seconds).toString()).toBe('1.000000021964959992727444') + }) + // we get pretty close, but not exact here, skipping + // eslint-disable-next-line jest/no-disabled-tests + it.skip('has correct forward calculation', () => { + const amount = new Decimal(1.0).div( + new Decimal('0.99999997803504048973201202316767079413460520837376'), + ) + const seconds = 1 + expect(decayFormula(amount, seconds).toString()).toBe('1.0') + }) + }) + it('has base 0.99999997802044727', () => { + const now = new Date() + now.setSeconds(1) + const oneSecondAgo = new Date(now.getTime()) + oneSecondAgo.setSeconds(0) + expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now).balance.toString()).toBe( + '0.999999978035040489732012', + ) + }) + + it('returns input amount when from and to is the same', () => { + const now = new Date() + expect(calculateDecay(new Decimal(100.0), now, now).balance.toString()).toBe('100') + }) +}) diff --git a/federation/src/graphql/util/decay.ts b/federation/src/graphql/util/decay.ts new file mode 100644 index 000000000..f195075ff --- /dev/null +++ b/federation/src/graphql/util/decay.ts @@ -0,0 +1,65 @@ +import { Decimal } from 'decimal.js-light' + +import { LogError } from '@/server/LogError' +import CONFIG from '@/config' +import { Decay } from '../api/1_0/model/Decay' + +// TODO: externalize all those definitions and functions into an external decay library + +function decayFormula(value: Decimal, seconds: number): Decimal { + // TODO why do we need to convert this here to a stting to work properly? + return value.mul( + new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds).toString(), + ) +} + +function calculateDecay( + amount: Decimal, + from: Date, + to: Date, + startBlock: Date = CONFIG.DECAY_START_TIME, +): Decay { + const fromMs = from.getTime() + const toMs = to.getTime() + const startBlockMs = startBlock.getTime() + + if (toMs < fromMs) { + throw new LogError('calculateDecay: to < from, reverse decay calculation is invalid') + } + + // Initialize with no decay + const decay: Decay = { + balance: amount, + decay: new Decimal(0), + roundedDecay: new Decimal(0), + start: null, + end: null, + duration: null, + } + + // decay started after end date; no decay + if (startBlockMs > toMs) { + return decay + } + // decay started before start date; decay for full duration + if (startBlockMs < fromMs) { + decay.start = from + decay.duration = (toMs - fromMs) / 1000 + } + // decay started between start and end date; decay from decay start till end date + else { + decay.start = startBlock + decay.duration = (toMs - startBlockMs) / 1000 + } + + decay.end = to + decay.balance = decayFormula(amount, decay.duration) + decay.decay = decay.balance.minus(amount) + decay.roundedDecay = amount + .toDecimalPlaces(2, Decimal.ROUND_DOWN) + .minus(decay.balance.toDecimalPlaces(2, Decimal.ROUND_DOWN).toString()) + .mul(-1) + return decay +} + +export { decayFormula, calculateDecay } diff --git a/federation/src/graphql/util/fullName.ts b/federation/src/graphql/util/fullName.ts new file mode 100644 index 000000000..7473f5ed0 --- /dev/null +++ b/federation/src/graphql/util/fullName.ts @@ -0,0 +1,2 @@ +export const fullName = (firstName: string, lastName: string): string => + [firstName, lastName].filter(Boolean).join(' ') diff --git a/federation/src/graphql/util/getLastTransaction.ts b/federation/src/graphql/util/getLastTransaction.ts new file mode 100644 index 000000000..0d7747088 --- /dev/null +++ b/federation/src/graphql/util/getLastTransaction.ts @@ -0,0 +1,12 @@ +import { Transaction as DbTransaction } from '@entity/Transaction' + +export const getLastTransaction = async ( + userId: number, + relations?: string[], +): Promise => { + return DbTransaction.findOne({ + where: { userId }, + order: { balanceDate: 'DESC', id: 'DESC' }, + relations, + }) +} diff --git a/federation/src/server/LogError.ts b/federation/src/server/LogError.ts new file mode 100644 index 000000000..b29e83dc8 --- /dev/null +++ b/federation/src/server/LogError.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { federationLogger as logger } from './logger' + +export class LogError extends Error { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(msg: string, ...details: any[]) { + super(msg) + logger.error(msg, ...details) + } +} diff --git a/federation/test/helpers.test.ts b/federation/test/helpers.test.ts new file mode 100644 index 000000000..69d8f3fa4 --- /dev/null +++ b/federation/test/helpers.test.ts @@ -0,0 +1,7 @@ +import { contributionDateFormatter } from '@test/helpers' + +describe('contributionDateFormatter', () => { + it('formats the date correctly', () => { + expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') + }) +}) diff --git a/federation/test/helpers.ts b/federation/test/helpers.ts new file mode 100644 index 000000000..542906f49 --- /dev/null +++ b/federation/test/helpers.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ + +import { entities } from '@entity/index' +import { createTestClient } from 'apollo-server-testing' + +import createServer from '@/server/createServer' + +import { logger } from './testSetup' + +export const headerPushMock = jest.fn((t) => { + context.token = t.value +}) + +const context = { + token: '', + setHeaders: { + push: headerPushMock, + forEach: jest.fn(), + }, + clientTimezoneOffset: 0, +} + +export const cleanDB = async () => { + // this only works as long we do not have foreign key constraints + for (const entity of entities) { + await resetEntity(entity) + } +} + +export const testEnvironment = async (testLogger = logger) => { + const server = await createServer(testLogger) // context, testLogger, testI18n) + const con = server.con + const testClient = createTestClient(server.apollo) + const mutate = testClient.mutate + const query = testClient.query + return { mutate, query, con } +} + +export const resetEntity = async (entity: any) => { + const items = await entity.find({ withDeleted: true }) + if (items.length > 0) { + const ids = items.map((e: any) => e.id) + await entity.delete(ids) + } +} + +export const resetToken = () => { + context.token = '' +} + +// format date string as it comes from the frontend for the contribution date +export const contributionDateFormatter = (date: Date): string => { + return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}` +} + +export const setClientTimezoneOffset = (offset: number): void => { + context.clientTimezoneOffset = offset +}