Merge branch 'master' into setup-backend-unit-tests

This commit is contained in:
Moriz Wahl 2021-10-01 13:10:15 +02:00
commit 855e1652fb
29 changed files with 729 additions and 4817 deletions

View File

@ -344,7 +344,7 @@ jobs:
report_name: Coverage Frontend
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 67
min_coverage: 69
token: ${{ github.token }}
##############################################################################

4712
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,10 @@ export const isAuthorized: AuthChecker<any> = async ({ root, args, context, info
`${CONFIG.LOGIN_API_URL}checkSessionState?session_id=${decoded.sessionId}`,
)
context.sessionId = decoded.sessionId
context.setHeaders.push({ key: 'token', value: encode(decoded.sessionId) })
context.pubKey = decoded.pubKey
context.setHeaders.push({ key: 'token', value: encode(decoded.sessionId, decoded.pubKey) })
return result.success
}
}
return false
throw new Error('401 Unauthorized')
}

View File

@ -9,7 +9,7 @@ export class TransactionListInput {
items: number
@Field(() => String)
order: string
order: 'ASC' | 'DESC'
}
@ArgsType()

View File

@ -1,29 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field, Int } from 'type-graphql'
import { Transaction } from '../../typeorm/entity/Transaction'
@ObjectType()
export class Decay {
constructor(json: any) {
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.decayStartBlock = json.decay_start_block
if (json) {
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.decayStartBlock = json.decay_start_block
}
}
static async getDecayStartBlock(): Promise<Transaction | undefined> {
if (!this.decayStartBlockTransaction) {
this.decayStartBlockTransaction = await Transaction.getDecayStartBlock()
}
return this.decayStartBlockTransaction
}
@Field(() => Number)
balance: number
// timestamp in seconds
@Field(() => Int, { nullable: true })
decayStart?: number
decayStart: string
// timestamp in seconds
@Field(() => Int, { nullable: true })
decayEnd?: number
decayEnd: string
@Field(() => String, { nullable: true })
decayDuration?: string
decayDuration?: number
@Field(() => Int, { nullable: true })
decayStartBlock?: number
decayStartBlock?: string
static decayStartBlockTransaction: Transaction | undefined
}

View File

@ -10,18 +10,11 @@ import { Decay } from './Decay'
@ObjectType()
export class Transaction {
constructor(json: any) {
this.type = json.type
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.memo = json.memo
this.transactionId = json.transaction_id
this.name = json.name
this.email = json.email
this.date = json.date
this.decay = json.decay ? new Decay(json.decay) : undefined
constructor() {
this.type = ''
this.balance = 0
this.totalBalance = 0
this.memo = ''
}
@Field(() => String)
@ -30,14 +23,17 @@ export class Transaction {
@Field(() => Number)
balance: number
@Field({ nullable: true })
decayStart?: number
@Field(() => Number)
totalBalance: number
@Field({ nullable: true })
decayEnd?: number
decayStart?: string
@Field({ nullable: true })
decayDuration?: string
decayEnd?: string
@Field({ nullable: true })
decayDuration?: number
@Field(() => String)
memo: string
@ -60,15 +56,12 @@ export class Transaction {
@ObjectType()
export class TransactionList {
constructor(json: any) {
this.gdtSum = Number(json.gdtSum)
this.count = json.count
this.balance = Number(json.balance)
this.decay = Number(json.decay)
this.decayDate = json.decay_date
this.transactions = json.transactions.map((el: any) => {
return new Transaction(el)
})
constructor() {
this.gdtSum = 0
this.count = 0
this.balance = 0
this.decay = 0
this.decayDate = ''
}
@Field(() => Number)

View File

@ -2,12 +2,10 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import CONFIG from '../../config'
import { Balance } from '../models/Balance'
import { apiGet } from '../../apis/HttpRequest'
import { User as dbUser } from '../../typeorm/entity/User'
import { Balance as dbBalance } from '../../typeorm/entity/Balance'
import calculateDecay from '../../util/decay'
import { calculateDecay } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
@Resolver()
@ -15,20 +13,26 @@ export class BalanceResolver {
@Authorized()
@Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> {
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
// load user and balance
const userEntity = await dbUser.findByPubkeyHex(result.data.user.public_hex)
const userEntity = await dbUser.findByPubkeyHex(context.pubKey)
const balanceEntity = await dbBalance.findByUser(userEntity.id)
let balance: Balance
const now = new Date()
const balance = new Balance({
balance: roundFloorFrom4(balanceEntity.amount),
decay: roundFloorFrom4(calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now)),
decay_date: now.toString(),
})
if (balanceEntity) {
balance = new Balance({
balance: roundFloorFrom4(balanceEntity.amount),
decay: roundFloorFrom4(
await calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now),
),
decay_date: now.toString(),
})
} else {
balance = new Balance({
balance: 0,
decay: 0,
decay_date: now.toString(),
})
}
return balance
}
}

View File

@ -18,18 +18,14 @@ export class GdtResolver {
{ currentPage = 1, pageSize = 5, order = 'DESC' }: GdtTransactionSessionIdInput,
@Ctx() context: any,
): Promise<GdtEntryList> {
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
// load user
const userEntity = await dbUser.findByPubkeyHex(result.data.user.public_hex)
const userEntity = await dbUser.findByPubkeyHex(context.pubKey)
const resultGDT = await apiGet(
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.email}/${currentPage}/${pageSize}/${order}`,
)
if (!resultGDT.success) {
throw new Error(result.data)
throw new Error(resultGDT.data)
}
return new GdtEntryList(resultGDT.data)

View File

@ -6,6 +6,11 @@ import CONFIG from '../../config'
import { TransactionList } from '../models/Transaction'
import { TransactionListInput, TransactionSendArgs } from '../inputs/TransactionInput'
import { apiGet, apiPost } from '../../apis/HttpRequest'
import { User as dbUser } from '../../typeorm/entity/User'
import { Balance as dbBalance } from '../../typeorm/entity/Balance'
import listTransactions from './listTransactions'
import { roundFloorFrom4 } from '../../util/round'
import { calculateDecay } from '../../util/decay'
@Resolver()
export class TransactionResolver {
@ -15,11 +20,34 @@ export class TransactionResolver {
@Args() { firstPage = 1, items = 25, order = 'DESC' }: TransactionListInput,
@Ctx() context: any,
): Promise<TransactionList> {
const result = await apiGet(
`${CONFIG.COMMUNITY_API_URL}listTransactions/${firstPage}/${items}/${order}/${context.sessionId}`,
)
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
return new TransactionList(result.data)
// load user
const userEntity = await dbUser.findByPubkeyHex(result.data.user.public_hex)
const transactions = await listTransactions(firstPage, items, order, userEntity)
// get gdt sum
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: userEntity.email,
})
if (!resultGDTSum.success) throw new Error(resultGDTSum.data)
transactions.gdtSum = resultGDTSum.data.sum
// get balance
const balanceEntity = await dbBalance.findByUser(userEntity.id)
if (balanceEntity) {
const now = new Date()
transactions.balance = roundFloorFrom4(balanceEntity.amount)
transactions.decay = roundFloorFrom4(
await calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now),
)
transactions.decayDate = now.toString()
}
return transactions
}
@Authorized()

View File

@ -22,6 +22,7 @@ import {
klicktippNewsletterStateMiddleware,
} from '../../middleware/klicktippMiddleware'
import { CheckEmailResponse } from '../models/CheckEmailResponse'
@Resolver()
export class UserResolver {
@Query(() => User)
@ -35,7 +36,10 @@ export class UserResolver {
throw new Error(result.data)
}
context.setHeaders.push({ key: 'token', value: encode(result.data.session_id) })
context.setHeaders.push({
key: 'token',
value: encode(result.data.session_id, result.data.user.public_hex),
})
return new User(result.data.user)
}

View File

@ -0,0 +1,194 @@
import { User as dbUser } from '../../typeorm/entity/User'
import { TransactionList, Transaction } from '../models/Transaction'
import { UserTransaction as dbUserTransaction } from '../../typeorm/entity/UserTransaction'
import { Transaction as dbTransaction } from '../../typeorm/entity/Transaction'
import { Decay } from '../models/Decay'
import { calculateDecayWithInterval } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
async function calculateAndAddDecayTransactions(
userTransactions: dbUserTransaction[],
user: dbUser,
decay: boolean,
skipFirstTransaction: boolean,
): Promise<Transaction[]> {
const finalTransactions: Transaction[] = []
const transactionIds: number[] = []
const involvedUserIds: number[] = []
userTransactions.forEach((userTransaction: dbUserTransaction) => {
transactionIds.push(userTransaction.transactionId)
})
const transactions = await dbTransaction
.createQueryBuilder('transaction')
.where('transaction.id IN (:...transactions)', { transactions: transactionIds })
.leftJoinAndSelect(
'transaction.transactionSendCoin',
'transactionSendCoin',
// 'transactionSendCoin.transaction_id = transaction.id',
)
.leftJoinAndSelect(
'transaction.transactionCreation',
'transactionCreation',
// 'transactionSendCoin.transaction_id = transaction.id',
)
.getMany()
const transactionIndiced: dbTransaction[] = []
transactions.forEach((transaction: dbTransaction) => {
transactionIndiced[transaction.id] = transaction
if (transaction.transactionTypeId === 2) {
involvedUserIds.push(transaction.transactionSendCoin.userId)
involvedUserIds.push(transaction.transactionSendCoin.recipiantUserId)
}
})
// remove duplicates
// https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i)
const userIndiced = await dbUser.getUsersIndiced(involvedUsersUnique)
const decayStartTransaction = await Decay.getDecayStartBlock()
for (let i = 0; i < userTransactions.length; i++) {
const userTransaction = userTransactions[i]
const transaction = transactionIndiced[userTransaction.transactionId]
const finalTransaction = new Transaction()
finalTransaction.transactionId = transaction.id
finalTransaction.date = transaction.received.toISOString()
finalTransaction.memo = transaction.memo
finalTransaction.totalBalance = roundFloorFrom4(userTransaction.balance)
const prev = i > 0 ? userTransactions[i - 1] : null
if (prev && prev.balance > 0) {
const current = userTransaction
const decay = await calculateDecayWithInterval(
prev.balance,
prev.balanceDate,
current.balanceDate,
)
const balance = prev.balance - decay.balance
if (balance) {
finalTransaction.decay = decay
finalTransaction.decay.balance = roundFloorFrom4(balance)
if (
decayStartTransaction &&
prev.transactionId < decayStartTransaction.id &&
current.transactionId > decayStartTransaction.id
) {
finalTransaction.decay.decayStartBlock = (
decayStartTransaction.received.getTime() / 1000
).toString()
}
}
}
// sender or receiver when user has sended money
// group name if creation
// type: gesendet / empfangen / geschöpft
// transaktion nr / id
// date
// balance
if (userTransaction.transactionTypeId === 1) {
// creation
const creation = transaction.transactionCreation
finalTransaction.name = 'Gradido Akademie'
finalTransaction.type = 'creation'
// finalTransaction.targetDate = creation.targetDate
finalTransaction.balance = roundFloorFrom4(creation.amount)
} else if (userTransaction.transactionTypeId === 2) {
// send coin
const sendCoin = transaction.transactionSendCoin
let otherUser: dbUser | undefined
finalTransaction.balance = roundFloorFrom4(sendCoin.amount)
if (sendCoin.userId === user.id) {
finalTransaction.type = 'send'
otherUser = userIndiced[sendCoin.recipiantUserId]
// finalTransaction.pubkey = sendCoin.recipiantPublic
} else if (sendCoin.recipiantUserId === user.id) {
finalTransaction.type = 'receive'
otherUser = userIndiced[sendCoin.userId]
// finalTransaction.pubkey = sendCoin.senderPublic
} else {
throw new Error('invalid transaction')
}
if (otherUser) {
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
finalTransaction.email = otherUser.email
}
}
if (i > 0 || !skipFirstTransaction) {
finalTransactions.push(finalTransaction)
}
if (i === userTransactions.length - 1 && decay) {
const now = new Date()
const decay = await calculateDecayWithInterval(
userTransaction.balance,
userTransaction.balanceDate,
now.getTime(),
)
const balance = userTransaction.balance - decay.balance
if (balance) {
const decayTransaction = new Transaction()
decayTransaction.type = 'decay'
decayTransaction.balance = roundFloorFrom4(balance)
decayTransaction.decayDuration = decay.decayDuration
decayTransaction.decayStart = decay.decayStart
decayTransaction.decayEnd = decay.decayEnd
finalTransactions.push(decayTransaction)
}
}
}
return finalTransactions
}
export default async function listTransactions(
firstPage: number,
items: number,
order: 'ASC' | 'DESC',
user: dbUser,
): Promise<TransactionList> {
let limit = items
let offset = 0
let skipFirstTransaction = false
if (firstPage > 1) {
offset = (firstPage - 1) * items - 1
limit++
}
if (offset && order === 'ASC') {
offset--
}
let [userTransactions, userTransactionsCount] = await dbUserTransaction.findByUserPaged(
user.id,
limit,
offset,
order,
)
skipFirstTransaction = userTransactionsCount > offset + limit
const decay = !(firstPage > 1)
let transactions: Transaction[] = []
if (userTransactions.length) {
if (order === 'DESC') {
userTransactions = userTransactions.reverse()
}
transactions = await calculateAndAddDecayTransactions(
userTransactions,
user,
decay,
skipFirstTransaction,
)
if (order === 'DESC') {
transactions = transactions.reverse()
}
}
const transactionList = new TransactionList()
transactionList.count = userTransactionsCount
transactionList.transactions = transactions
return transactionList
}

View File

@ -1,18 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import jwt from 'jsonwebtoken'
import jwt, { JwtPayload } from 'jsonwebtoken'
import CONFIG from '../config/'
export default (token: string): any => {
if (!token) return new Error('401 Unauthorized')
interface CustomJwtPayload extends JwtPayload {
sessionId: number
pubKey: Buffer
}
type DecodedJwt = {
token: string
sessionId: number
pubKey: Buffer
}
export default (token: string): DecodedJwt => {
if (!token) throw new Error('401 Unauthorized')
let sessionId = null
let pubKey = null
try {
const decoded = jwt.verify(token, CONFIG.JWT_SECRET)
sessionId = decoded.sub
const decoded = <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
sessionId = decoded.sessionId
pubKey = decoded.pubKey
return {
token,
sessionId,
pubKey,
}
} catch (err) {
throw new Error('403.13 - Client certificate revoked')

View File

@ -5,8 +5,8 @@ import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
// Generate an Access Token
export default function encode(sessionId: string): string {
const token = jwt.sign({ sessionId }, CONFIG.JWT_SECRET, {
export default function encode(sessionId: number, pubKey: Buffer): string {
const token = jwt.sign({ sessionId, pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
subject: sessionId.toString(),
})

View File

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const context = (args: any) => {
const authorization = args.req.headers.authorization
let token = null

View File

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const plugins = [
{
requestDidStart() {

View File

@ -17,9 +17,7 @@ export class Balance extends BaseEntity {
@Column({ type: 'bigint' })
amount: number
static findByUser(userId: number): Promise<Balance> {
return this.createQueryBuilder('balance')
.where('balance.userId = :userId', { userId })
.getOneOrFail()
static findByUser(userId: number): Promise<Balance | undefined> {
return this.createQueryBuilder('balance').where('balance.userId = :userId', { userId }).getOne()
}
}

View File

@ -0,0 +1,45 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm'
import { TransactionCreation } from './TransactionCreation'
import { TransactionSendCoin } from './TransactionSendCoin'
@Entity('transactions')
export class Transaction extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_type_id' })
transactionTypeId: number
@Column({ name: 'tx_hash', type: 'binary', length: 48 })
txHash: Buffer
@Column()
memo: string
@Column({ type: 'timestamp' })
received: Date
@Column({ name: 'blockchain_type_id' })
blockchainTypeId: number
@OneToOne(() => TransactionSendCoin, (transactionSendCoin) => transactionSendCoin.transaction)
transactionSendCoin: TransactionSendCoin
@OneToOne(() => TransactionCreation, (transactionCreation) => transactionCreation.transaction)
transactionCreation: TransactionCreation
static async findByTransactionTypeId(transactionTypeId: number): Promise<Transaction[]> {
return this.createQueryBuilder('transaction')
.where('transaction.transactionTypeId = :transactionTypeId', {
transactionTypeId: transactionTypeId,
})
.getMany()
}
static async getDecayStartBlock(): Promise<Transaction | undefined> {
return this.createQueryBuilder('transaction')
.where('transaction.transactionTypeId = :transactionTypeId', { transactionTypeId: 9 })
.orderBy('received', 'ASC')
.getOne()
}
}

View File

@ -0,0 +1,32 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
Timestamp,
OneToOne,
JoinColumn,
} from 'typeorm'
import { Transaction } from './Transaction'
@Entity('transaction_creations')
export class TransactionCreation extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'state_user_id' })
userId: number
@Column()
amount: number
@Column({ name: 'target_date', type: 'timestamp' })
targetDate: Timestamp
@OneToOne(() => Transaction)
@JoinColumn({ name: 'transaction_id' })
transaction: Transaction
}

View File

@ -0,0 +1,30 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
import { Transaction } from './Transaction'
@Entity('transaction_send_coins')
export class TransactionSendCoin extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'sender_public_key', type: 'binary', length: 32 })
senderPublic: Buffer
@Column({ name: 'state_user_id' })
userId: number
@Column({ name: 'receiver_public_key', type: 'binary', length: 32 })
recipiantPublic: Buffer
@Column({ name: 'receiver_user_id' })
recipiantUserId: number
@Column()
amount: number
@OneToOne(() => Transaction)
@JoinColumn({ name: 'transaction_id' })
transaction: Transaction
}

View File

@ -1,6 +1,8 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
// import { Group } from "./Group"
// Moriz: I do not like the idea of having two user tables
@Entity('state_users')
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
@ -27,9 +29,24 @@ export class User extends BaseEntity {
@Column()
disabled: boolean
// Moriz: I am voting for the data mapper implementation.
// see: https://typeorm.io/#/active-record-data-mapper/what-is-the-data-mapper-pattern
// We should discuss this ASAP
static findByPubkeyHex(pubkeyHex: string): Promise<User> {
return this.createQueryBuilder('user')
.where('hex(user.pubkey) = :pubkeyHex', { pubkeyHex })
.getOneOrFail()
}
static async getUsersIndiced(userIds: number[]): Promise<User[]> {
const users = await this.createQueryBuilder('user')
.select(['user.id', 'user.firstName', 'user.lastName', 'user.email'])
.where('user.id IN (:...users)', { users: userIds })
.getMany()
const usersIndiced: User[] = []
users.forEach((value) => {
usersIndiced[value.id] = value
})
return usersIndiced
}
}

View File

@ -0,0 +1,36 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('state_user_transactions')
export class UserTransaction extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'state_user_id' })
userId: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'transaction_type_id' })
transactionTypeId: number
@Column({ name: 'balance', type: 'bigint' })
balance: number
@Column({ name: 'balance_date', type: 'timestamp' })
balanceDate: Date
static findByUserPaged(
userId: number,
limit: number,
offset: number,
order: 'ASC' | 'DESC',
): Promise<[UserTransaction[], number]> {
return this.createQueryBuilder('userTransaction')
.where('userTransaction.userId = :userId', { userId })
.orderBy('userTransaction.balanceDate', order)
.limit(limit)
.offset(offset)
.getManyAndCount()
}
}

View File

@ -1,6 +1,55 @@
export default function (amount: number, from: Date, to: Date): number {
// what happens when from > to
// Do we want to have negative decay?
const decayDuration = (to.getTime() - from.getTime()) / 1000
return amount * Math.pow(0.99999997802044727, decayDuration)
import { Decay } from '../graphql/models/Decay'
function decayFormula(amount: number, durationInSeconds: number): number {
return amount * Math.pow(0.99999997802044727, durationInSeconds)
}
async function calculateDecay(amount: number, from: Date, to: Date): Promise<number> {
// load decay start block
const decayStartBlock = await Decay.getDecayStartBlock()
// if decay hasn't started yet we return input amount
if (!decayStartBlock) return amount
const decayDuration = (to.getTime() - from.getTime()) / 1000
return decayFormula(amount, decayDuration)
}
async function calculateDecayWithInterval(
amount: number,
from: number | Date,
to: number | Date,
): Promise<Decay> {
const decayStartBlock = await Decay.getDecayStartBlock()
const result = new Decay(undefined)
result.balance = amount
const fromMillis = typeof from === 'number' ? from : from.getTime()
const toMillis = typeof to === 'number' ? to : to.getTime()
result.decayStart = (fromMillis / 1000).toString()
result.decayEnd = (toMillis / 1000).toString()
// (amount, from.getTime(), to.getTime())
// if no decay start block exist or decay startet after end date
if (!decayStartBlock || decayStartBlock.received.getTime() > toMillis) {
return result
}
const decayStartBlockMillis = decayStartBlock.received.getTime()
// if decay start date is before start date we calculate decay for full duration
if (decayStartBlockMillis < fromMillis) {
result.decayDuration = toMillis - fromMillis
}
// if decay start in between start date and end date we caculcate decay from decay start time to end date
else {
result.decayDuration = toMillis - decayStartBlockMillis
result.decayStart = (decayStartBlockMillis / 1000).toString()
}
// js use timestamp in milliseconds but we calculate with seconds
result.decayDuration /= 1000
result.balance = decayFormula(amount, result.decayDuration)
return result
}
export { calculateDecay, calculateDecayWithInterval }

View File

@ -114,14 +114,14 @@ export const loadAllRules = (i18nCallback) => {
extend('atLeastOneSpecialCharater', {
validate(value) {
return !!value.match(/[^a-zA-Z0-9]/)
return !!value.match(/[^a-zA-Z0-9 \t\n\r]/)
},
message: (_, values) => i18nCallback.t('site.signup.special-char', values),
})
extend('noWhitespaceCharacters', {
validate(value) {
return !!value.match(/[^ \t\n\r]/)
return !value.match(/[ \t\n\r]+/)
},
message: (_, values) => i18nCallback.t('site.signup.no-whitespace', values),
})

View File

@ -32,6 +32,9 @@ describe('DashboardLayoutGdd', () => {
},
$router: {
push: routerPushMock,
currentRoute: {
path: '/overview',
},
},
$toasted: {
error: toasterMock,
@ -143,21 +146,23 @@ describe('DashboardLayoutGdd', () => {
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
})
describe('logout fails', () => {
beforeEach(() => {
apolloMock.mockRejectedValue({
message: 'error',
})
describe('logout fails', () => {
beforeEach(async () => {
apolloMock.mockRejectedValue({
message: 'error',
})
await wrapper.findComponent({ name: 'sidebar' }).vm.$emit('logout')
await flushPromises()
})
it('dispatches logout to store', () => {
expect(storeDispatchMock).toBeCalledWith('logout')
})
it('dispatches logout to store', () => {
expect(storeDispatchMock).toBeCalledWith('logout')
})
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
})

View File

@ -101,7 +101,7 @@ export default {
.catch(() => {
this.$sidebar.displaySidebar(false)
this.$store.dispatch('logout')
this.$router.push('/login')
if (this.$router.currentRoute.path !== '/login') this.$router.push('/login')
})
},
async updateTransactions(pagination) {

View File

@ -105,13 +105,20 @@ describe('UserCard_FormUserPasswort', () => {
describe('validation', () => {
it('displays all password requirements', () => {
const feedbackArray = wrapper.findAll('div.invalid-feedback').at(1).findAll('span')
expect(feedbackArray).toHaveLength(7)
expect(feedbackArray).toHaveLength(6)
expect(feedbackArray.at(0).text()).toBe('validations.messages.required')
expect(feedbackArray.at(1).text()).toBe('site.signup.lowercase')
expect(feedbackArray.at(2).text()).toBe('site.signup.uppercase')
expect(feedbackArray.at(3).text()).toBe('site.signup.one_number')
expect(feedbackArray.at(4).text()).toBe('site.signup.minimum')
expect(feedbackArray.at(5).text()).toBe('site.signup.special-char')
})
it('displays no whitespace error when a space character is entered', async () => {
await wrapper.findAll('input').at(1).setValue(' ')
await flushPromises()
const feedbackArray = wrapper.findAll('div.invalid-feedback').at(1).findAll('span')
expect(feedbackArray).toHaveLength(7)
expect(feedbackArray.at(6).text()).toBe('site.signup.no-whitespace')
})

View File

@ -3,7 +3,13 @@ import UserCardLanguage from './UserCard_Language'
const localVue = global.localVue
const mockAPIcall = jest.fn()
const mockAPIcall = jest.fn().mockResolvedValue({
data: {
updateUserInfos: {
validValues: 1,
},
},
})
const toastErrorMock = jest.fn()
const toastSuccessMock = jest.fn()
@ -17,6 +23,7 @@ describe('UserCard_Language', () => {
$store: {
state: {
language: 'de',
email: 'peter@lustig.de',
},
commit: storeCommitMock,
},
@ -27,6 +34,9 @@ describe('UserCard_Language', () => {
$apollo: {
mutate: mockAPIcall,
},
$i18n: {
locale: 'de',
},
}
const Wrapper = () => {
@ -45,5 +55,119 @@ describe('UserCard_Language', () => {
it('has an edit icon', () => {
expect(wrapper.find('svg.bi-pencil').exists()).toBeTruthy()
})
it('has change language as text', () => {
expect(wrapper.find('a').text()).toBe('settings.language.changeLanguage')
})
it('has no select field by default', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
describe('edit button', () => {
beforeEach(() => {
wrapper.find('a').trigger('click')
})
it('has no edit icon anymore', () => {
expect(wrapper.find('svg.bi-pencil').exists()).toBeFalsy()
})
it('has x-circle icon', () => {
expect(wrapper.find('svg.bi-x-circle').exists()).toBeTruthy()
})
it('has a submit button', () => {
expect(wrapper.find('button[type="submit"]').exists()).toBeTruthy()
})
it('has the submit button disbaled by default', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
describe('change language', () => {
it('does not enable the submit button when same language is chosen', () => {
wrapper.findAll('option').at(0).setSelected()
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
it('enables the submit button when other language is chosen', async () => {
await wrapper.findAll('option').at(1).setSelected()
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe(undefined)
})
it('updates language data in component', async () => {
await wrapper.findAll('option').at(1).setSelected()
expect(wrapper.vm.language).toBe('en')
})
describe('cancel edit', () => {
beforeEach(async () => {
await wrapper.findAll('option').at(1).setSelected()
wrapper.find('a').trigger('click')
})
it('sets the language to initial value', () => {
expect(wrapper.vm.language).toBe('de')
})
it('has no select field anymore', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
})
describe('submit', () => {
beforeEach(async () => {
await wrapper.findAll('option').at(1).setSelected()
wrapper.find('form').trigger('submit')
})
describe('with success', () => {
it('calls the API', () => {
expect(mockAPIcall).toBeCalledWith(
expect.objectContaining({
variables: {
email: 'peter@lustig.de',
locale: 'en',
},
}),
)
})
it('commits new language to store', () => {
expect(storeCommitMock).toBeCalledWith('language', 'en')
})
it('changes the i18n locale', () => {
expect(mocks.$i18n.locale).toBe('en')
})
it('has no select field anymore', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('settings.language.success')
})
})
describe('with error', () => {
beforeEach(() => {
mockAPIcall.mockRejectedValue({
message: 'Ouch!',
})
})
it('sets the language back to initial value', () => {
expect(wrapper.vm.language).toBe('de')
})
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch!')
})
})
})
})
})
})
})

View File

@ -82,6 +82,7 @@ export default {
},
cancelEdit() {
this.showLanguage = true
this.language = this.$store.state.language
},
async onSubmit() {
this.$apollo
@ -104,7 +105,6 @@ export default {
this.$toasted.error(error.message)
})
},
buildTagFromLanguageString() {
return 'languages.' + this.$store.state.language
},

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import UserCardNewsletter from './UserCard_Newsletter'
import { unsubscribeNewsletter } from '../../../graphql/mutations'
import { unsubscribeNewsletter, subscribeNewsletter } from '../../../graphql/mutations'
const localVue = global.localVue
@ -9,7 +9,6 @@ const mockAPIcall = jest.fn()
const toastErrorMock = jest.fn()
const toastSuccessMock = jest.fn()
const storeCommitMock = jest.fn()
const newsletterStateMock = jest.fn().mockReturnValue(true)
describe('UserCard_Newsletter', () => {
let wrapper
@ -20,7 +19,7 @@ describe('UserCard_Newsletter', () => {
state: {
language: 'de',
email: 'peter@lustig.de',
newsletterState: newsletterStateMock,
newsletterState: true,
},
commit: storeCommitMock,
},
@ -50,14 +49,15 @@ describe('UserCard_Newsletter', () => {
expect(wrapper.find('.Test-BFormCheckbox').exists()).toBeTruthy()
})
describe('unsubscribe with sucess', () => {
beforeEach(() => {
describe('unsubscribe with success', () => {
beforeEach(async () => {
await wrapper.setData({ newsletterState: false })
mockAPIcall.mockResolvedValue({
data: {
unsubscribeNewsletter: true,
},
})
wrapper.find('input').trigger('change')
await wrapper.find('input').trigger('change')
})
it('calls the unsubscribe mutation', () => {
@ -78,6 +78,36 @@ describe('UserCard_Newsletter', () => {
})
})
describe('subscribe with success', () => {
beforeEach(async () => {
await wrapper.setData({ newsletterState: true })
mockAPIcall.mockResolvedValue({
data: {
subscribeNewsletter: true,
},
})
wrapper.find('input').trigger('change')
})
it('calls the subscribe mutation', () => {
expect(mockAPIcall).toBeCalledWith({
mutation: subscribeNewsletter,
variables: {
email: 'peter@lustig.de',
language: 'de',
},
})
})
it('updates the store', () => {
expect(storeCommitMock).toBeCalledWith('newsletterState', true)
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('settings.newsletter.newsletterFalse')
})
})
describe('unsubscribe with server error', () => {
beforeEach(() => {
mockAPIcall.mockRejectedValue({