From db1a11a2b5dba3a159c016c1a30b06419176e4d8 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 22 Nov 2022 12:41:08 +0100 Subject: [PATCH 01/16] seperate admin resolver into existing resolvers --- backend/src/graphql/resolver/AdminResolver.ts | 918 ------------------ .../resolver/ContributionMessageResolver.ts | 80 +- .../graphql/resolver/ContributionResolver.ts | 446 ++++++++- .../resolver/TransactionLinkResolver.ts | 197 +++- backend/src/graphql/resolver/UserResolver.ts | 213 +++- 5 files changed, 910 insertions(+), 944 deletions(-) delete mode 100644 backend/src/graphql/resolver/AdminResolver.ts diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts deleted file mode 100644 index 80c69a864..000000000 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ /dev/null @@ -1,918 +0,0 @@ -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { backendLogger as logger } from '@/server/logger' -import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' -import { - getCustomRepository, - IsNull, - getConnection, - In, - MoreThan, - FindOperator, -} from '@dbTools/typeorm' -import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' -import { UnconfirmedContribution } from '@model/UnconfirmedContribution' -import { AdminCreateContributions } from '@model/AdminCreateContributions' -import { AdminUpdateContribution } from '@model/AdminUpdateContribution' -import { ContributionLink } from '@model/ContributionLink' -import { ContributionLinkList } from '@model/ContributionLinkList' -import { Contribution } from '@model/Contribution' -import { RIGHTS } from '@/auth/RIGHTS' -import { UserRepository } from '@repository/User' -import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' -import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' -import SearchUsersArgs from '@arg/SearchUsersArgs' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { calculateDecay } from '@/util/decay' -import { Contribution as DbContribution } from '@entity/Contribution' -import { hasElopageBuys } from '@/util/hasElopageBuys' -import { User as dbUser } from '@entity/User' -import { User } from '@model/User' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionType } from '@enum/ContributionType' -import { ContributionStatus } from '@enum/ContributionStatus' -import Decimal from 'decimal.js-light' -import { Decay } from '@model/Decay' -import Paginated from '@arg/Paginated' -import TransactionLinkFilters from '@arg/TransactionLinkFilters' -import { Order } from '@enum/Order' -import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver' -import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' -import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' -import CONFIG from '@/config' -import { - getUserCreation, - getUserCreations, - validateContribution, - isStartEndDateValid, - updateCreations, - isValidDateString, -} from './util/creations' -import { - CONTRIBUTIONLINK_NAME_MAX_CHARS, - CONTRIBUTIONLINK_NAME_MIN_CHARS, - FULL_CREATION_AVAILABLE, - MEMO_MAX_CHARS, - MEMO_MIN_CHARS, -} from './const/const' -import { UserContact } from '@entity/UserContact' -import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' -import ContributionMessageArgs from '@arg/ContributionMessageArgs' -import { ContributionMessageType } from '@enum/MessageType' -import { ContributionMessage } from '@model/ContributionMessage' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' -import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' -import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' -import { eventProtocol } from '@/event/EventProtocolEmitter' -import { - Event, - EventAdminContributionCreate, - EventAdminContributionDelete, - EventAdminContributionUpdate, - EventContributionConfirm, - EventSendConfirmationEmail, -} from '@/event/Event' -import { ContributionListResult } from '../model/Contribution' - -// const EMAIL_OPT_IN_REGISTER = 1 -// const EMAIL_OPT_UNKNOWN = 3 // elopage? - -@Resolver() -export class AdminResolver { - @Authorized([RIGHTS.SEARCH_USERS]) - @Query(() => SearchUsersResult) - async searchUsers( - @Args() - { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const userRepository = getCustomRepository(UserRepository) - const userFields = [ - 'id', - 'firstName', - 'lastName', - 'emailId', - 'emailContact', - 'deletedAt', - 'isAdmin', - ] - const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered( - userFields.map((fieldName) => { - return 'user.' + fieldName - }), - searchText, - filters, - currentPage, - pageSize, - ) - - if (users.length === 0) { - return { - userCount: 0, - userList: [], - } - } - - const creations = await getUserCreations( - users.map((u) => u.id), - clientTimezoneOffset, - ) - - const adminUsers = await Promise.all( - users.map(async (user) => { - let emailConfirmationSend = '' - if (!user.emailContact.emailChecked) { - if (user.emailContact.updatedAt) { - emailConfirmationSend = user.emailContact.updatedAt.toISOString() - } else { - emailConfirmationSend = user.emailContact.createdAt.toISOString() - } - } - const userCreations = creations.find((c) => c.id === user.id) - const adminUser = new UserAdmin( - user, - userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, - await hasElopageBuys(user.emailContact.email), - emailConfirmationSend, - ) - return adminUser - }), - ) - return { - userCount: count, - userList: adminUsers, - } - } - - @Authorized([RIGHTS.SET_USER_ROLE]) - @Mutation(() => Date, { nullable: true }) - async setUserRole( - @Arg('userId', () => Int) - userId: number, - @Arg('isAdmin', () => Boolean) - isAdmin: boolean, - @Ctx() - context: Context, - ): Promise { - const user = await dbUser.findOne({ id: userId }) - // user exists ? - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - // administrator user changes own role? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { - logger.error('Administrator can not change his own role!') - throw new Error('Administrator can not change his own role!') - } - // change isAdmin - switch (user.isAdmin) { - case null: - if (isAdmin === true) { - user.isAdmin = new Date() - } else { - logger.error('User is already a usual user!') - throw new Error('User is already a usual user!') - } - break - default: - if (isAdmin === false) { - user.isAdmin = null - } else { - logger.error('User is already admin!') - throw new Error('User is already admin!') - } - break - } - await user.save() - const newUser = await dbUser.findOne({ id: userId }) - return newUser ? newUser.isAdmin : null - } - - @Authorized([RIGHTS.DELETE_USER]) - @Mutation(() => Date, { nullable: true }) - async deleteUser( - @Arg('userId', () => Int) userId: number, - @Ctx() context: Context, - ): Promise { - const user = await dbUser.findOne({ id: userId }) - // user exists ? - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - // moderator user disabled own account? - const moderatorUser = getUser(context) - if (moderatorUser.id === userId) { - logger.error('Moderator can not delete his own account!') - throw new Error('Moderator can not delete his own account!') - } - // soft-delete user - await user.softRemove() - const newUser = await dbUser.findOne({ id: userId }, { withDeleted: true }) - return newUser ? newUser.deletedAt : null - } - - @Authorized([RIGHTS.UNDELETE_USER]) - @Mutation(() => Date, { nullable: true }) - async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { - const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) - if (!user) { - logger.error(`Could not find user with userId: ${userId}`) - throw new Error(`Could not find user with userId: ${userId}`) - } - if (!user.deletedAt) { - logger.error('User is not deleted') - throw new Error('User is not deleted') - } - await user.recover() - return null - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION]) - @Mutation(() => [Number]) - async adminCreateContribution( - @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, - @Ctx() context: Context, - ): Promise { - logger.info( - `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, - ) - const clientTimezoneOffset = getClientTimezoneOffset(context) - if (!isValidDateString(creationDate)) { - logger.error(`invalid Date for creationDate=${creationDate}`) - throw new Error(`invalid Date for creationDate=${creationDate}`) - } - const emailContact = await UserContact.findOne({ - where: { email }, - withDeleted: true, - relations: ['user'], - }) - if (!emailContact) { - logger.error(`Could not find user with email: ${email}`) - throw new Error(`Could not find user with email: ${email}`) - } - if (emailContact.deletedAt) { - logger.error('This emailContact was deleted. Cannot create a contribution.') - throw new Error('This emailContact was deleted. Cannot create a contribution.') - } - if (emailContact.user.deletedAt) { - logger.error('This user was deleted. Cannot create a contribution.') - throw new Error('This user was deleted. Cannot create a contribution.') - } - if (!emailContact.emailChecked) { - logger.error('Contribution could not be saved, Email is not activated') - throw new Error('Contribution could not be saved, Email is not activated') - } - - const event = new Event() - const moderator = getUser(context) - logger.trace('moderator: ', moderator.id) - const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) - logger.trace('creations:', creations) - const creationDateObj = new Date(creationDate) - logger.trace('creationDateObj:', creationDateObj) - validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - const contribution = DbContribution.create() - contribution.userId = emailContact.userId - contribution.amount = amount - contribution.createdAt = new Date() - contribution.contributionDate = creationDateObj - contribution.memo = memo - contribution.moderatorId = moderator.id - contribution.contributionType = ContributionType.ADMIN - contribution.contributionStatus = ContributionStatus.PENDING - - logger.trace('contribution to save', contribution) - - await DbContribution.save(contribution) - - const eventAdminCreateContribution = new EventAdminContributionCreate() - eventAdminCreateContribution.userId = moderator.id - eventAdminCreateContribution.amount = amount - eventAdminCreateContribution.contributionId = contribution.id - await eventProtocol.writeEvent( - event.setEventAdminContributionCreate(eventAdminCreateContribution), - ) - - return getUserCreation(emailContact.userId, clientTimezoneOffset) - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) - @Mutation(() => AdminCreateContributions) - async adminCreateContributions( - @Arg('pendingCreations', () => [AdminCreateContributionArgs]) - contributions: AdminCreateContributionArgs[], - @Ctx() context: Context, - ): Promise { - let success = false - const successfulContribution: string[] = [] - const failedContribution: string[] = [] - for (const contribution of contributions) { - await this.adminCreateContribution(contribution, context) - .then(() => { - successfulContribution.push(contribution.email) - success = true - }) - .catch(() => { - failedContribution.push(contribution.email) - }) - } - return { - success, - successfulContribution, - failedContribution, - } - } - - @Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION]) - @Mutation(() => AdminUpdateContribution) - async adminUpdateContribution( - @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const emailContact = await UserContact.findOne({ - where: { email }, - withDeleted: true, - relations: ['user'], - }) - if (!emailContact) { - logger.error(`Could not find UserContact with email: ${email}`) - throw new Error(`Could not find UserContact with email: ${email}`) - } - const user = emailContact.user - if (!user) { - logger.error(`Could not find User to emailContact: ${email}`) - throw new Error(`Could not find User to emailContact: ${email}`) - } - if (user.deletedAt) { - logger.error(`User was deleted (${email})`) - throw new Error(`User was deleted (${email})`) - } - - const moderator = getUser(context) - - const contributionToUpdate = await DbContribution.findOne({ - where: { id, confirmedAt: IsNull() }, - }) - if (!contributionToUpdate) { - logger.error('No contribution found to given id.') - throw new Error('No contribution found to given id.') - } - - if (contributionToUpdate.userId !== user.id) { - logger.error('user of the pending contribution and send user does not correspond') - throw new Error('user of the pending contribution and send user does not correspond') - } - - if (contributionToUpdate.moderatorId === null) { - logger.error('An admin is not allowed to update a user contribution.') - throw new Error('An admin is not allowed to update a user contribution.') - } - - const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id, clientTimezoneOffset) - - if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) - } else { - logger.error('Currently the month of the contribution cannot change.') - throw new Error('Currently the month of the contribution cannot change.') - } - - // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - contributionToUpdate.amount = amount - contributionToUpdate.memo = memo - contributionToUpdate.contributionDate = new Date(creationDate) - contributionToUpdate.moderatorId = moderator.id - contributionToUpdate.contributionStatus = ContributionStatus.PENDING - - await DbContribution.save(contributionToUpdate) - - const result = new AdminUpdateContribution() - result.amount = amount - result.memo = contributionToUpdate.memo - result.date = contributionToUpdate.contributionDate - - result.creation = await getUserCreation(user.id, clientTimezoneOffset) - - const event = new Event() - const eventAdminContributionUpdate = new EventAdminContributionUpdate() - eventAdminContributionUpdate.userId = user.id - eventAdminContributionUpdate.amount = amount - eventAdminContributionUpdate.contributionId = contributionToUpdate.id - await eventProtocol.writeEvent( - event.setEventAdminContributionUpdate(eventAdminContributionUpdate), - ) - - return result - } - - @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) - @Query(() => [UnconfirmedContribution]) - async listUnconfirmedContributions(@Ctx() context: Context): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const contributions = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .leftJoinAndSelect('c.messages', 'm') - .where({ confirmedAt: IsNull() }) - .getMany() - - if (contributions.length === 0) { - return [] - } - - const userIds = contributions.map((p) => p.userId) - const userCreations = await getUserCreations(userIds, clientTimezoneOffset) - const users = await dbUser.find({ - where: { id: In(userIds) }, - withDeleted: true, - relations: ['emailContact'], - }) - - return contributions.map((contribution) => { - const user = users.find((u) => u.id === contribution.userId) - const creation = userCreations.find((c) => c.id === contribution.userId) - - return new UnconfirmedContribution( - contribution, - user, - creation ? creation.creations : FULL_CREATION_AVAILABLE, - ) - }) - } - - @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) - @Mutation(() => Boolean) - async adminDeleteContribution( - @Arg('id', () => Int) id: number, - @Ctx() context: Context, - ): Promise { - const contribution = await DbContribution.findOne(id) - if (!contribution) { - logger.error(`Contribution not found for given id: ${id}`) - throw new Error('Contribution not found for given id.') - } - const moderator = getUser(context) - if ( - contribution.contributionType === ContributionType.USER && - contribution.userId === moderator.id - ) { - throw new Error('Own contribution can not be deleted as admin') - } - const user = await dbUser.findOneOrFail( - { id: contribution.userId }, - { relations: ['emailContact'] }, - ) - contribution.contributionStatus = ContributionStatus.DELETED - contribution.deletedBy = moderator.id - await contribution.save() - const res = await contribution.softRemove() - - const event = new Event() - const eventAdminContributionDelete = new EventAdminContributionDelete() - eventAdminContributionDelete.userId = contribution.userId - eventAdminContributionDelete.amount = contribution.amount - eventAdminContributionDelete.contributionId = contribution.id - await eventProtocol.writeEvent( - event.setEventAdminContributionDelete(eventAdminContributionDelete), - ) - sendContributionRejectedEmail({ - senderFirstName: moderator.firstName, - senderLastName: moderator.lastName, - recipientEmail: user.emailContact.email, - recipientFirstName: user.firstName, - recipientLastName: user.lastName, - contributionMemo: contribution.memo, - contributionAmount: contribution.amount, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, - }) - - return !!res - } - - @Authorized([RIGHTS.CONFIRM_CONTRIBUTION]) - @Mutation(() => Boolean) - async confirmContribution( - @Arg('id', () => Int) id: number, - @Ctx() context: Context, - ): Promise { - const clientTimezoneOffset = getClientTimezoneOffset(context) - const contribution = await DbContribution.findOne(id) - if (!contribution) { - logger.error(`Contribution not found for given id: ${id}`) - throw new Error('Contribution not found to given id.') - } - const moderatorUser = getUser(context) - if (moderatorUser.id === contribution.userId) { - logger.error('Moderator can not confirm own contribution') - throw new Error('Moderator can not confirm own contribution') - } - const user = await dbUser.findOneOrFail( - { id: contribution.userId }, - { withDeleted: true, relations: ['emailContact'] }, - ) - if (user.deletedAt) { - logger.error('This user was deleted. Cannot confirm a contribution.') - throw new Error('This user was deleted. Cannot confirm a contribution.') - } - const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) - validateContribution( - creations, - contribution.amount, - contribution.contributionDate, - clientTimezoneOffset, - ) - - const receivedCallDate = new Date() - - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') - try { - const lastTransaction = await queryRunner.manager - .createQueryBuilder() - .select('transaction') - .from(DbTransaction, 'transaction') - .where('transaction.userId = :id', { id: contribution.userId }) - .orderBy('transaction.balanceDate', 'DESC') - .getOne() - logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined') - - let newBalance = new Decimal(0) - let decay: Decay | null = null - if (lastTransaction) { - decay = calculateDecay( - lastTransaction.balance, - lastTransaction.balanceDate, - receivedCallDate, - ) - newBalance = decay.balance - } - newBalance = newBalance.add(contribution.amount.toString()) - - const transaction = new DbTransaction() - transaction.typeId = TransactionTypeId.CREATION - transaction.memo = contribution.memo - transaction.userId = contribution.userId - transaction.previous = lastTransaction ? lastTransaction.id : null - transaction.amount = contribution.amount - transaction.creationDate = contribution.contributionDate - transaction.balance = newBalance - transaction.balanceDate = receivedCallDate - transaction.decay = decay ? decay.decay : new Decimal(0) - transaction.decayStart = decay ? decay.start : null - await queryRunner.manager.insert(DbTransaction, transaction) - - contribution.confirmedAt = receivedCallDate - contribution.confirmedBy = moderatorUser.id - contribution.transactionId = transaction.id - contribution.contributionStatus = ContributionStatus.CONFIRMED - await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) - - await queryRunner.commitTransaction() - logger.info('creation commited successfuly.') - sendContributionConfirmedEmail({ - senderFirstName: moderatorUser.firstName, - senderLastName: moderatorUser.lastName, - recipientFirstName: user.firstName, - recipientLastName: user.lastName, - recipientEmail: user.emailContact.email, - contributionMemo: contribution.memo, - contributionAmount: contribution.amount, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, - }) - } catch (e) { - await queryRunner.rollbackTransaction() - logger.error(`Creation was not successful: ${e}`) - throw new Error(`Creation was not successful.`) - } finally { - await queryRunner.release() - } - - const event = new Event() - const eventContributionConfirm = new EventContributionConfirm() - eventContributionConfirm.userId = user.id - eventContributionConfirm.amount = contribution.amount - eventContributionConfirm.contributionId = contribution.id - await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) - return true - } - - @Authorized([RIGHTS.CREATION_TRANSACTION_LIST]) - @Query(() => ContributionListResult) - async creationTransactionList( - @Args() - { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, - @Arg('userId', () => Int) userId: number, - ): Promise { - const offset = (currentPage - 1) * pageSize - const [contributionResult, count] = await getConnection() - .createQueryBuilder() - .select('c') - .from(DbContribution, 'c') - .leftJoinAndSelect('c.user', 'u') - .where(`user_id = ${userId}`) - .limit(pageSize) - .offset(offset) - .orderBy('c.created_at', order) - .getManyAndCount() - - return new ContributionListResult( - count, - contributionResult.map((contribution) => new Contribution(contribution, contribution.user)), - ) - // return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) - } - - @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) - @Mutation(() => Boolean) - async sendActivationEmail(@Arg('email') email: string): Promise { - email = email.trim().toLowerCase() - // const user = await dbUser.findOne({ id: emailContact.userId }) - const user = await findUserByEmail(email) - if (!user) { - logger.error(`Could not find User to emailContact: ${email}`) - throw new Error(`Could not find User to emailContact: ${email}`) - } - if (user.deletedAt) { - logger.error(`User with emailContact: ${email} is deleted.`) - throw new Error(`User with emailContact: ${email} is deleted.`) - } - const emailContact = user.emailContact - if (emailContact.deletedAt) { - logger.error(`The emailContact: ${email} of htis User is deleted.`) - throw new Error(`The emailContact: ${email} of htis User is deleted.`) - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const emailSent = await sendAccountActivationEmail({ - link: activationLink(emailContact.emailVerificationCode), - firstName: user.firstName, - lastName: user.lastName, - email, - duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), - }) - - // In case EMails are disabled log the activation link for the user - if (!emailSent) { - logger.info(`Account confirmation link: ${activationLink}`) - } else { - const event = new Event() - const eventSendConfirmationEmail = new EventSendConfirmationEmail() - eventSendConfirmationEmail.userId = user.id - await eventProtocol.writeEvent( - event.setEventSendConfirmationEmail(eventSendConfirmationEmail), - ) - } - - return true - } - - @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) - @Query(() => TransactionLinkResult) - async listTransactionLinksAdmin( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - @Arg('filters', () => TransactionLinkFilters, { nullable: true }) - filters: TransactionLinkFilters, - @Arg('userId', () => Int) - userId: number, - ): Promise { - const user = await dbUser.findOneOrFail({ id: userId }) - const where: { - userId: number - redeemedBy?: number | null - validUntil?: FindOperator | null - } = { - userId, - redeemedBy: null, - validUntil: MoreThan(new Date()), - } - if (filters) { - if (filters.withRedeemed) delete where.redeemedBy - if (filters.withExpired) delete where.validUntil - } - const [transactionLinks, count] = await dbTransactionLink.findAndCount({ - where, - withDeleted: filters ? filters.withDeleted : false, - order: { - createdAt: order, - }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - - return { - linkCount: count, - linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), - } - } - - @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async createContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - ): Promise { - isStartEndDateValid(validFrom, validTo) - if (!name) { - logger.error(`The name must be initialized!`) - throw new Error(`The name must be initialized!`) - } - if ( - name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || - name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS - ) { - const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!memo) { - logger.error(`The memo must be initialized!`) - throw new Error(`The memo must be initialized!`) - } - if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { - const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!amount) { - logger.error(`The amount must be initialized!`) - throw new Error('The amount must be initialized!') - } - if (!new Decimal(amount).isPositive()) { - logger.error(`The amount=${amount} must be initialized with a positiv value!`) - throw new Error(`The amount=${amount} must be initialized with a positiv value!`) - } - const dbContributionLink = new DbContributionLink() - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.createdAt = new Date() - dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`createContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) - @Query(() => ContributionLinkList) - async listContributionLinks( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - ): Promise { - const [links, count] = await DbContributionLink.findAndCount({ - where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], - order: { createdAt: order }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - return { - links: links.map((link: DbContributionLink) => new ContributionLink(link)), - count, - } - } - - @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) - @Mutation(() => Date, { nullable: true }) - async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { - const contributionLink = await DbContributionLink.findOne(id) - if (!contributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - await contributionLink.softRemove() - logger.debug(`deleteContributionLink successful!`) - const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) - return newContributionLink ? newContributionLink.deletedAt : null - } - - @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async updateContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - @Arg('id', () => Int) id: number, - ): Promise { - const dbContributionLink = await DbContributionLink.findOne(id) - if (!dbContributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`updateContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE]) - @Mutation(() => ContributionMessage) - async adminCreateContributionMessage( - @Args() { contributionId, message }: ContributionMessageArgs, - @Ctx() context: Context, - ): Promise { - const user = getUser(context) - if (!user.emailContact) { - user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } }) - } - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') - const contributionMessage = DbContributionMessage.create() - try { - const contribution = await DbContribution.findOne({ - where: { id: contributionId }, - relations: ['user'], - }) - if (!contribution) { - logger.error('Contribution not found') - throw new Error('Contribution not found') - } - if (contribution.userId === user.id) { - logger.error('Admin can not answer on own contribution') - throw new Error('Admin can not answer on own contribution') - } - if (!contribution.user.emailContact) { - contribution.user.emailContact = await UserContact.findOneOrFail({ - where: { id: contribution.user.emailId }, - }) - } - contributionMessage.contributionId = contributionId - contributionMessage.createdAt = new Date() - contributionMessage.message = message - contributionMessage.userId = user.id - contributionMessage.type = ContributionMessageType.DIALOG - contributionMessage.isModerator = true - await queryRunner.manager.insert(DbContributionMessage, contributionMessage) - - if ( - contribution.contributionStatus === ContributionStatus.DELETED || - contribution.contributionStatus === ContributionStatus.DENIED || - contribution.contributionStatus === ContributionStatus.PENDING - ) { - contribution.contributionStatus = ContributionStatus.IN_PROGRESS - await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) - } - - await sendAddedContributionMessageEmail({ - senderFirstName: user.firstName, - senderLastName: user.lastName, - recipientFirstName: contribution.user.firstName, - recipientLastName: contribution.user.lastName, - recipientEmail: contribution.user.emailContact.email, - senderEmail: user.emailContact.email, - contributionMemo: contribution.memo, - message, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, - }) - await queryRunner.commitTransaction() - } catch (e) { - await queryRunner.rollbackTransaction() - logger.error(`ContributionMessage was not successful: ${e}`) - throw new Error(`ContributionMessage was not successful: ${e}`) - } finally { - await queryRunner.release() - } - return new ContributionMessage(contributionMessage, user) - } -} diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 0b33c4722..84eccf5ca 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -4,13 +4,16 @@ import { Context, getUser } from '@/server/context' import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' import ContributionMessageArgs from '@arg/ContributionMessageArgs' -import { Contribution } from '@entity/Contribution' +import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionMessageType } from '@enum/MessageType' import { ContributionStatus } from '@enum/ContributionStatus' import { getConnection } from '@dbTools/typeorm' import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' +import { UserContact } from '@entity/UserContact' +import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' +import CONFIG from '@/config' @Resolver() export class ContributionMessageResolver { @@ -26,7 +29,7 @@ export class ContributionMessageResolver { await queryRunner.startTransaction('REPEATABLE READ') const contributionMessage = DbContributionMessage.create() try { - const contribution = await Contribution.findOne({ id: contributionId }) + const contribution = await DbContribution.findOne({ id: contributionId }) if (!contribution) { throw new Error('Contribution not found') } @@ -44,7 +47,7 @@ export class ContributionMessageResolver { if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) { contribution.contributionStatus = ContributionStatus.PENDING - await queryRunner.manager.update(Contribution, { id: contributionId }, contribution) + await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) } await queryRunner.commitTransaction() } catch (e) { @@ -82,4 +85,75 @@ export class ContributionMessageResolver { ), } } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE]) + @Mutation(() => ContributionMessage) + async adminCreateContributionMessage( + @Args() { contributionId, message }: ContributionMessageArgs, + @Ctx() context: Context, + ): Promise { + const user = getUser(context) + if (!user.emailContact) { + user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } }) + } + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') + const contributionMessage = DbContributionMessage.create() + try { + const contribution = await DbContribution.findOne({ + where: { id: contributionId }, + relations: ['user'], + }) + if (!contribution) { + logger.error('Contribution not found') + throw new Error('Contribution not found') + } + if (contribution.userId === user.id) { + logger.error('Admin can not answer on own contribution') + throw new Error('Admin can not answer on own contribution') + } + if (!contribution.user.emailContact) { + contribution.user.emailContact = await UserContact.findOneOrFail({ + where: { id: contribution.user.emailId }, + }) + } + contributionMessage.contributionId = contributionId + contributionMessage.createdAt = new Date() + contributionMessage.message = message + contributionMessage.userId = user.id + contributionMessage.type = ContributionMessageType.DIALOG + contributionMessage.isModerator = true + await queryRunner.manager.insert(DbContributionMessage, contributionMessage) + + if ( + contribution.contributionStatus === ContributionStatus.DELETED || + contribution.contributionStatus === ContributionStatus.DENIED || + contribution.contributionStatus === ContributionStatus.PENDING + ) { + contribution.contributionStatus = ContributionStatus.IN_PROGRESS + await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution) + } + + await sendAddedContributionMessageEmail({ + senderFirstName: user.firstName, + senderLastName: user.lastName, + recipientFirstName: contribution.user.firstName, + recipientLastName: contribution.user.lastName, + recipientEmail: contribution.user.emailContact.email, + senderEmail: user.emailContact.email, + contributionMemo: contribution.memo, + message, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }) + await queryRunner.commitTransaction() + } catch (e) { + await queryRunner.rollbackTransaction() + logger.error(`ContributionMessage was not successful: ${e}`) + throw new Error(`ContributionMessage was not successful: ${e}`) + } finally { + await queryRunner.release() + } + return new ContributionMessage(contributionMessage, user) + } } diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 15bdbfc2e..6d0716915 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,9 +1,9 @@ import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' -import { Contribution as dbContribution } from '@entity/Contribution' +import { Contribution as DbContribution } from '@entity/Contribution' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' -import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm' +import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' import ContributionArgs from '@arg/ContributionArgs' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' @@ -11,8 +11,14 @@ import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' -import { validateContribution, getUserCreation, updateCreations } from './util/creations' -import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' +import { + getUserCreation, + getUserCreations, + validateContribution, + updateCreations, + isValidDateString, +} from './util/creations' +import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' import { ContributionMessage } from '@entity/ContributionMessage' import { ContributionMessageType } from '@enum/MessageType' import { @@ -20,8 +26,26 @@ import { EventContributionCreate, EventContributionDelete, EventContributionUpdate, + EventContributionConfirm, + EventAdminContributionCreate, + EventAdminContributionDelete, + EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' +import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' +import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' +import Decimal from 'decimal.js-light' +import CONFIG from '@/config' +import { UserContact } from '@entity/UserContact' +import { AdminCreateContributions } from '@model/AdminCreateContributions' +import { AdminUpdateContribution } from '@model/AdminUpdateContribution' +import { User as DbUser } from '@entity/User' +import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { Decay } from '@model/Decay' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { calculateDecay } from '@/util/decay' +import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' @Resolver() export class ContributionResolver { @@ -50,7 +74,7 @@ export class ContributionResolver { const creationDateObj = new Date(creationDate) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) - const contribution = dbContribution.create() + const contribution = DbContribution.create() contribution.userId = user.id contribution.amount = amount contribution.createdAt = new Date() @@ -60,7 +84,7 @@ export class ContributionResolver { contribution.contributionStatus = ContributionStatus.PENDING logger.trace('contribution to save', contribution) - await dbContribution.save(contribution) + await DbContribution.save(contribution) const eventCreateContribution = new EventContributionCreate() eventCreateContribution.userId = user.id @@ -79,7 +103,7 @@ export class ContributionResolver { ): Promise { const event = new Event() const user = getUser(context) - const contribution = await dbContribution.findOne(id) + const contribution = await DbContribution.findOne(id) if (!contribution) { logger.error('Contribution not found for given id') throw new Error('Contribution not found for given id.') @@ -128,7 +152,7 @@ export class ContributionResolver { const [contributions, count] = await getConnection() .createQueryBuilder() .select('c') - .from(dbContribution, 'c') + .from(DbContribution, 'c') .leftJoinAndSelect('c.messages', 'm') .where(where) .withDeleted() @@ -152,7 +176,7 @@ export class ContributionResolver { const [dbContributions, count] = await getConnection() .createQueryBuilder() .select('c') - .from(dbContribution, 'c') + .from(DbContribution, 'c') .innerJoinAndSelect('c.user', 'u') .orderBy('c.createdAt', order) .limit(pageSize) @@ -185,7 +209,7 @@ export class ContributionResolver { const user = getUser(context) - const contributionToUpdate = await dbContribution.findOne({ + const contributionToUpdate = await DbContribution.findOne({ where: { id: contributionId, confirmedAt: IsNull() }, }) if (!contributionToUpdate) { @@ -240,7 +264,7 @@ export class ContributionResolver { contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.updatedAt = new Date() - dbContribution.save(contributionToUpdate) + DbContribution.save(contributionToUpdate) const event = new Event() @@ -252,4 +276,404 @@ export class ContributionResolver { return new UnconfirmedContribution(contributionToUpdate, user, creations) } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION]) + @Mutation(() => [Number]) + async adminCreateContribution( + @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, + @Ctx() context: Context, + ): Promise { + logger.info( + `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, + ) + const clientTimezoneOffset = getClientTimezoneOffset(context) + if (!isValidDateString(creationDate)) { + logger.error(`invalid Date for creationDate=${creationDate}`) + throw new Error(`invalid Date for creationDate=${creationDate}`) + } + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find user with email: ${email}`) + throw new Error(`Could not find user with email: ${email}`) + } + if (emailContact.deletedAt) { + logger.error('This emailContact was deleted. Cannot create a contribution.') + throw new Error('This emailContact was deleted. Cannot create a contribution.') + } + if (emailContact.user.deletedAt) { + logger.error('This user was deleted. Cannot create a contribution.') + throw new Error('This user was deleted. Cannot create a contribution.') + } + if (!emailContact.emailChecked) { + logger.error('Contribution could not be saved, Email is not activated') + throw new Error('Contribution could not be saved, Email is not activated') + } + + const event = new Event() + const moderator = getUser(context) + logger.trace('moderator: ', moderator.id) + const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) + logger.trace('creations:', creations) + const creationDateObj = new Date(creationDate) + logger.trace('creationDateObj:', creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) + const contribution = DbContribution.create() + contribution.userId = emailContact.userId + contribution.amount = amount + contribution.createdAt = new Date() + contribution.contributionDate = creationDateObj + contribution.memo = memo + contribution.moderatorId = moderator.id + contribution.contributionType = ContributionType.ADMIN + contribution.contributionStatus = ContributionStatus.PENDING + + logger.trace('contribution to save', contribution) + + await DbContribution.save(contribution) + + const eventAdminCreateContribution = new EventAdminContributionCreate() + eventAdminCreateContribution.userId = moderator.id + eventAdminCreateContribution.amount = amount + eventAdminCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionCreate(eventAdminCreateContribution), + ) + + return getUserCreation(emailContact.userId, clientTimezoneOffset) + } + + @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) + @Mutation(() => AdminCreateContributions) + async adminCreateContributions( + @Arg('pendingCreations', () => [AdminCreateContributionArgs]) + contributions: AdminCreateContributionArgs[], + @Ctx() context: Context, + ): Promise { + let success = false + const successfulContribution: string[] = [] + const failedContribution: string[] = [] + for (const contribution of contributions) { + await this.adminCreateContribution(contribution, context) + .then(() => { + successfulContribution.push(contribution.email) + success = true + }) + .catch(() => { + failedContribution.push(contribution.email) + }) + } + return { + success, + successfulContribution, + failedContribution, + } + } + + @Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION]) + @Mutation(() => AdminUpdateContribution) + async adminUpdateContribution( + @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const emailContact = await UserContact.findOne({ + where: { email }, + withDeleted: true, + relations: ['user'], + }) + if (!emailContact) { + logger.error(`Could not find UserContact with email: ${email}`) + throw new Error(`Could not find UserContact with email: ${email}`) + } + const user = emailContact.user + if (!user) { + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) + } + if (user.deletedAt) { + logger.error(`User was deleted (${email})`) + throw new Error(`User was deleted (${email})`) + } + + const moderator = getUser(context) + + const contributionToUpdate = await DbContribution.findOne({ + where: { id, confirmedAt: IsNull() }, + }) + if (!contributionToUpdate) { + logger.error('No contribution found to given id.') + throw new Error('No contribution found to given id.') + } + + if (contributionToUpdate.userId !== user.id) { + logger.error('user of the pending contribution and send user does not correspond') + throw new Error('user of the pending contribution and send user does not correspond') + } + + if (contributionToUpdate.moderatorId === null) { + logger.error('An admin is not allowed to update a user contribution.') + throw new Error('An admin is not allowed to update a user contribution.') + } + + const creationDateObj = new Date(creationDate) + let creations = await getUserCreation(user.id, clientTimezoneOffset) + + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) + } else { + logger.error('Currently the month of the contribution cannot change.') + throw new Error('Currently the month of the contribution cannot change.') + } + + // all possible cases not to be true are thrown in this function + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) + contributionToUpdate.amount = amount + contributionToUpdate.memo = memo + contributionToUpdate.contributionDate = new Date(creationDate) + contributionToUpdate.moderatorId = moderator.id + contributionToUpdate.contributionStatus = ContributionStatus.PENDING + + await DbContribution.save(contributionToUpdate) + + const result = new AdminUpdateContribution() + result.amount = amount + result.memo = contributionToUpdate.memo + result.date = contributionToUpdate.contributionDate + + result.creation = await getUserCreation(user.id, clientTimezoneOffset) + + const event = new Event() + const eventAdminContributionUpdate = new EventAdminContributionUpdate() + eventAdminContributionUpdate.userId = user.id + eventAdminContributionUpdate.amount = amount + eventAdminContributionUpdate.contributionId = contributionToUpdate.id + await eventProtocol.writeEvent( + event.setEventAdminContributionUpdate(eventAdminContributionUpdate), + ) + + return result + } + + @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) + @Query(() => [UnconfirmedContribution]) + async listUnconfirmedContributions(@Ctx() context: Context): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const contributions = await getConnection() + .createQueryBuilder() + .select('c') + .from(DbContribution, 'c') + .leftJoinAndSelect('c.messages', 'm') + .where({ confirmedAt: IsNull() }) + .getMany() + + if (contributions.length === 0) { + return [] + } + + const userIds = contributions.map((p) => p.userId) + const userCreations = await getUserCreations(userIds, clientTimezoneOffset) + const users = await DbUser.find({ + where: { id: In(userIds) }, + withDeleted: true, + relations: ['emailContact'], + }) + + return contributions.map((contribution) => { + const user = users.find((u) => u.id === contribution.userId) + const creation = userCreations.find((c) => c.id === contribution.userId) + + return new UnconfirmedContribution( + contribution, + user, + creation ? creation.creations : FULL_CREATION_AVAILABLE, + ) + }) + } + + @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) + @Mutation(() => Boolean) + async adminDeleteContribution( + @Arg('id', () => Int) id: number, + @Ctx() context: Context, + ): Promise { + const contribution = await DbContribution.findOne(id) + if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) + throw new Error('Contribution not found for given id.') + } + const moderator = getUser(context) + if ( + contribution.contributionType === ContributionType.USER && + contribution.userId === moderator.id + ) { + throw new Error('Own contribution can not be deleted as admin') + } + const user = await DbUser.findOneOrFail( + { id: contribution.userId }, + { relations: ['emailContact'] }, + ) + contribution.contributionStatus = ContributionStatus.DELETED + contribution.deletedBy = moderator.id + await contribution.save() + const res = await contribution.softRemove() + + const event = new Event() + const eventAdminContributionDelete = new EventAdminContributionDelete() + eventAdminContributionDelete.userId = contribution.userId + eventAdminContributionDelete.amount = contribution.amount + eventAdminContributionDelete.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionDelete(eventAdminContributionDelete), + ) + sendContributionRejectedEmail({ + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + recipientEmail: user.emailContact.email, + recipientFirstName: user.firstName, + recipientLastName: user.lastName, + contributionMemo: contribution.memo, + contributionAmount: contribution.amount, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }) + + return !!res + } + + @Authorized([RIGHTS.CONFIRM_CONTRIBUTION]) + @Mutation(() => Boolean) + async confirmContribution( + @Arg('id', () => Int) id: number, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const contribution = await DbContribution.findOne(id) + if (!contribution) { + logger.error(`Contribution not found for given id: ${id}`) + throw new Error('Contribution not found to given id.') + } + const moderatorUser = getUser(context) + if (moderatorUser.id === contribution.userId) { + logger.error('Moderator can not confirm own contribution') + throw new Error('Moderator can not confirm own contribution') + } + const user = await DbUser.findOneOrFail( + { id: contribution.userId }, + { withDeleted: true, relations: ['emailContact'] }, + ) + if (user.deletedAt) { + logger.error('This user was deleted. Cannot confirm a contribution.') + throw new Error('This user was deleted. Cannot confirm a contribution.') + } + const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) + validateContribution( + creations, + contribution.amount, + contribution.contributionDate, + clientTimezoneOffset, + ) + + const receivedCallDate = new Date() + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED') + try { + const lastTransaction = await queryRunner.manager + .createQueryBuilder() + .select('transaction') + .from(DbTransaction, 'transaction') + .where('transaction.userId = :id', { id: contribution.userId }) + .orderBy('transaction.balanceDate', 'DESC') + .getOne() + logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined') + + let newBalance = new Decimal(0) + let decay: Decay | null = null + if (lastTransaction) { + decay = calculateDecay( + lastTransaction.balance, + lastTransaction.balanceDate, + receivedCallDate, + ) + newBalance = decay.balance + } + newBalance = newBalance.add(contribution.amount.toString()) + + const transaction = new DbTransaction() + transaction.typeId = TransactionTypeId.CREATION + transaction.memo = contribution.memo + transaction.userId = contribution.userId + transaction.previous = lastTransaction ? lastTransaction.id : null + transaction.amount = contribution.amount + transaction.creationDate = contribution.contributionDate + transaction.balance = newBalance + transaction.balanceDate = receivedCallDate + transaction.decay = decay ? decay.decay : new Decimal(0) + transaction.decayStart = decay ? decay.start : null + await queryRunner.manager.insert(DbTransaction, transaction) + + contribution.confirmedAt = receivedCallDate + contribution.confirmedBy = moderatorUser.id + contribution.transactionId = transaction.id + contribution.contributionStatus = ContributionStatus.CONFIRMED + await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) + + await queryRunner.commitTransaction() + logger.info('creation commited successfuly.') + sendContributionConfirmedEmail({ + senderFirstName: moderatorUser.firstName, + senderLastName: moderatorUser.lastName, + recipientFirstName: user.firstName, + recipientLastName: user.lastName, + recipientEmail: user.emailContact.email, + contributionMemo: contribution.memo, + contributionAmount: contribution.amount, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }) + } catch (e) { + await queryRunner.rollbackTransaction() + logger.error(`Creation was not successful: ${e}`) + throw new Error(`Creation was not successful.`) + } finally { + await queryRunner.release() + } + + const event = new Event() + const eventContributionConfirm = new EventContributionConfirm() + eventContributionConfirm.userId = user.id + eventContributionConfirm.amount = contribution.amount + eventContributionConfirm.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) + return true + } + + @Authorized([RIGHTS.CREATION_TRANSACTION_LIST]) + @Query(() => ContributionListResult) + async creationTransactionList( + @Args() + { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, + @Arg('userId', () => Int) userId: number, + ): Promise { + const offset = (currentPage - 1) * pageSize + const [contributionResult, count] = await getConnection() + .createQueryBuilder() + .select('c') + .from(DbContribution, 'c') + .leftJoinAndSelect('c.user', 'u') + .where(`user_id = ${userId}`) + .limit(pageSize) + .offset(offset) + .orderBy('c.created_at', order) + .getManyAndCount() + + return new ContributionListResult( + count, + contributionResult.map((contribution) => new Contribution(contribution, contribution.user)), + ) + // return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) + } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index a5c4a5f01..9de8efa35 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,6 +1,6 @@ import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { getConnection } from '@dbTools/typeorm' +import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' import { Resolver, Args, @@ -12,9 +12,8 @@ import { Int, createUnionType, } from 'type-graphql' -import { TransactionLink } from '@model/TransactionLink' import { ContributionLink } from '@model/ContributionLink' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' import { Transaction as DbTransaction } from '@entity/Transaction' import { User as dbUser } from '@entity/User' import TransactionLinkArgs from '@arg/TransactionLinkArgs' @@ -30,11 +29,22 @@ import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' import { Contribution as DbContribution } from '@entity/Contribution' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { getUserCreation, validateContribution } from './util/creations' +import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' import { Decay } from '@model/Decay' import Decimal from 'decimal.js-light' import { TransactionTypeId } from '@enum/TransactionTypeId' import { ContributionCycleType } from '@enum/ContributionCycleType' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import TransactionLinkFilters from '@arg/TransactionLinkFilters' +import { + CONTRIBUTIONLINK_NAME_MAX_CHARS, + CONTRIBUTIONLINK_NAME_MIN_CHARS, + MEMO_MAX_CHARS, + MEMO_MIN_CHARS, +} from './const/const' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' +import { ContributionLinkList } from '@model/ContributionLinkList' const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union @@ -76,7 +86,7 @@ export class TransactionLinkResolver { // validate amount await calculateBalance(user.id, holdAvailableAmount, createdDate) - const transactionLink = dbTransactionLink.create() + const transactionLink = DbTransactionLink.create() transactionLink.userId = user.id transactionLink.amount = amount transactionLink.memo = memo @@ -84,7 +94,7 @@ export class TransactionLinkResolver { transactionLink.code = transactionLinkCode(createdDate) transactionLink.createdAt = createdDate transactionLink.validUntil = validUntil - await dbTransactionLink.save(transactionLink).catch(() => { + await DbTransactionLink.save(transactionLink).catch(() => { throw new Error('Unable to save transaction link') }) @@ -99,7 +109,7 @@ export class TransactionLinkResolver { ): Promise { const user = getUser(context) - const transactionLink = await dbTransactionLink.findOne({ id }) + const transactionLink = await DbTransactionLink.findOne({ id }) if (!transactionLink) { throw new Error('Transaction Link not found!') } @@ -129,7 +139,7 @@ export class TransactionLinkResolver { ) return new ContributionLink(contributionLink) } else { - const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) + const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) const user = await dbUser.findOneOrFail({ id: transactionLink.userId }) let redeemedBy: User | null = null if (transactionLink && transactionLink.redeemedBy) { @@ -148,7 +158,7 @@ export class TransactionLinkResolver { ): Promise { const user = getUser(context) // const now = new Date() - const transactionLinks = await dbTransactionLink.find({ + const transactionLinks = await DbTransactionLink.find({ where: { userId: user.id, redeemedBy: null, @@ -318,7 +328,7 @@ export class TransactionLinkResolver { } return true } else { - const transactionLink = await dbTransactionLink.findOneOrFail({ code }) + const transactionLink = await DbTransactionLink.findOneOrFail({ code }) const linkedUser = await dbUser.findOneOrFail( { id: transactionLink.userId }, { relations: ['emailContact'] }, @@ -347,4 +357,171 @@ export class TransactionLinkResolver { return true } } + + @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) + @Query(() => TransactionLinkResult) + async listTransactionLinksAdmin( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + @Arg('filters', () => TransactionLinkFilters, { nullable: true }) + filters: TransactionLinkFilters, + @Arg('userId', () => Int) + userId: number, + ): Promise { + const user = await dbUser.findOneOrFail({ id: userId }) + const where: { + userId: number + redeemedBy?: number | null + validUntil?: FindOperator | null + } = { + userId, + redeemedBy: null, + validUntil: MoreThan(new Date()), + } + if (filters) { + if (filters.withRedeemed) delete where.redeemedBy + if (filters.withExpired) delete where.validUntil + } + const [transactionLinks, count] = await DbTransactionLink.findAndCount({ + where, + withDeleted: filters ? filters.withDeleted : false, + order: { + createdAt: order, + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + + return { + linkCount: count, + linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), + } + } + + @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async createContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + ): Promise { + isStartEndDateValid(validFrom, validTo) + if (!name) { + logger.error(`The name must be initialized!`) + throw new Error(`The name must be initialized!`) + } + if ( + name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || + name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS + ) { + const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!memo) { + logger.error(`The memo must be initialized!`) + throw new Error(`The memo must be initialized!`) + } + if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { + const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!amount) { + logger.error(`The amount must be initialized!`) + throw new Error('The amount must be initialized!') + } + if (!new Decimal(amount).isPositive()) { + logger.error(`The amount=${amount} must be initialized with a positiv value!`) + throw new Error(`The amount=${amount} must be initialized with a positiv value!`) + } + const dbContributionLink = new DbContributionLink() + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.createdAt = new Date() + dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`createContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } + + @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) + @Query(() => ContributionLinkList) + async listContributionLinks( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + ): Promise { + const [links, count] = await DbContributionLink.findAndCount({ + where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], + order: { createdAt: order }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return { + links: links.map((link: DbContributionLink) => new ContributionLink(link)), + count, + } + } + + @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) + @Mutation(() => Date, { nullable: true }) + async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { + const contributionLink = await DbContributionLink.findOne(id) + if (!contributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + await contributionLink.softRemove() + logger.debug(`deleteContributionLink successful!`) + const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) + return newContributionLink ? newContributionLink.deletedAt : null + } + + @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async updateContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + @Arg('id', () => Int) id: number, + ): Promise { + const dbContributionLink = await DbContributionLink.findOne(id) + if (!dbContributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`updateContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } } diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 707b7ac49..067b7a0d4 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -2,7 +2,17 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' import i18n from 'i18n' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' +import { + Resolver, + Query, + Args, + Arg, + Authorized, + Ctx, + UseMiddleware, + Mutation, + Int, +} from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' import { User } from '@model/User' @@ -33,13 +43,16 @@ import { EventSendConfirmationEmail, EventActivateAccount, } from '@/event/Event' -import { getUserCreation } from './util/creations' +import { getUserCreation, getUserCreations } from './util/creations' import { UserContactType } from '../enum/UserContactType' import { UserRepository } from '@/typeorm/repository/User' import { SearchAdminUsersResult } from '@model/AdminUser' +import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' import { v4 as uuidv4 } from 'uuid' +import SearchUsersArgs from '@arg/SearchUsersArgs' +import { FULL_CREATION_AVAILABLE } from './const/const' // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') @@ -895,6 +908,202 @@ export class UserResolver { }), } } + + @Authorized([RIGHTS.SEARCH_USERS]) + @Query(() => SearchUsersResult) + async searchUsers( + @Args() + { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, + @Ctx() context: Context, + ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) + const userRepository = getCustomRepository(UserRepository) + const userFields = [ + 'id', + 'firstName', + 'lastName', + 'emailId', + 'emailContact', + 'deletedAt', + 'isAdmin', + ] + const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered( + userFields.map((fieldName) => { + return 'user.' + fieldName + }), + searchText, + filters, + currentPage, + pageSize, + ) + + if (users.length === 0) { + return { + userCount: 0, + userList: [], + } + } + + const creations = await getUserCreations( + users.map((u) => u.id), + clientTimezoneOffset, + ) + + const adminUsers = await Promise.all( + users.map(async (user) => { + let emailConfirmationSend = '' + if (!user.emailContact.emailChecked) { + if (user.emailContact.updatedAt) { + emailConfirmationSend = user.emailContact.updatedAt.toISOString() + } else { + emailConfirmationSend = user.emailContact.createdAt.toISOString() + } + } + const userCreations = creations.find((c) => c.id === user.id) + const adminUser = new UserAdmin( + user, + userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, + await hasElopageBuys(user.emailContact.email), + emailConfirmationSend, + ) + return adminUser + }), + ) + return { + userCount: count, + userList: adminUsers, + } + } + + @Authorized([RIGHTS.SET_USER_ROLE]) + @Mutation(() => Date, { nullable: true }) + async setUserRole( + @Arg('userId', () => Int) + userId: number, + @Arg('isAdmin', () => Boolean) + isAdmin: boolean, + @Ctx() + context: Context, + ): Promise { + const user = await DbUser.findOne({ id: userId }) + // user exists ? + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + // administrator user changes own role? + const moderatorUser = getUser(context) + if (moderatorUser.id === userId) { + logger.error('Administrator can not change his own role!') + throw new Error('Administrator can not change his own role!') + } + // change isAdmin + switch (user.isAdmin) { + case null: + if (isAdmin === true) { + user.isAdmin = new Date() + } else { + logger.error('User is already a usual user!') + throw new Error('User is already a usual user!') + } + break + default: + if (isAdmin === false) { + user.isAdmin = null + } else { + logger.error('User is already admin!') + throw new Error('User is already admin!') + } + break + } + await user.save() + const newUser = await DbUser.findOne({ id: userId }) + return newUser ? newUser.isAdmin : null + } + + @Authorized([RIGHTS.DELETE_USER]) + @Mutation(() => Date, { nullable: true }) + async deleteUser( + @Arg('userId', () => Int) userId: number, + @Ctx() context: Context, + ): Promise { + const user = await DbUser.findOne({ id: userId }) + // user exists ? + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + // moderator user disabled own account? + const moderatorUser = getUser(context) + if (moderatorUser.id === userId) { + logger.error('Moderator can not delete his own account!') + throw new Error('Moderator can not delete his own account!') + } + // soft-delete user + await user.softRemove() + const newUser = await DbUser.findOne({ id: userId }, { withDeleted: true }) + return newUser ? newUser.deletedAt : null + } + + @Authorized([RIGHTS.UNDELETE_USER]) + @Mutation(() => Date, { nullable: true }) + async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { + const user = await DbUser.findOne({ id: userId }, { withDeleted: true }) + if (!user) { + logger.error(`Could not find user with userId: ${userId}`) + throw new Error(`Could not find user with userId: ${userId}`) + } + if (!user.deletedAt) { + logger.error('User is not deleted') + throw new Error('User is not deleted') + } + await user.recover() + return null + } + + @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) + @Mutation(() => Boolean) + async sendActivationEmail(@Arg('email') email: string): Promise { + email = email.trim().toLowerCase() + // const user = await dbUser.findOne({ id: emailContact.userId }) + const user = await findUserByEmail(email) + if (!user) { + logger.error(`Could not find User to emailContact: ${email}`) + throw new Error(`Could not find User to emailContact: ${email}`) + } + if (user.deletedAt) { + logger.error(`User with emailContact: ${email} is deleted.`) + throw new Error(`User with emailContact: ${email} is deleted.`) + } + const emailContact = user.emailContact + if (emailContact.deletedAt) { + logger.error(`The emailContact: ${email} of htis User is deleted.`) + throw new Error(`The emailContact: ${email} of htis User is deleted.`) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const emailSent = await sendAccountActivationEmail({ + link: activationLink(emailContact.emailVerificationCode), + firstName: user.firstName, + lastName: user.lastName, + email, + duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), + }) + + // In case EMails are disabled log the activation link for the user + if (!emailSent) { + logger.info(`Account confirmation link: ${activationLink}`) + } else { + const event = new Event() + const eventSendConfirmationEmail = new EventSendConfirmationEmail() + eventSendConfirmationEmail.userId = user.id + await eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendConfirmationEmail), + ) + } + + return true + } } export async function findUserByEmail(email: string): Promise { From 4554b01b892a65ac3908f9bc3f1f55f52bbffb70 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 22 Nov 2022 13:01:38 +0100 Subject: [PATCH 02/16] order and correct imports of resolvers --- .../src/graphql/resolver/BalanceResolver.ts | 19 +++--- .../src/graphql/resolver/CommunityResolver.ts | 4 +- .../resolver/ContributionMessageResolver.ts | 25 ++++---- .../graphql/resolver/ContributionResolver.ts | 43 +++++++------- backend/src/graphql/resolver/GdtResolver.ts | 10 ++-- .../src/graphql/resolver/KlicktippResolver.ts | 4 +- .../graphql/resolver/StatisticsResolver.ts | 15 +++-- .../resolver/TransactionLinkResolver.ts | 58 ++++++++++--------- .../graphql/resolver/TransactionResolver.ts | 41 ++++++------- backend/src/graphql/resolver/UserResolver.ts | 41 +++++++------ 10 files changed, 140 insertions(+), 120 deletions(-) diff --git a/backend/src/graphql/resolver/BalanceResolver.ts b/backend/src/graphql/resolver/BalanceResolver.ts index 176b45354..a0016e8f2 100644 --- a/backend/src/graphql/resolver/BalanceResolver.ts +++ b/backend/src/graphql/resolver/BalanceResolver.ts @@ -1,16 +1,19 @@ -import { backendLogger as logger } from '@/server/logger' - -import { Context, getUser } from '@/server/context' +import Decimal from 'decimal.js-light' import { Resolver, Query, Ctx, Authorized } from 'type-graphql' +import { getCustomRepository } from '@dbTools/typeorm' + +import { Transaction as dbTransaction } from '@entity/Transaction' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' + +import { TransactionLinkRepository } from '@repository/TransactionLink' + import { Balance } from '@model/Balance' + +import { backendLogger as logger } from '@/server/logger' +import { Context, getUser } from '@/server/context' import { calculateDecay } from '@/util/decay' import { RIGHTS } from '@/auth/RIGHTS' -import { Transaction as dbTransaction } from '@entity/Transaction' -import Decimal from 'decimal.js-light' import { GdtResolver } from './GdtResolver' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { getCustomRepository } from '@dbTools/typeorm' -import { TransactionLinkRepository } from '@repository/TransactionLink' @Resolver() export class BalanceResolver { diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index c194cdf1a..f56254e1f 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -1,7 +1,9 @@ import { Resolver, Query, Authorized } from 'type-graphql' + +import { Community } from '@model/Community' + import { RIGHTS } from '@/auth/RIGHTS' import CONFIG from '@/config' -import { Community } from '@model/Community' @Resolver() export class CommunityResolver { diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 84eccf5ca..1f47a14d6 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -1,17 +1,20 @@ +import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' +import { getConnection } from '@dbTools/typeorm' + +import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' +import { Contribution as DbContribution } from '@entity/Contribution' +import { UserContact } from '@entity/UserContact' + +import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' +import ContributionMessageArgs from '@arg/ContributionMessageArgs' +import { ContributionMessageType } from '@enum/MessageType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + import { backendLogger as logger } from '@/server/logger' import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser } from '@/server/context' -import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage' -import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' -import ContributionMessageArgs from '@arg/ContributionMessageArgs' -import { Contribution as DbContribution } from '@entity/Contribution' -import { ContributionMessageType } from '@enum/MessageType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { getConnection } from '@dbTools/typeorm' -import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage' -import Paginated from '@arg/Paginated' -import { Order } from '@enum/Order' -import { UserContact } from '@entity/UserContact' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' import CONFIG from '@/config' diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 6d0716915..d3e72c2ff 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,16 +1,31 @@ -import { RIGHTS } from '@/auth/RIGHTS' -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { backendLogger as logger } from '@/server/logger' -import { Contribution as DbContribution } from '@entity/Contribution' +import Decimal from 'decimal.js-light' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' -import ContributionArgs from '@arg/ContributionArgs' -import Paginated from '@arg/Paginated' + +import { Contribution as DbContribution } from '@entity/Contribution' +import { ContributionMessage } from '@entity/ContributionMessage' +import { UserContact } from '@entity/UserContact' +import { User as DbUser } from '@entity/User' +import { Transaction as DbTransaction } from '@entity/Transaction' + +import { AdminCreateContributions } from '@model/AdminCreateContributions' +import { AdminUpdateContribution } from '@model/AdminUpdateContribution' +import { Contribution, ContributionListResult } from '@model/Contribution' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { Decay } from '@model/Decay' +import { TransactionTypeId } from '@enum/TransactionTypeId' import { Order } from '@enum/Order' import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' -import { Contribution, ContributionListResult } from '@model/Contribution' -import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import { ContributionMessageType } from '@enum/MessageType' +import ContributionArgs from '@arg/ContributionArgs' +import Paginated from '@arg/Paginated' +import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' +import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' + +import { RIGHTS } from '@/auth/RIGHTS' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import { backendLogger as logger } from '@/server/logger' import { getUserCreation, getUserCreations, @@ -19,8 +34,6 @@ import { isValidDateString, } from './util/creations' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' -import { ContributionMessage } from '@entity/ContributionMessage' -import { ContributionMessageType } from '@enum/MessageType' import { Event, EventContributionCreate, @@ -32,18 +45,8 @@ import { EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' -import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' -import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs' -import Decimal from 'decimal.js-light' import CONFIG from '@/config' -import { UserContact } from '@entity/UserContact' -import { AdminCreateContributions } from '@model/AdminCreateContributions' -import { AdminUpdateContribution } from '@model/AdminUpdateContribution' -import { User as DbUser } from '@entity/User' import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { Decay } from '@model/Decay' -import { TransactionTypeId } from '@enum/TransactionTypeId' import { calculateDecay } from '@/util/decay' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' diff --git a/backend/src/graphql/resolver/GdtResolver.ts b/backend/src/graphql/resolver/GdtResolver.ts index a1d75e946..6f9691cd9 100644 --- a/backend/src/graphql/resolver/GdtResolver.ts +++ b/backend/src/graphql/resolver/GdtResolver.ts @@ -1,10 +1,12 @@ -import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql' -import CONFIG from '@/config' + import { GdtEntryList } from '@model/GdtEntryList' -import Paginated from '@arg/Paginated' -import { apiGet, apiPost } from '@/apis/HttpRequest' import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + +import { Context, getUser } from '@/server/context' +import CONFIG from '@/config' +import { apiGet, apiPost } from '@/apis/HttpRequest' import { RIGHTS } from '@/auth/RIGHTS' @Resolver() diff --git a/backend/src/graphql/resolver/KlicktippResolver.ts b/backend/src/graphql/resolver/KlicktippResolver.ts index ce9a097e2..4f88ccdc1 100644 --- a/backend/src/graphql/resolver/KlicktippResolver.ts +++ b/backend/src/graphql/resolver/KlicktippResolver.ts @@ -1,4 +1,7 @@ import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql' + +import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs' + import { getKlickTippUser, getKlicktippTagMap, @@ -6,7 +9,6 @@ import { klicktippSignIn, } from '@/apis/KlicktippController' import { RIGHTS } from '@/auth/RIGHTS' -import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs' @Resolver() export class KlicktippResolver { diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index 7bfae319e..f6c2b9e22 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -1,10 +1,13 @@ -import { Resolver, Query, Authorized } from 'type-graphql' -import { RIGHTS } from '@/auth/RIGHTS' -import { CommunityStatistics } from '@model/CommunityStatistics' -import { User as DbUser } from '@entity/User' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { getConnection } from '@dbTools/typeorm' import Decimal from 'decimal.js-light' +import { Resolver, Query, Authorized } from 'type-graphql' +import { getConnection } from '@dbTools/typeorm' + +import { Transaction as DbTransaction } from '@entity/Transaction' +import { User as DbUser } from '@entity/User' + +import { CommunityStatistics } from '@model/CommunityStatistics' + +import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 9de8efa35..297a96ce9 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,6 +1,31 @@ +import { randomBytes } from 'crypto' +import Decimal from 'decimal.js-light' + +import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' + +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' +import { User as DbUser } from '@entity/User' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { Contribution as DbContribution } from '@entity/Contribution' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' + +import { User } from '@model/User' +import { ContributionLink } from '@model/ContributionLink' +import { Decay } from '@model/Decay' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import { ContributionLinkList } from '@model/ContributionLinkList' +import { Order } from '@enum/Order' +import { ContributionType } from '@enum/ContributionType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { ContributionCycleType } from '@enum/ContributionCycleType' +import TransactionLinkArgs from '@arg/TransactionLinkArgs' +import Paginated from '@arg/Paginated' +import TransactionLinkFilters from '@arg/TransactionLinkFilters' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' + import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' import { Resolver, Args, @@ -12,39 +37,18 @@ import { Int, createUnionType, } from 'type-graphql' -import { ContributionLink } from '@model/ContributionLink' -import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { User as dbUser } from '@entity/User' -import TransactionLinkArgs from '@arg/TransactionLinkArgs' -import Paginated from '@arg/Paginated' import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' -import { randomBytes } from 'crypto' -import { User } from '@model/User' import { calculateDecay } from '@/util/decay' -import { executeTransaction } from './TransactionResolver' -import { Order } from '@enum/Order' -import { ContributionType } from '@enum/ContributionType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { Contribution as DbContribution } from '@entity/Contribution' -import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' -import { Decay } from '@model/Decay' -import Decimal from 'decimal.js-light' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionCycleType } from '@enum/ContributionCycleType' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import TransactionLinkFilters from '@arg/TransactionLinkFilters' import { CONTRIBUTIONLINK_NAME_MAX_CHARS, CONTRIBUTIONLINK_NAME_MIN_CHARS, MEMO_MAX_CHARS, MEMO_MIN_CHARS, } from './const/const' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { executeTransaction } from './TransactionResolver' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' -import { ContributionLinkList } from '@model/ContributionLinkList' const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union @@ -140,10 +144,10 @@ export class TransactionLinkResolver { return new ContributionLink(contributionLink) } else { const transactionLink = await DbTransactionLink.findOneOrFail({ code }, { withDeleted: true }) - const user = await dbUser.findOneOrFail({ id: transactionLink.userId }) + const user = await DbUser.findOneOrFail({ id: transactionLink.userId }) let redeemedBy: User | null = null if (transactionLink && transactionLink.redeemedBy) { - redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy })) + redeemedBy = new User(await DbUser.findOneOrFail({ id: transactionLink.redeemedBy })) } return new TransactionLink(transactionLink, new User(user), redeemedBy) } @@ -329,7 +333,7 @@ export class TransactionLinkResolver { return true } else { const transactionLink = await DbTransactionLink.findOneOrFail({ code }) - const linkedUser = await dbUser.findOneOrFail( + const linkedUser = await DbUser.findOneOrFail( { id: transactionLink.userId }, { relations: ['emailContact'] }, ) @@ -368,7 +372,7 @@ export class TransactionLinkResolver { @Arg('userId', () => Int) userId: number, ): Promise { - const user = await dbUser.findOneOrFail({ id: userId }) + const user = await DbUser.findOneOrFail({ id: userId }) const where: { userId: number redeemedBy?: number | null diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index f0fb2f452..57fe3bd3c 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -1,45 +1,40 @@ /* eslint-disable new-cap */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { backendLogger as logger } from '@/server/logger' -import CONFIG from '@/config' - -import { Context, getUser } from '@/server/context' +import Decimal from 'decimal.js-light' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { getCustomRepository, getConnection, In } from '@dbTools/typeorm' -import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' - -import { Transaction } from '@model/Transaction' -import { TransactionList } from '@model/TransactionList' - -import TransactionSendArgs from '@arg/TransactionSendArgs' -import Paginated from '@arg/Paginated' - -import { Order } from '@enum/Order' - -import { TransactionRepository } from '@repository/Transaction' -import { TransactionLinkRepository } from '@repository/TransactionLink' - import { User as dbUser } from '@entity/User' import { Transaction as dbTransaction } from '@entity/Transaction' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { TransactionRepository } from '@repository/Transaction' +import { TransactionLinkRepository } from '@repository/TransactionLink' +import { Decay } from '@model/Decay' +import { User } from '@model/User' +import { Transaction } from '@model/Transaction' +import { TransactionList } from '@model/TransactionList' +import { Order } from '@enum/Order' import { TransactionTypeId } from '@enum/TransactionTypeId' +import TransactionSendArgs from '@arg/TransactionSendArgs' +import Paginated from '@arg/Paginated' + +import { backendLogger as logger } from '@/server/logger' +import CONFIG from '@/config' +import { Context, getUser } from '@/server/context' +import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' import { calculateBalance, isHexPublicKey } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' -import { User } from '@model/User' import { communityUser } from '@/util/communityUser' import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions' -import Decimal from 'decimal.js-light' +import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' +import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' +import { eventProtocol } from '@/event/EventProtocolEmitter' 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, diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 067b7a0d4..39f7783e1 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,7 +1,6 @@ import fs from 'fs' -import { backendLogger as logger } from '@/server/logger' import i18n from 'i18n' -import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import { v4 as uuidv4 } from 'uuid' import { Resolver, Query, @@ -14,19 +13,31 @@ import { Int, } from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' -import CONFIG from '@/config' -import { User } from '@model/User' + import { User as DbUser } from '@entity/User' import { UserContact as DbUserContact } from '@entity/UserContact' -import { communityDbUser } from '@/util/communityUser' -import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { ContributionLink as dbContributionLink } from '@entity/ContributionLink' -import { encode } from '@/auth/JWT' +import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { UserRepository } from '@repository/User' + +import { User } from '@model/User' +import { SearchAdminUsersResult } from '@model/AdminUser' +import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' +import { OptInType } from '@enum/OptInType' +import { Order } from '@enum/Order' +import { UserContactType } from '@enum/UserContactType' import CreateUserArgs from '@arg/CreateUserArgs' import UnsecureLoginArgs from '@arg/UnsecureLoginArgs' import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs' +import Paginated from '@arg/Paginated' +import SearchUsersArgs from '@arg/SearchUsersArgs' + +import { backendLogger as logger } from '@/server/logger' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' +import CONFIG from '@/config' +import { communityDbUser } from '@/util/communityUser' +import { encode } from '@/auth/JWT' import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware' -import { OptInType } from '@enum/OptInType' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants' @@ -44,14 +55,6 @@ import { EventActivateAccount, } from '@/event/Event' import { getUserCreation, getUserCreations } from './util/creations' -import { UserContactType } from '../enum/UserContactType' -import { UserRepository } from '@/typeorm/repository/User' -import { SearchAdminUsersResult } from '@model/AdminUser' -import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' -import Paginated from '@arg/Paginated' -import { Order } from '@enum/Order' -import { v4 as uuidv4 } from 'uuid' -import SearchUsersArgs from '@arg/SearchUsersArgs' import { FULL_CREATION_AVAILABLE } from './const/const' // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -498,7 +501,7 @@ export class UserResolver { logger.debug('new dbUser=' + dbUser) if (redeemCode) { if (redeemCode.match(/^CL-/)) { - const contributionLink = await dbContributionLink.findOne({ + const contributionLink = await DbContributionLink.findOne({ code: redeemCode.replace('CL-', ''), }) logger.info('redeemCode found contributionLink=' + contributionLink) @@ -507,7 +510,7 @@ export class UserResolver { eventRedeemRegister.contributionId = contributionLink.id } } else { - const transactionLink = await dbTransactionLink.findOne({ code: redeemCode }) + const transactionLink = await DbTransactionLink.findOne({ code: redeemCode }) logger.info('redeemCode found transactionLink=' + transactionLink) if (transactionLink) { dbUser.referrerId = transactionLink.userId From a2c1b0ff963226c7df51b77b645c01292b71a6b7 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 22:52:22 +0100 Subject: [PATCH 03/16] separate AdminResolver.test into the corresponding parts --- .../graphql/resolver/AdminResolver.test.ts | 2668 ----------------- .../resolver/ContributionResolver.test.ts | 1115 ++++++- .../resolver/TransactionLinkResolver.test.ts | 896 +++++- .../src/graphql/resolver/UserResolver.test.ts | 639 +++- backend/test/helpers.test.ts | 7 + 5 files changed, 2649 insertions(+), 2676 deletions(-) delete mode 100644 backend/src/graphql/resolver/AdminResolver.test.ts create mode 100644 backend/test/helpers.test.ts diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts deleted file mode 100644 index 503bab472..000000000 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ /dev/null @@ -1,2668 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - -import { objectValuesToArray } from '@/util/utilities' -import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers' -import { userFactory } from '@/seeds/factory/user' -import { creationFactory } from '@/seeds/factory/creation' -import { creations } from '@/seeds/creation/index' -import { transactionLinkFactory } from '@/seeds/factory/transactionLink' -import { transactionLinks } from '@/seeds/transactionLink/index' -import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' -import { peterLustig } from '@/seeds/users/peter-lustig' -import { stephenHawking } from '@/seeds/users/stephen-hawking' -import { garrickOllivander } from '@/seeds/users/garrick-ollivander' -import { - login, - setUserRole, - deleteUser, - unDeleteUser, - createContribution, - adminCreateContribution, - adminCreateContributions, - adminUpdateContribution, - adminDeleteContribution, - confirmContribution, - createContributionLink, - deleteContributionLink, - updateContributionLink, -} from '@/seeds/graphql/mutations' -import { - listUnconfirmedContributions, - searchUsers, - listTransactionLinksAdmin, - listContributionLinks, -} from '@/seeds/graphql/queries' -import { GraphQLError } from 'graphql' -import { User } from '@entity/User' -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ -import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' -import Decimal from 'decimal.js-light' -import { Contribution } from '@entity/Contribution' -import { Transaction as DbTransaction } from '@entity/Transaction' -import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' -import { EventProtocol } from '@entity/EventProtocol' -import { EventProtocolType } from '@/event/EventProtocolType' -import { logger } from '@test/testSetup' - -// mock account activation email to avoid console spam -jest.mock('@/mailer/sendAccountActivationEmail', () => { - return { - __esModule: true, - sendAccountActivationEmail: jest.fn(), - } -}) - -// mock account activation email to avoid console spam -jest.mock('@/mailer/sendContributionConfirmedEmail', () => { - return { - __esModule: true, - sendContributionConfirmedEmail: jest.fn(), - } -}) - -let mutate: any, query: any, con: any -let testEnv: any - -beforeAll(async () => { - testEnv = await testEnvironment() - mutate = testEnv.mutate - query = testEnv.query - con = testEnv.con - await cleanDB() -}) - -afterAll(async () => { - await cleanDB() - await con.close() -}) - -let admin: User -let user: User -let creation: Contribution | void -let result: any - -describe('contributionDateFormatter', () => { - it('formats the date correctly', () => { - expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') - }) -}) - -describe('AdminResolver', () => { - describe('set user role', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to get a new role does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('change role with success', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - describe('user gets new role', () => { - describe('to admin', () => { - it('returns date string', async () => { - const result = await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: true }, - }) - expect(result).toEqual( - expect.objectContaining({ - data: { - setUserRole: expect.any(String), - }, - }), - ) - expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date)) - }) - }) - - describe('to usual user', () => { - it('returns null', async () => { - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - setUserRole: null, - }, - }), - ) - }) - }) - }) - }) - - describe('change role with error', () => { - describe('is own role', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Administrator can not change his own role!')], - }), - ) - }) - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Administrator can not change his own role!') - }) - }) - - describe('user has already role to be set', () => { - describe('to admin', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: true }, - }) - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is already admin!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is already admin!') - }) - }) - - describe('to usual user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await mutate({ - mutation: setUserRole, - variables: { userId: user.id, isAdmin: false }, - }) - await expect( - mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is already a usual user!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is already a usual user!') - }) - }) - }) - }) - }) - }) - }) - - describe('delete user', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to be deleted does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('delete self', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: admin.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Moderator can not delete his own account!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') - }) - }) - - describe('delete with success', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - it('returns date string', async () => { - const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } }) - expect(result).toEqual( - expect.objectContaining({ - data: { - deleteUser: expect.any(String), - }, - }), - ) - expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) - }) - - describe('delete deleted user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: deleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) - }) - }) - }) - }) - }) - }) - - describe('unDelete user', () => { - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('user to be undelete does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) - }) - }) - - describe('user to undelete is not deleted', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User is not deleted')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User is not deleted') - }) - - describe('undelete deleted user', () => { - beforeAll(async () => { - await mutate({ mutation: deleteUser, variables: { userId: user.id } }) - }) - - it('returns null', async () => { - await expect( - mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { unDeleteUser: null }, - }), - ) - }) - }) - }) - }) - }) - }) - - describe('search users', () => { - const variablesWithoutTextAndFilters = { - searchText: '', - currentPage: 1, - pageSize: 25, - filters: null, - } - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - const allUsers = { - bibi: expect.objectContaining({ - email: 'bibi@bloxberg.de', - }), - garrick: expect.objectContaining({ - email: 'garrick@ollivander.com', - }), - peter: expect.objectContaining({ - email: 'peter@lustig.de', - }), - stephen: expect.objectContaining({ - email: 'stephen@hawking.uk', - }), - } - - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - - await userFactory(testEnv, bibiBloxberg) - await userFactory(testEnv, stephenHawking) - await userFactory(testEnv, garrickOllivander) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('without any filters', () => { - it('finds all users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 4, - userList: expect.arrayContaining(objectValuesToArray(allUsers)), - }, - }, - }), - ) - }) - }) - - describe('all filters are null', () => { - it('finds all users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: null, - byDeleted: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 4, - userList: expect.arrayContaining(objectValuesToArray(allUsers)), - }, - }, - }), - ) - }) - }) - - describe('filter by unchecked email', () => { - it('finds only users with unchecked email', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: false, - byDeleted: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 1, - userList: expect.arrayContaining([allUsers.garrick]), - }, - }, - }), - ) - }) - }) - - describe('filter by deleted users', () => { - it('finds only users with deleted account', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: null, - byDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 1, - userList: expect.arrayContaining([allUsers.stephen]), - }, - }, - }), - ) - }) - }) - - describe('filter by deleted account and unchecked email', () => { - it('finds no users', async () => { - await expect( - query({ - query: searchUsers, - variables: { - ...variablesWithoutTextAndFilters, - filters: { - byActivated: false, - byDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - searchUsers: { - userCount: 0, - userList: [], - }, - }, - }), - ) - }) - }) - }) - }) - }) - - describe('creations', () => { - const variables = { - email: 'bibi@bloxberg.de', - amount: new Decimal(2000), - memo: 'Aktives Grundeinkommen', - creationDate: 'not-valid', - } - - describe('unauthenticated', () => { - describe('adminCreateContribution', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminCreateContributions', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: [variables] }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('confirmContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('adminCreateContribution', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminCreateContributions', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: [variables] }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns an error', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('confirmContribution', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: 1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - admin = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('adminCreateContribution', () => { - const now = new Date() - - beforeAll(async () => { - creation = await creationFactory(testEnv, { - email: 'peter@lustig.de', - amount: 400, - memo: 'Herzlich Willkommen bei Gradido!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ), - }) - }) - - describe('user to create for does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Could not find user with email: bibi@bloxberg.de')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Could not find user with email: bibi@bloxberg.de', - ) - }) - }) - - describe('user to create for is deleted', () => { - beforeAll(async () => { - user = await userFactory(testEnv, stephenHawking) - variables.email = 'stephen@hawking.uk' - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('This user was deleted. Cannot create a contribution.'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'This user was deleted. Cannot create a contribution.', - ) - }) - }) - - describe('user to create for has email not confirmed', () => { - beforeAll(async () => { - user = await userFactory(testEnv, garrickOllivander) - variables.email = 'garrick@ollivander.com' - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Contribution could not be saved, Email is not activated'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Contribution could not be saved, Email is not activated', - ) - }) - }) - - describe('valid user to create for', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - variables.email = 'bibi@bloxberg.de' - variables.creationDate = 'invalid-date' - }) - - describe('date of creation is not a date string', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) - }) - }) - - describe('date of creation is four months ago', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 4, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'No information for available creations with the given creationDate=', - new Date(variables.creationDate).toString(), - ) - }) - }) - - describe('date of creation is in the future', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() + 4, 1), - ) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'No information for available creations with the given creationDate=', - new Date(variables.creationDate).toString(), - ) - }) - }) - - describe('amount of creation is too high', () => { - it('throws an error', async () => { - jest.clearAllMocks() - variables.creationDate = contributionDateFormatter(now) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ) - }) - }) - - describe('creation is valid', () => { - it('returns an array of the open creations for the last three months', async () => { - variables.amount = new Decimal(200) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminCreateContribution: [1000, 1000, 800], - }, - }), - ) - }) - - it('stores the admin create contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, - userId: admin.id, - }), - ) - }) - }) - - describe('second creation surpasses the available amount ', () => { - it('returns an array of the open creations for the last three months', async () => { - variables.amount = new Decimal(1000) - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', - ) - }) - }) - }) - }) - - describe('adminCreateContributions', () => { - // at this point we have this data in DB: - // bibi@bloxberg.de: [1000, 1000, 800] - // peter@lustig.de: [1000, 600, 1000] - // stephen@hawking.uk: [1000, 1000, 1000] - deleted - // garrick@ollivander.com: [1000, 1000, 1000] - not activated - - const massCreationVariables = [ - 'bibi@bloxberg.de', - 'peter@lustig.de', - 'stephen@hawking.uk', - 'garrick@ollivander.com', - 'bob@baumeister.de', - ].map((email) => { - return { - email, - amount: new Decimal(500), - memo: 'Grundeinkommen', - creationDate: contributionDateFormatter(new Date()), - } - }) - - it('returns success, two successful creation and three failed creations', async () => { - await expect( - mutate({ - mutation: adminCreateContributions, - variables: { pendingCreations: massCreationVariables }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminCreateContributions: { - success: true, - successfulContribution: ['bibi@bloxberg.de', 'peter@lustig.de'], - failedContribution: [ - 'stephen@hawking.uk', - 'garrick@ollivander.com', - 'bob@baumeister.de', - ], - }, - }, - }), - ) - }) - }) - - describe('adminUpdateContribution', () => { - // at this I expect to have this data in DB: - // bibi@bloxberg.de: [1000, 1000, 300] - // peter@lustig.de: [1000, 600, 500] - // stephen@hawking.uk: [1000, 1000, 1000] - deleted - // garrick@ollivander.com: [1000, 1000, 1000] - not activated - - describe('user for creation to update does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'bob@baumeister.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Could not find UserContact with email: bob@baumeister.de'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Could not find UserContact with email: bob@baumeister.de', - ) - }) - }) - - describe('user for creation to update is deleted', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: 1, - email: 'stephen@hawking.uk', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('User was deleted (stephen@hawking.uk)')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') - }) - }) - - describe('creation does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: -1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('No contribution found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('No contribution found to given id.') - }) - }) - - describe('user email does not match creation user', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'bibi@bloxberg.de', - amount: new Decimal(300), - memo: 'Danke Bibi!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'user of the pending contribution and send user does not correspond', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'user of the pending contribution and send user does not correspond', - ) - }) - }) - - describe('creation update is not valid', () => { - // as this test has not clearly defined that date, it is a false positive - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(1900), - memo: 'Danke Peter!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', - ) - }) - }) - - describe.skip('creation update is successful changing month', () => { - // skipped as changing the month is currently disable - it('returns update creation object', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(300), - memo: 'Danke Peter!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminUpdateContribution: { - date: expect.any(String), - memo: 'Danke Peter!', - amount: '300', - creation: ['1000', '700', '500'], - }, - }, - }), - ) - }) - - it('stores the admin update contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, - userId: admin.id, - }), - ) - }) - }) - - describe('creation update is successful without changing month', () => { - // actually this mutation IS changing the month - it('returns update creation object', async () => { - await expect( - mutate({ - mutation: adminUpdateContribution, - variables: { - id: creation ? creation.id : -1, - email: 'peter@lustig.de', - amount: new Decimal(200), - memo: 'Das war leider zu Viel!', - creationDate: creation - ? contributionDateFormatter(creation.contributionDate) - : contributionDateFormatter(new Date()), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - adminUpdateContribution: { - date: expect.any(String), - memo: 'Das war leider zu Viel!', - amount: '200', - creation: ['1000', '800', '500'], - }, - }, - }), - ) - }) - - it('stores the admin update contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, - userId: admin.id, - }), - ) - }) - }) - }) - - describe('listUnconfirmedContributions', () => { - it('returns four pending creations', async () => { - await expect( - query({ - query: listUnconfirmedContributions, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listUnconfirmedContributions: expect.arrayContaining([ - { - id: expect.any(Number), - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - date: expect.any(String), - memo: 'Das war leider zu Viel!', - amount: '200', - moderator: admin.id, - creation: ['1000', '800', '500'], - }, - { - id: expect.any(Number), - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - date: expect.any(String), - memo: 'Grundeinkommen', - amount: '500', - moderator: admin.id, - creation: ['1000', '800', '500'], - }, - { - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Grundeinkommen', - amount: '500', - moderator: admin.id, - creation: ['1000', '1000', '300'], - }, - { - id: expect.any(Number), - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - date: expect.any(String), - memo: 'Aktives Grundeinkommen', - amount: '200', - moderator: admin.id, - creation: ['1000', '1000', '300'], - }, - ]), - }, - }), - ) - }) - }) - - describe('adminDeleteContribution', () => { - describe('creation id does not exist', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution not found for given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') - }) - }) - - describe('admin deletes own user contribution', () => { - beforeAll(async () => { - await query({ - query: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - result = await mutate({ - mutation: createContribution, - variables: { - amount: 100.0, - memo: 'Test env contribution', - creationDate: contributionDateFormatter(new Date()), - }, - }) - }) - - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: result.data.createContribution.id, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Own contribution can not be deleted as admin')], - }), - ) - }) - }) - - describe('creation id does exist', () => { - it('returns true', async () => { - await expect( - mutate({ - mutation: adminDeleteContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { adminDeleteContribution: true }, - }), - ) - }) - - it('stores the admin delete contribution event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, - userId: admin.id, - }), - ) - }) - }) - }) - - describe('confirmContribution', () => { - describe('creation does not exits', () => { - it('throws an error', async () => { - jest.clearAllMocks() - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') - }) - }) - - describe('confirm own creation', () => { - beforeAll(async () => { - const now = new Date() - creation = await creationFactory(testEnv, { - email: 'peter@lustig.de', - amount: 400, - memo: 'Herzlich Willkommen bei Gradido!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 1, 1), - ), - }) - }) - - it('thows an error', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Moderator can not confirm own contribution')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') - }) - }) - - describe('confirm creation for other user', () => { - beforeAll(async () => { - const now = new Date() - creation = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 450, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - }) - - it('returns true', async () => { - await expect( - mutate({ - mutation: confirmContribution, - variables: { - id: creation ? creation.id : -1, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { confirmContribution: true }, - }), - ) - }) - - it('stores the contribution confirm event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.CONTRIBUTION_CONFIRM, - }), - ) - }) - - it('creates a transaction', async () => { - const transaction = await DbTransaction.find() - expect(transaction[0].amount.toString()).toBe('450') - expect(transaction[0].memo).toBe('Herzlich Willkommen bei Gradido liebe Bibi!') - expect(transaction[0].linkedTransactionId).toEqual(null) - expect(transaction[0].transactionLinkId).toEqual(null) - expect(transaction[0].previous).toEqual(null) - expect(transaction[0].linkedUserId).toEqual(null) - expect(transaction[0].typeId).toEqual(1) - }) - - it('calls sendContributionConfirmedEmail', async () => { - expect(sendContributionConfirmedEmail).toBeCalledWith( - expect.objectContaining({ - contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - overviewURL: 'http://localhost/overview', - recipientEmail: 'bibi@bloxberg.de', - recipientFirstName: 'Bibi', - recipientLastName: 'Bloxberg', - senderFirstName: 'Peter', - senderLastName: 'Lustig', - }), - ) - }) - - it('stores the send confirmation email event in the database', async () => { - await expect(EventProtocol.find()).resolves.toContainEqual( - expect.objectContaining({ - type: EventProtocolType.SEND_CONFIRMATION_EMAIL, - }), - ) - }) - }) - - describe('confirm two creations one after the other quickly', () => { - let c1: Contribution | void - let c2: Contribution | void - - beforeAll(async () => { - const now = new Date() - c1 = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 50, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - c2 = await creationFactory(testEnv, { - email: 'bibi@bloxberg.de', - amount: 50, - memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: contributionDateFormatter( - new Date(now.getFullYear(), now.getMonth() - 2, 1), - ), - }) - }) - - // In the futrue this should not throw anymore - it('throws an error for the second confirmation', async () => { - const r1 = mutate({ - mutation: confirmContribution, - variables: { - id: c1 ? c1.id : -1, - }, - }) - const r2 = mutate({ - mutation: confirmContribution, - variables: { - id: c2 ? c2.id : -1, - }, - }) - await expect(r1).resolves.toEqual( - expect.objectContaining({ - data: { confirmContribution: true }, - }), - ) - await expect(r2).resolves.toEqual( - expect.objectContaining({ - // data: { confirmContribution: true }, - errors: [new GraphQLError('Creation was not successful.')], - }), - ) - }) - }) - }) - }) - }) - }) - - describe('transaction links list', () => { - const variables = { - userId: 1, // dummy, may be replaced - filters: null, - currentPage: 1, - pageSize: 5, - } - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - // admin 'peter@lustig.de' has to exists for 'creationFactory' - admin = await userFactory(testEnv, peterLustig) - - user = await userFactory(testEnv, bibiBloxberg) - variables.userId = user.id - variables.pageSize = 25 - // bibi needs GDDs - const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await creationFactory(testEnv, bibisCreation!) - // bibis transaktion links - const bibisTransaktionLinks = transactionLinks.filter( - (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', - ) - for (let i = 0; i < bibisTransaktionLinks.length; i++) { - await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) - } - - // admin: only now log in - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('without any filters', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('all filters are null', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('filter with deleted', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - describe('filter by expired', () => { - it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withExpired: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - - // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory - describe.skip('filter by redeemed', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Yeah, eingelöst!', - redeemedAt: expect.any(String), - redeemedBy: expect.any(Number), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) - }) - }) - }) - - describe('Contribution Links', () => { - const now = new Date() - const variables = { - amount: new Decimal(200), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - cycle: 'once', - validFrom: new Date(2022, 5, 18).toISOString(), - validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), - maxAmountPerMonth: new Decimal(200), - maxPerCycle: 1, - } - - describe('unauthenticated', () => { - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listContributionLinks', () => { - it('returns an error', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - // TODO: Set this test in new location to have datas - describe('listContributionLinks', () => { - it('returns an empty object', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - count: 0, - links: [], - }, - }, - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns a contribution link object', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - data: { - createContributionLink: expect.objectContaining({ - id: expect.any(Number), - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - }, - }), - ) - }) - - it('has a contribution link stored in db', async () => { - const cls = await DbContributionLink.find() - expect(cls).toHaveLength(1) - expect(cls[0]).toEqual( - expect.objectContaining({ - id: expect.any(Number), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: new Date('2022-06-18T00:00:00.000Z'), - validTo: expect.any(Date), - cycle: 'once', - maxPerCycle: 1, - totalMaxCountOfContribution: null, - maxAccountBalance: null, - minGapHours: null, - createdAt: expect.any(Date), - deletedAt: null, - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - linkEnabled: true, - amount: expect.decimalEqual(200), - maxAmountPerMonth: expect.decimalEqual(200), - }), - ) - }) - - it('returns an error if missing startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Start-Date is not initialized. A Start-Date must be set!', - ) - }) - - it('returns an error if missing endDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validTo: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'End-Date is not initialized. An End-Date must be set!', - ) - }) - - it('returns an error if endDate is before startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), - validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError(`The value of validFrom must before or equals the validTo!`), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of validFrom must before or equals the validTo!`, - ) - }) - - it('returns an error if name is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The name must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The name must be initialized!') - }) - - it('returns an error if name is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if name is longer than 100 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if memo is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The memo must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The memo must be initialized!') - }) - - it('returns an error if memo is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if memo is longer than 255 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if amount is not positive', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - amount: new Decimal(0), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('The amount=0 must be initialized with a positiv value!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount=0 must be initialized with a positiv value!', - ) - }) - }) - - describe('listContributionLinks', () => { - describe('one link in DB', () => { - it('returns the link and count 1', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: expect.arrayContaining([ - expect.objectContaining({ - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - ]), - count: 1, - }, - }, - }), - ) - }) - }) - }) - - describe('updateContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns updated contribution link object', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: linkId, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - updateContributionLink: { - id: linkId, - amount: '400', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }, - }, - }), - ) - }) - - it('updated the DB record', async () => { - await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( - expect.objectContaining({ - id: linkId, - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - amount: expect.decimalEqual(400), - }), - ) - }) - }) - }) - - describe('deleteContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns a date string', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - deleteContributionLink: expect.any(String), - }, - }), - ) - }) - - it('does not list this contribution link anymore', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: [], - count: 0, - }, - }, - }), - ) - }) - }) - }) - }) - }) - }) -}) diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index e512961e7..0b1113df9 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -1,28 +1,53 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import Decimal from 'decimal.js-light' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' import { - adminUpdateContribution, - confirmContribution, createContribution, - deleteContribution, updateContribution, + deleteContribution, + confirmContribution, + adminCreateContribution, + adminCreateContributions, + adminUpdateContribution, + adminDeleteContribution, login, } from '@/seeds/graphql/mutations' -import { listAllContributions, listContributions } from '@/seeds/graphql/queries' -import { cleanDB, resetToken, testEnvironment } from '@test/helpers' +import { + listAllContributions, + listContributions, + listUnconfirmedContributions, +} from '@/seeds/graphql/queries' +import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' import { peterLustig } from '@/seeds/users/peter-lustig' import { EventProtocol } from '@entity/EventProtocol' +import { Contribution } from '@entity/Contribution' +import { Transaction as DbTransaction } from '@entity/Transaction' +import { User } from '@entity/User' import { EventProtocolType } from '@/event/EventProtocolType' import { logger } from '@test/testSetup' +// mock account activation email to avoid console spam +jest.mock('@/mailer/sendContributionConfirmedEmail', () => { + return { + __esModule: true, + sendContributionConfirmedEmail: jest.fn(), + } +}) + let mutate: any, query: any, con: any let testEnv: any +let creation: Contribution | void +let user: User +let admin: User let result: any beforeAll(async () => { @@ -876,4 +901,1084 @@ describe('ContributionResolver', () => { }) }) }) + + describe('contributions', () => { + const variables = { + email: 'bibi@bloxberg.de', + amount: new Decimal(2000), + memo: 'Aktives Grundeinkommen', + creationDate: 'not-valid', + } + + describe('unauthenticated', () => { + describe('adminCreateContribution', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminCreateContributions', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns an error', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('adminCreateContribution', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: adminCreateContribution, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminCreateContributions', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns an error', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmContribution', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('adminCreateContribution', () => { + const now = new Date() + + beforeAll(async () => { + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), + }) + }) + + describe('user to create for does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Could not find user with email: bibi@bloxberg.de')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find user with email: bibi@bloxberg.de', + ) + }) + }) + + describe('user to create for is deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, stephenHawking) + variables.email = 'stephen@hawking.uk' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('This user was deleted. Cannot create a contribution.'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'This user was deleted. Cannot create a contribution.', + ) + }) + }) + + describe('user to create for has email not confirmed', () => { + beforeAll(async () => { + user = await userFactory(testEnv, garrickOllivander) + variables.email = 'garrick@ollivander.com' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Contribution could not be saved, Email is not activated'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Contribution could not be saved, Email is not activated', + ) + }) + }) + + describe('valid user to create for', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + variables.email = 'bibi@bloxberg.de' + variables.creationDate = 'invalid-date' + }) + + describe('date of creation is not a date string', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) + }) + }) + + describe('date of creation is four months ago', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 4, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), + ) + }) + }) + + describe('date of creation is in the future', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() + 4, 1), + ) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), + ) + }) + }) + + describe('amount of creation is too high', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter(now) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) + }) + + describe('creation is valid', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(200) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminCreateContribution: [1000, 1000, 800], + }, + }), + ) + }) + + it('stores the admin create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, + userId: admin.id, + }), + ) + }) + }) + + describe('second creation surpasses the available amount ', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(1000) + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ) + }) + }) + }) + }) + + describe('adminCreateContributions', () => { + // at this point we have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 800] + // peter@lustig.de: [1000, 600, 1000] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + const massCreationVariables = [ + 'bibi@bloxberg.de', + 'peter@lustig.de', + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ].map((email) => { + return { + email, + amount: new Decimal(500), + memo: 'Grundeinkommen', + creationDate: contributionDateFormatter(new Date()), + } + }) + + it('returns success, two successful creation and three failed creations', async () => { + await expect( + mutate({ + mutation: adminCreateContributions, + variables: { pendingCreations: massCreationVariables }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminCreateContributions: { + success: true, + successfulContribution: ['bibi@bloxberg.de', 'peter@lustig.de'], + failedContribution: [ + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ], + }, + }, + }), + ) + }) + }) + + describe('adminUpdateContribution', () => { + // at this I expect to have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 300] + // peter@lustig.de: [1000, 600, 500] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + describe('user for creation to update does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'bob@baumeister.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Could not find UserContact with email: bob@baumeister.de'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find UserContact with email: bob@baumeister.de', + ) + }) + }) + + describe('user for creation to update is deleted', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: 1, + email: 'stephen@hawking.uk', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User was deleted (stephen@hawking.uk)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') + }) + }) + + describe('creation does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No contribution found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id.') + }) + }) + + describe('user email does not match creation user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'user of the pending contribution and send user does not correspond', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) + }) + + describe('creation update is not valid', () => { + // as this test has not clearly defined that date, it is a false positive + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(1900), + memo: 'Danke Peter!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) + }) + + describe.skip('creation update is successful changing month', () => { + // skipped as changing the month is currently disable + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(300), + memo: 'Danke Peter!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminUpdateContribution: { + date: expect.any(String), + memo: 'Danke Peter!', + amount: '300', + creation: ['1000', '700', '500'], + }, + }, + }), + ) + }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) + }) + + describe('creation update is successful without changing month', () => { + // actually this mutation IS changing the month + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(200), + memo: 'Das war leider zu Viel!', + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + adminUpdateContribution: { + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + creation: ['1000', '800', '500'], + }, + }, + }), + ) + }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) + }) + }) + + describe('listUnconfirmedContributions', () => { + it('returns four pending creations', async () => { + await expect( + query({ + query: listUnconfirmedContributions, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listUnconfirmedContributions: expect.arrayContaining([ + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + moderator: admin.id, + creation: ['1000', '800', '500'], + }, + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '800', '500'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Aktives Grundeinkommen', + amount: '200', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + ]), + }, + }), + ) + }) + }) + + describe('adminDeleteContribution', () => { + describe('creation id does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution not found for given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) + }) + + describe('admin deletes own user contribution', () => { + beforeAll(async () => { + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + result = await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: contributionDateFormatter(new Date()), + }, + }) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: result.data.createContribution.id, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Own contribution can not be deleted as admin')], + }), + ) + }) + }) + + describe('creation id does exist', () => { + it('returns true', async () => { + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { adminDeleteContribution: true }, + }), + ) + }) + + it('stores the admin delete contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, + userId: admin.id, + }), + ) + }) + }) + }) + + describe('confirmContribution', () => { + describe('creation does not exits', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) + }) + + describe('confirm own creation', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), + }) + }) + + it('thows an error', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not confirm own contribution')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') + }) + }) + + describe('confirm creation for other user', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 450, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + }) + + it('returns true', async () => { + await expect( + mutate({ + mutation: confirmContribution, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { confirmContribution: true }, + }), + ) + }) + + it('stores the contribution confirm event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CONFIRM, + }), + ) + }) + + it('creates a transaction', async () => { + const transaction = await DbTransaction.find() + expect(transaction[0].amount.toString()).toBe('450') + expect(transaction[0].memo).toBe('Herzlich Willkommen bei Gradido liebe Bibi!') + expect(transaction[0].linkedTransactionId).toEqual(null) + expect(transaction[0].transactionLinkId).toEqual(null) + expect(transaction[0].previous).toEqual(null) + expect(transaction[0].linkedUserId).toEqual(null) + expect(transaction[0].typeId).toEqual(1) + }) + + it('calls sendContributionConfirmedEmail', async () => { + expect(sendContributionConfirmedEmail).toBeCalledWith( + expect.objectContaining({ + contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + overviewURL: 'http://localhost/overview', + recipientEmail: 'bibi@bloxberg.de', + recipientFirstName: 'Bibi', + recipientLastName: 'Bloxberg', + senderFirstName: 'Peter', + senderLastName: 'Lustig', + }), + ) + }) + + it('stores the send confirmation email event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.SEND_CONFIRMATION_EMAIL, + }), + ) + }) + }) + + describe('confirm two creations one after the other quickly', () => { + let c1: Contribution | void + let c2: Contribution | void + + beforeAll(async () => { + const now = new Date() + c1 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + c2 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), + }) + }) + + // In the futrue this should not throw anymore + it('throws an error for the second confirmation', async () => { + const r1 = mutate({ + mutation: confirmContribution, + variables: { + id: c1 ? c1.id : -1, + }, + }) + const r2 = mutate({ + mutation: confirmContribution, + variables: { + id: c2 ? c2.id : -1, + }, + }) + await expect(r1).resolves.toEqual( + expect.objectContaining({ + data: { confirmContribution: true }, + }), + ) + await expect(r2).resolves.toEqual( + expect.objectContaining({ + // data: { confirmContribution: true }, + errors: [new GraphQLError('Creation was not successful.')], + }), + ) + }) + }) + }) + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 275242bd3..5d8e7ec91 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -4,26 +4,39 @@ import { transactionLinkCode } from './TransactionLinkResolver' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { cleanDB, testEnvironment } from '@test/helpers' +import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { creationFactory } from '@/seeds/factory/creation' +import { creations } from '@/seeds/creation/index' import { userFactory } from '@/seeds/factory/user' +import { transactionLinkFactory } from '@/seeds/factory/transactionLink' +import { transactionLinks } from '@/seeds/transactionLink/index' import { login, createContributionLink, + deleteContributionLink, + updateContributionLink, redeemTransactionLink, createContribution, updateContribution, } from '@/seeds/graphql/mutations' +import { listTransactionLinksAdmin, listContributionLinks } from '@/seeds/graphql/queries' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { User } from '@entity/User' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' +import { logger } from '@test/testSetup' -let mutate: any, con: any +let mutate: any, query: any, con: any let testEnv: any +let user: User +let admin: User + beforeAll(async () => { testEnv = await testEnvironment() mutate = testEnv.mutate + query = testEnv.query con = testEnv.con await cleanDB() await userFactory(testEnv, bibiBloxberg) @@ -223,6 +236,885 @@ describe('TransactionLinkResolver', () => { }) }) }) + + describe('transaction links list', () => { + const variables = { + userId: 1, // dummy, may be replaced + filters: null, + currentPage: 1, + pageSize: 5, + } + + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + // admin 'peter@lustig.de' has to exists for 'creationFactory' + admin = await userFactory(testEnv, peterLustig) + + user = await userFactory(testEnv, bibiBloxberg) + variables.userId = user.id + variables.pageSize = 25 + // bibi needs GDDs + const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + // bibis transaktion links + const bibisTransaktionLinks = transactionLinks.filter( + (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', + ) + for (let i = 0; i < bibisTransaktionLinks.length; i++) { + await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) + } + + // admin: only now log in + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter with deleted', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter by expired', () => { + it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withExpired: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory + describe.skip('filter by redeemed', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Yeah, eingelöst!', + redeemedAt: expect.any(String), + redeemedBy: expect.any(Number), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + }) + }) + }) + + describe('Contribution Links', () => { + const now = new Date() + const variables = { + amount: new Decimal(200), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + cycle: 'once', + validFrom: new Date(2022, 5, 18).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + } + + describe('unauthenticated', () => { + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listContributionLinks', () => { + it('returns an error', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + // TODO: Set this test in new location to have datas + describe('listContributionLinks', () => { + it('returns an empty object', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + count: 0, + links: [], + }, + }, + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns a contribution link object', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + createContributionLink: expect.objectContaining({ + id: expect.any(Number), + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + }, + }), + ) + }) + + it('has a contribution link stored in db', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + expect(cls[0]).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: new Date('2022-06-18T00:00:00.000Z'), + validTo: expect.any(Date), + cycle: 'once', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(200), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) + }) + + it('returns an error if missing startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + + it('returns an error if missing endDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validTo: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + + it('returns an error if endDate is before startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), + validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError(`The value of validFrom must before or equals the validTo!`), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + + it('returns an error if name is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The name must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + + it('returns an error if name is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if name is longer than 100 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if memo is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The memo must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + + it('returns an error if memo is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if memo is longer than 255 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if amount is not positive', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + amount: new Decimal(0), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('The amount=0 must be initialized with a positiv value!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) + }) + + describe('listContributionLinks', () => { + describe('one link in DB', () => { + it('returns the link and count 1', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: expect.arrayContaining([ + expect.objectContaining({ + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + ]), + count: 1, + }, + }, + }), + ) + }) + }) + }) + + describe('updateContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns updated contribution link object', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: linkId, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updateContributionLink: { + id: linkId, + amount: '400', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }, + }, + }), + ) + }) + + it('updated the DB record', async () => { + await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( + expect.objectContaining({ + id: linkId, + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + amount: expect.decimalEqual(400), + }), + ) + }) + }) + }) + + describe('deleteContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns a date string', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + deleteContributionLink: expect.any(String), + }, + }), + ) + }) + + it('does not list this contribution link anymore', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: [], + count: 0, + }, + }, + }), + ) + }) + }) + }) + }) + }) + }) }) describe('transactionLinkCode', () => { diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 6323abfde..965a85c29 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { objectValuesToArray } from '@/util/utilities' import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' @@ -13,8 +14,11 @@ import { updateUserInfos, createContribution, confirmContribution, + setUserRole, + deleteUser, + unDeleteUser, } from '@/seeds/graphql/mutations' -import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' +import { verifyLogin, queryOptIn, searchAdminUsers, searchUsers } from '@/seeds/graphql/queries' import { GraphQLError } from 'graphql' import { User } from '@entity/User' import CONFIG from '@/config' @@ -36,6 +40,8 @@ import { UserContact } from '@entity/UserContact' import { OptInType } from '../enum/OptInType' import { UserContactType } from '../enum/UserContactType' import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' // import { klicktippSignIn } from '@/apis/KlicktippController' @@ -69,6 +75,8 @@ jest.mock('@/apis/KlicktippController', () => { }) */ +let admin: User +let user: User let mutate: any, query: any, con: any let testEnv: any @@ -1159,6 +1167,635 @@ describe('UserResolver', () => { }) }) }) + + describe('set user role', () => { + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id + 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to get a new role does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('change role with success', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + describe('user gets new role', () => { + describe('to admin', () => { + it('returns date string', async () => { + const result = await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: true }, + }) + expect(result).toEqual( + expect.objectContaining({ + data: { + setUserRole: expect.any(String), + }, + }), + ) + expect(new Date(result.data.setUserRole)).toEqual(expect.any(Date)) + }) + }) + + describe('to usual user', () => { + it('returns null', async () => { + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + setUserRole: null, + }, + }), + ) + }) + }) + }) + }) + + describe('change role with error', () => { + describe('is own role', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Administrator can not change his own role!')], + }), + ) + }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Administrator can not change his own role!') + }) + }) + + describe('user has already role to be set', () => { + describe('to admin', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: true }, + }) + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is already admin!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already admin!') + }) + }) + + describe('to usual user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: setUserRole, + variables: { userId: user.id, isAdmin: false }, + }) + await expect( + mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is already a usual user!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already a usual user!') + }) + }) + }) + }) + }) + }) + }) + + describe('delete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be deleted does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('delete self', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not delete his own account!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') + }) + }) + + describe('delete with success', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('returns date string', async () => { + const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + expect(result).toEqual( + expect.objectContaining({ + data: { + deleteUser: expect.any(String), + }, + }), + ) + expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) + }) + + describe('delete deleted user', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) + }) + }) + }) + }) + }) + }) + + describe('unDelete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be undelete does not exist', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) + }) + + describe('user to undelete is not deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is not deleted')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is not deleted') + }) + + describe('undelete deleted user', () => { + beforeAll(async () => { + await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + }) + + it('returns null', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { unDeleteUser: null }, + }), + ) + }) + }) + }) + }) + }) + }) + + describe('search users', () => { + const variablesWithoutTextAndFilters = { + searchText: '', + currentPage: 1, + pageSize: 25, + filters: null, + } + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + const allUsers = { + bibi: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + garrick: expect.objectContaining({ + email: 'garrick@ollivander.com', + }), + peter: expect.objectContaining({ + email: 'peter@lustig.de', + }), + stephen: expect.objectContaining({ + email: 'stephen@hawking.uk', + }), + } + + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(objectValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: null, + byDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(objectValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('filter by unchecked email', () => { + it('finds only users with unchecked email', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: false, + byDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.garrick]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted users', () => { + it('finds only users with deleted account', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: null, + byDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.stephen]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted account and unchecked email', () => { + it('finds no users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + byActivated: false, + byDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 0, + userList: [], + }, + }, + }), + ) + }) + }) + }) + }) + }) }) describe('printTimeDuration', () => { diff --git a/backend/test/helpers.test.ts b/backend/test/helpers.test.ts new file mode 100644 index 000000000..69d8f3fa4 --- /dev/null +++ b/backend/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') + }) +}) From ecb99bd603480dc97d7f7ec1d224fd28506f7f7a Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:37:47 +0100 Subject: [PATCH 04/16] separate Contribution- and TransactionLink Resolvers --- backend/jest.config.js | 3 +- .../resolver/ContributionLinkResolver.test.ts | 650 ++++++++++++++++++ .../resolver/ContributionLinkResolver.ts | 152 ++++ .../resolver/TransactionLinkResolver.test.ts | 617 +---------------- .../resolver/TransactionLinkResolver.ts | 158 +---- backend/src/graphql/union/QueryLinkResult.ts | 7 + backend/tsconfig.json | 1 + 7 files changed, 818 insertions(+), 770 deletions(-) create mode 100644 backend/src/graphql/resolver/ContributionLinkResolver.test.ts create mode 100644 backend/src/graphql/resolver/ContributionLinkResolver.ts create mode 100644 backend/src/graphql/union/QueryLinkResult.ts diff --git a/backend/jest.config.js b/backend/jest.config.js index a472df316..d6683d292 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -9,9 +9,10 @@ module.exports = { modulePathIgnorePatterns: ['/build/'], moduleNameMapper: { '@/(.*)': '/src/$1', - '@model/(.*)': '/src/graphql/model/$1', '@arg/(.*)': '/src/graphql/arg/$1', '@enum/(.*)': '/src/graphql/enum/$1', + '@model/(.*)': '/src/graphql/model/$1', + '@union/(.*)': '/src/graphql/union/$1', '@repository/(.*)': '/src/typeorm/repository/$1', '@test/(.*)': '/test/$1', '@entity/(.*)': diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts new file mode 100644 index 000000000..b5f9e27e1 --- /dev/null +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -0,0 +1,650 @@ +import Decimal from 'decimal.js-light' +import { logger } from '@test/testSetup' +import { GraphQLError } from 'graphql' +import { + login, + createContributionLink, + deleteContributionLink, + updateContributionLink, +} from '@/seeds/graphql/mutations' +import { listContributionLinks } from '@/seeds/graphql/queries' +import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { User } from '@entity/User' +import { userFactory } from '@/seeds/factory/user' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' + +let mutate: any, query: any, con: any +let testEnv: any + +let user: User + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('Contribution Links', () => { + const now = new Date() + const variables = { + amount: new Decimal(200), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + cycle: 'once', + validFrom: new Date(2022, 5, 18).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + } + + describe('unauthenticated', () => { + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('listContributionLinks', () => { + it('returns an error', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + // TODO: Set this test in new location to have datas + describe('listContributionLinks', () => { + it('returns an empty object', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + count: 0, + links: [], + }, + }, + }), + ) + }) + }) + + describe('updateContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deleteContributionLink', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, peterLustig) + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createContributionLink', () => { + it('returns a contribution link object', async () => { + await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( + expect.objectContaining({ + data: { + createContributionLink: expect.objectContaining({ + id: expect.any(Number), + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + }, + }), + ) + }) + + it('has a contribution link stored in db', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + expect(cls[0]).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: new Date('2022-06-18T00:00:00.000Z'), + validTo: expect.any(Date), + cycle: 'once', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(200), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) + }) + + it('returns an error if missing startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + + it('returns an error if missing endDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validTo: null, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + + it('returns an error if endDate is before startDate', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), + validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError(`The value of validFrom must before or equals the validTo!`), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + + it('returns an error if name is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The name must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + + it('returns an error if name is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if name is longer than 100 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + + it('returns an error if memo is an empty string', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The memo must be initialized!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + + it('returns an error if memo is shorter than 5 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '123', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if memo is longer than 255 characters', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ), + ], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + + it('returns an error if amount is not positive', async () => { + await expect( + mutate({ + mutation: createContributionLink, + variables: { + ...variables, + amount: new Decimal(0), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The amount=0 must be initialized with a positiv value!')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) + }) + + describe('listContributionLinks', () => { + describe('one link in DB', () => { + it('returns the link and count 1', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: expect.arrayContaining([ + expect.objectContaining({ + amount: '200', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2022', + memo: 'Danke für deine Teilnahme an der Dokumenta 2022', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }), + ]), + count: 1, + }, + }, + }), + ) + }) + }) + }) + + describe('updateContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: -1, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns updated contribution link object', async () => { + await expect( + mutate({ + mutation: updateContributionLink, + variables: { + ...variables, + id: linkId, + amount: new Decimal(400), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updateContributionLink: { + id: linkId, + amount: '400', + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), + createdAt: expect.any(String), + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + validFrom: expect.any(String), + validTo: expect.any(String), + maxAmountPerMonth: '200', + cycle: 'once', + maxPerCycle: 1, + }, + }, + }), + ) + }) + + it('updated the DB record', async () => { + await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( + expect.objectContaining({ + id: linkId, + name: 'Dokumenta 2023', + memo: 'Danke für deine Teilnahme an der Dokumenta 2023', + amount: expect.decimalEqual(400), + }), + ) + }) + }) + }) + + describe('deleteContributionLink', () => { + describe('no valid id', () => { + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Contribution Link not found to given id.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + }) + + describe('valid id', () => { + let linkId: number + beforeAll(async () => { + const links = await query({ query: listContributionLinks }) + linkId = links.data.listContributionLinks.links[0].id + }) + + it('returns a date string', async () => { + await expect( + mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + deleteContributionLink: expect.any(String), + }, + }), + ) + }) + + it('does not list this contribution link anymore', async () => { + await expect(query({ query: listContributionLinks })).resolves.toEqual( + expect.objectContaining({ + data: { + listContributionLinks: { + links: [], + count: 0, + }, + }, + }), + ) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.ts b/backend/src/graphql/resolver/ContributionLinkResolver.ts new file mode 100644 index 000000000..0a6bb971c --- /dev/null +++ b/backend/src/graphql/resolver/ContributionLinkResolver.ts @@ -0,0 +1,152 @@ +import Decimal from 'decimal.js-light' +import { Resolver, Args, Arg, Authorized, Mutation, Query, Int } from 'type-graphql' +import { MoreThan, IsNull } from '@dbTools/typeorm' + +import { + CONTRIBUTIONLINK_NAME_MAX_CHARS, + CONTRIBUTIONLINK_NAME_MIN_CHARS, + MEMO_MAX_CHARS, + MEMO_MIN_CHARS, +} from './const/const' +import { isStartEndDateValid } from './util/creations' +import { ContributionLinkList } from '@model/ContributionLinkList' +import { ContributionLink } from '@model/ContributionLink' +import ContributionLinkArgs from '@arg/ContributionLinkArgs' +import { backendLogger as logger } from '@/server/logger' +import { RIGHTS } from '@/auth/RIGHTS' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { Order } from '@enum/Order' +import Paginated from '@arg/Paginated' + +// TODO: this is a strange construct +import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' + +@Resolver() +export class ContributionLinkResolver { + @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async createContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + ): Promise { + isStartEndDateValid(validFrom, validTo) + if (!name) { + logger.error(`The name must be initialized!`) + throw new Error(`The name must be initialized!`) + } + if ( + name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || + name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS + ) { + const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!memo) { + logger.error(`The memo must be initialized!`) + throw new Error(`The memo must be initialized!`) + } + if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { + const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` + logger.error(`${msg}`) + throw new Error(`${msg}`) + } + if (!amount) { + logger.error(`The amount must be initialized!`) + throw new Error('The amount must be initialized!') + } + if (!new Decimal(amount).isPositive()) { + logger.error(`The amount=${amount} must be initialized with a positiv value!`) + throw new Error(`The amount=${amount} must be initialized with a positiv value!`) + } + const dbContributionLink = new DbContributionLink() + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.createdAt = new Date() + dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`createContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } + + @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) + @Query(() => ContributionLinkList) + async listContributionLinks( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + ): Promise { + const [links, count] = await DbContributionLink.findAndCount({ + where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], + order: { createdAt: order }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return { + links: links.map((link: DbContributionLink) => new ContributionLink(link)), + count, + } + } + + @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) + @Mutation(() => Date, { nullable: true }) + async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { + const contributionLink = await DbContributionLink.findOne(id) + if (!contributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + await contributionLink.softRemove() + logger.debug(`deleteContributionLink successful!`) + const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) + return newContributionLink ? newContributionLink.deletedAt : null + } + + @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) + @Mutation(() => ContributionLink) + async updateContributionLink( + @Args() + { + amount, + name, + memo, + cycle, + validFrom, + validTo, + maxAmountPerMonth, + maxPerCycle, + }: ContributionLinkArgs, + @Arg('id', () => Int) id: number, + ): Promise { + const dbContributionLink = await DbContributionLink.findOne(id) + if (!dbContributionLink) { + logger.error(`Contribution Link not found to given id: ${id}`) + throw new Error('Contribution Link not found to given id.') + } + dbContributionLink.amount = amount + dbContributionLink.name = name + dbContributionLink.memo = memo + dbContributionLink.cycle = cycle + if (validFrom) dbContributionLink.validFrom = new Date(validFrom) + if (validTo) dbContributionLink.validTo = new Date(validTo) + dbContributionLink.maxAmountPerMonth = maxAmountPerMonth + dbContributionLink.maxPerCycle = maxPerCycle + await dbContributionLink.save() + logger.debug(`updateContributionLink successful!`) + return new ContributionLink(dbContributionLink) + } +} diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 5d8e7ec91..6f500db0a 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -13,19 +13,16 @@ import { transactionLinks } from '@/seeds/transactionLink/index' import { login, createContributionLink, - deleteContributionLink, - updateContributionLink, redeemTransactionLink, createContribution, updateContribution, } from '@/seeds/graphql/mutations' -import { listTransactionLinksAdmin, listContributionLinks } from '@/seeds/graphql/queries' +import { listTransactionLinksAdmin } from '@/seeds/graphql/queries' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { User } from '@entity/User' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' -import { logger } from '@test/testSetup' let mutate: any, query: any, con: any let testEnv: any @@ -49,6 +46,7 @@ afterAll(async () => { }) describe('TransactionLinkResolver', () => { + // TODO: have this test separated into a transactionLink and a contributionLink part (if possible) describe('redeem daily Contribution Link', () => { const now = new Date() let contributionLink: DbContributionLink | undefined @@ -504,617 +502,6 @@ describe('TransactionLinkResolver', () => { }) }) }) - - describe('Contribution Links', () => { - const now = new Date() - const variables = { - amount: new Decimal(200), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - cycle: 'once', - validFrom: new Date(2022, 5, 18).toISOString(), - validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), - maxAmountPerMonth: new Decimal(200), - maxPerCycle: 1, - } - - describe('unauthenticated', () => { - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('listContributionLinks', () => { - it('returns an error', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) - await mutate({ - mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns an error', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - // TODO: Set this test in new location to have datas - describe('listContributionLinks', () => { - it('returns an empty object', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - count: 0, - links: [], - }, - }, - }), - ) - }) - }) - - describe('updateContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('deleteContributionLink', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - }) - - describe('with admin rights', () => { - beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, - }) - }) - - afterAll(async () => { - await cleanDB() - resetToken() - }) - - describe('createContributionLink', () => { - it('returns a contribution link object', async () => { - await expect(mutate({ mutation: createContributionLink, variables })).resolves.toEqual( - expect.objectContaining({ - data: { - createContributionLink: expect.objectContaining({ - id: expect.any(Number), - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - }, - }), - ) - }) - - it('has a contribution link stored in db', async () => { - const cls = await DbContributionLink.find() - expect(cls).toHaveLength(1) - expect(cls[0]).toEqual( - expect.objectContaining({ - id: expect.any(Number), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: new Date('2022-06-18T00:00:00.000Z'), - validTo: expect.any(Date), - cycle: 'once', - maxPerCycle: 1, - totalMaxCountOfContribution: null, - maxAccountBalance: null, - minGapHours: null, - createdAt: expect.any(Date), - deletedAt: null, - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - linkEnabled: true, - amount: expect.decimalEqual(200), - maxAmountPerMonth: expect.decimalEqual(200), - }), - ) - }) - - it('returns an error if missing startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'Start-Date is not initialized. A Start-Date must be set!', - ) - }) - - it('returns an error if missing endDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validTo: null, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'End-Date is not initialized. An End-Date must be set!', - ) - }) - - it('returns an error if endDate is before startDate', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - validFrom: new Date('2022-06-18T00:00:00.001Z').toISOString(), - validTo: new Date('2022-06-18T00:00:00.000Z').toISOString(), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError(`The value of validFrom must before or equals the validTo!`), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of validFrom must before or equals the validTo!`, - ) - }) - - it('returns an error if name is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The name must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The name must be initialized!') - }) - - it('returns an error if name is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if name is longer than 100 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - name: '12345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, - ) - }) - - it('returns an error if memo is an empty string', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('The memo must be initialized!')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('The memo must be initialized!') - }) - - it('returns an error if memo is shorter than 5 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '123', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if memo is longer than 255 characters', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, - ) - }) - - it('returns an error if amount is not positive', async () => { - await expect( - mutate({ - mutation: createContributionLink, - variables: { - ...variables, - amount: new Decimal(0), - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('The amount=0 must be initialized with a positiv value!'), - ], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'The amount=0 must be initialized with a positiv value!', - ) - }) - }) - - describe('listContributionLinks', () => { - describe('one link in DB', () => { - it('returns the link and count 1', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: expect.arrayContaining([ - expect.objectContaining({ - amount: '200', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2022', - memo: 'Danke für deine Teilnahme an der Dokumenta 2022', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }), - ]), - count: 1, - }, - }, - }), - ) - }) - }) - }) - - describe('updateContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: -1, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns updated contribution link object', async () => { - await expect( - mutate({ - mutation: updateContributionLink, - variables: { - ...variables, - id: linkId, - amount: new Decimal(400), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - updateContributionLink: { - id: linkId, - amount: '400', - code: expect.stringMatching(/^[0-9a-f]{24,24}$/), - link: expect.stringMatching(/^.*?\/CL-[0-9a-f]{24,24}$/), - createdAt: expect.any(String), - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - validFrom: expect.any(String), - validTo: expect.any(String), - maxAmountPerMonth: '200', - cycle: 'once', - maxPerCycle: 1, - }, - }, - }), - ) - }) - - it('updated the DB record', async () => { - await expect(DbContributionLink.findOne(linkId)).resolves.toEqual( - expect.objectContaining({ - id: linkId, - name: 'Dokumenta 2023', - memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - amount: expect.decimalEqual(400), - }), - ) - }) - }) - }) - - describe('deleteContributionLink', () => { - describe('no valid id', () => { - it('returns an error', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: -1 } }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('Contribution Link not found to given id.')], - }), - ) - }) - - it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') - }) - }) - - describe('valid id', () => { - let linkId: number - beforeAll(async () => { - const links = await query({ query: listContributionLinks }) - linkId = links.data.listContributionLinks.links[0].id - }) - - it('returns a date string', async () => { - await expect( - mutate({ mutation: deleteContributionLink, variables: { id: linkId } }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - deleteContributionLink: expect.any(String), - }, - }), - ) - }) - - it('does not list this contribution link anymore', async () => { - await expect(query({ query: listContributionLinks })).resolves.toEqual( - expect.objectContaining({ - data: { - listContributionLinks: { - links: [], - count: 0, - }, - }, - }), - ) - }) - }) - }) - }) - }) - }) }) describe('transactionLinkCode', () => { diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 297a96ce9..d983fe368 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'crypto' import Decimal from 'decimal.js-light' -import { getConnection, MoreThan, FindOperator, IsNull } from '@dbTools/typeorm' +import { getConnection, MoreThan, FindOperator } from '@dbTools/typeorm' import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink' import { User as DbUser } from '@entity/User' @@ -13,7 +13,6 @@ import { User } from '@model/User' import { ContributionLink } from '@model/ContributionLink' import { Decay } from '@model/Decay' import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { ContributionLinkList } from '@model/ContributionLinkList' import { Order } from '@enum/Order' import { ContributionType } from '@enum/ContributionType' import { ContributionStatus } from '@enum/ContributionStatus' @@ -22,38 +21,16 @@ import { ContributionCycleType } from '@enum/ContributionCycleType' import TransactionLinkArgs from '@arg/TransactionLinkArgs' import Paginated from '@arg/Paginated' import TransactionLinkFilters from '@arg/TransactionLinkFilters' -import ContributionLinkArgs from '@arg/ContributionLinkArgs' import { backendLogger as logger } from '@/server/logger' import { Context, getUser, getClientTimezoneOffset } from '@/server/context' -import { - Resolver, - Args, - Arg, - Authorized, - Ctx, - Mutation, - Query, - Int, - createUnionType, -} from 'type-graphql' +import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql' import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' -import { getUserCreation, validateContribution, isStartEndDateValid } from './util/creations' -import { - CONTRIBUTIONLINK_NAME_MAX_CHARS, - CONTRIBUTIONLINK_NAME_MIN_CHARS, - MEMO_MAX_CHARS, - MEMO_MIN_CHARS, -} from './const/const' +import { getUserCreation, validateContribution } from './util/creations' import { executeTransaction } from './TransactionResolver' -import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' - -const QueryLinkResult = createUnionType({ - name: 'QueryLinkResult', // the name of the GraphQL union - types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes -}) +import QueryLinkResult from '@union/QueryLinkResult' // TODO: do not export, test it inside the resolver export const transactionLinkCode = (date: Date): string => { @@ -401,131 +378,4 @@ export class TransactionLinkResolver { linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))), } } - - @Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async createContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - ): Promise { - isStartEndDateValid(validFrom, validTo) - if (!name) { - logger.error(`The name must be initialized!`) - throw new Error(`The name must be initialized!`) - } - if ( - name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS || - name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS - ) { - const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!memo) { - logger.error(`The memo must be initialized!`) - throw new Error(`The memo must be initialized!`) - } - if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) { - const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}` - logger.error(`${msg}`) - throw new Error(`${msg}`) - } - if (!amount) { - logger.error(`The amount must be initialized!`) - throw new Error('The amount must be initialized!') - } - if (!new Decimal(amount).isPositive()) { - logger.error(`The amount=${amount} must be initialized with a positiv value!`) - throw new Error(`The amount=${amount} must be initialized with a positiv value!`) - } - const dbContributionLink = new DbContributionLink() - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.createdAt = new Date() - dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt) - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`createContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } - - @Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS]) - @Query(() => ContributionLinkList) - async listContributionLinks( - @Args() - { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, - ): Promise { - const [links, count] = await DbContributionLink.findAndCount({ - where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], - order: { createdAt: order }, - skip: (currentPage - 1) * pageSize, - take: pageSize, - }) - return { - links: links.map((link: DbContributionLink) => new ContributionLink(link)), - count, - } - } - - @Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK]) - @Mutation(() => Date, { nullable: true }) - async deleteContributionLink(@Arg('id', () => Int) id: number): Promise { - const contributionLink = await DbContributionLink.findOne(id) - if (!contributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - await contributionLink.softRemove() - logger.debug(`deleteContributionLink successful!`) - const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true }) - return newContributionLink ? newContributionLink.deletedAt : null - } - - @Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK]) - @Mutation(() => ContributionLink) - async updateContributionLink( - @Args() - { - amount, - name, - memo, - cycle, - validFrom, - validTo, - maxAmountPerMonth, - maxPerCycle, - }: ContributionLinkArgs, - @Arg('id', () => Int) id: number, - ): Promise { - const dbContributionLink = await DbContributionLink.findOne(id) - if (!dbContributionLink) { - logger.error(`Contribution Link not found to given id: ${id}`) - throw new Error('Contribution Link not found to given id.') - } - dbContributionLink.amount = amount - dbContributionLink.name = name - dbContributionLink.memo = memo - dbContributionLink.cycle = cycle - if (validFrom) dbContributionLink.validFrom = new Date(validFrom) - if (validTo) dbContributionLink.validTo = new Date(validTo) - dbContributionLink.maxAmountPerMonth = maxAmountPerMonth - dbContributionLink.maxPerCycle = maxPerCycle - await dbContributionLink.save() - logger.debug(`updateContributionLink successful!`) - return new ContributionLink(dbContributionLink) - } } diff --git a/backend/src/graphql/union/QueryLinkResult.ts b/backend/src/graphql/union/QueryLinkResult.ts new file mode 100644 index 000000000..bcd0ad6b8 --- /dev/null +++ b/backend/src/graphql/union/QueryLinkResult.ts @@ -0,0 +1,7 @@ +import { createUnionType } from 'type-graphql' +import { TransactionLink } from '@model/TransactionLink' +import { ContributionLink } from '@model/ContributionLink' +export default createUnionType({ + name: 'QueryLinkResult', // the name of the GraphQL union + types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes +}) diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2e5a8b5b2..52241a0a6 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -51,6 +51,7 @@ "@arg/*": ["src/graphql/arg/*"], "@enum/*": ["src/graphql/enum/*"], "@model/*": ["src/graphql/model/*"], + "@union/*": ["src/graphql/union/*"], "@repository/*": ["src/typeorm/repository/*"], "@test/*": ["test/*"], /* external */ From 7b2e6730bde20e093041ad109f985b98a0e9aaa1 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:44:43 +0100 Subject: [PATCH 05/16] lint fixes --- .../graphql/resolver/ContributionLinkResolver.test.ts | 7 +++---- .../src/graphql/resolver/ContributionResolver.test.ts | 9 ++++----- .../src/graphql/resolver/TransactionLinkResolver.test.ts | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts index b5f9e27e1..c1a0895e2 100644 --- a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import Decimal from 'decimal.js-light' import { logger } from '@test/testSetup' import { GraphQLError } from 'graphql' @@ -11,15 +13,12 @@ import { listContributionLinks } from '@/seeds/graphql/queries' import { cleanDB, testEnvironment, resetToken } from '@test/helpers' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { User } from '@entity/User' import { userFactory } from '@/seeds/factory/user' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' let mutate: any, query: any, con: any let testEnv: any -let user: User - beforeAll(async () => { testEnv = await testEnvironment() mutate = testEnv.mutate @@ -185,7 +184,7 @@ describe('Contribution Links', () => { describe('with admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, peterLustig) + await userFactory(testEnv, peterLustig) await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 0b1113df9..1223ded0e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -46,7 +46,6 @@ jest.mock('@/mailer/sendContributionConfirmedEmail', () => { let mutate: any, query: any, con: any let testEnv: any let creation: Contribution | void -let user: User let admin: User let result: any @@ -1009,7 +1008,7 @@ describe('ContributionResolver', () => { describe('authenticated', () => { describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, @@ -1168,7 +1167,7 @@ describe('ContributionResolver', () => { describe('user to create for is deleted', () => { beforeAll(async () => { - user = await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, stephenHawking) variables.email = 'stephen@hawking.uk' variables.creationDate = contributionDateFormatter( new Date(now.getFullYear(), now.getMonth() - 1, 1), @@ -1197,7 +1196,7 @@ describe('ContributionResolver', () => { describe('user to create for has email not confirmed', () => { beforeAll(async () => { - user = await userFactory(testEnv, garrickOllivander) + await userFactory(testEnv, garrickOllivander) variables.email = 'garrick@ollivander.com' variables.creationDate = contributionDateFormatter( new Date(now.getFullYear(), now.getMonth() - 1, 1), @@ -1226,7 +1225,7 @@ describe('ContributionResolver', () => { describe('valid user to create for', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) variables.email = 'bibi@bloxberg.de' variables.creationDate = 'invalid-date' }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 6f500db0a..28422af26 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -28,7 +28,6 @@ let mutate: any, query: any, con: any let testEnv: any let user: User -let admin: User beforeAll(async () => { testEnv = await testEnvironment() @@ -296,7 +295,7 @@ describe('TransactionLinkResolver', () => { describe('with admin rights', () => { beforeAll(async () => { // admin 'peter@lustig.de' has to exists for 'creationFactory' - admin = await userFactory(testEnv, peterLustig) + await userFactory(testEnv, peterLustig) user = await userFactory(testEnv, bibiBloxberg) variables.userId = user.id From 6f8212fe548944a02470b324e67c2a4bc149646d Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 23 Nov 2022 23:52:53 +0100 Subject: [PATCH 06/16] test fix --- backend/src/graphql/resolver/ContributionLinkResolver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts index c1a0895e2..0cf27bf33 100644 --- a/backend/src/graphql/resolver/ContributionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionLinkResolver.test.ts @@ -110,7 +110,7 @@ describe('Contribution Links', () => { describe('authenticated', () => { describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, From 7f9190f276bef05aca6d16169297fef0e06f9fa2 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 29 Nov 2022 06:31:57 +0100 Subject: [PATCH 07/16] lint fix --- backend/src/graphql/resolver/TransactionResolver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index ec6b2597c..457d42f1d 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -11,7 +11,6 @@ import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionRepository } from '@repository/Transaction' import { TransactionLinkRepository } from '@repository/TransactionLink' -import { Decay } from '@model/Decay' import { User } from '@model/User' import { Transaction } from '@model/Transaction' import { TransactionList } from '@model/TransactionList' From c5f4e95847ef7554f2608bbd8756b094e598624e Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 16:54:48 +0100 Subject: [PATCH 08/16] fix(database): create missing users for transactions --- .../0056-consistent_transactions_table.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 database/migrations/0056-consistent_transactions_table.ts diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts new file mode 100644 index 000000000..968bcf3b0 --- /dev/null +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -0,0 +1,32 @@ +/* MIGRATION TO add users that have a transaction but do not exist */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { v4 as uuidv4 } from 'uuid' + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + const missingUserIds = await queryFn(` + SELECT user_id FROM transactions + WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`) + + for (let i = 0; i < missingUserIds.length; i++) { + let gradidoId = null + let countIds = null + do { + gradidoId = uuidv4() + countIds = await queryFn( + `SELECT COUNT(*) FROM \`users\` WHERE \`gradido_id\` = "${gradidoId}"`, + ) + } while (countIds[0] > 0) + + await queryFn(` + INSERT INTO users + (id, gradido_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) + VALUES + (${missingUserIds[i].user_id}, '${gradidoId}', 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + } +} + +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) {} From ed3a76dfdcaaa17804eff3af9533acc31e16452f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 16:56:24 +0100 Subject: [PATCH 09/16] update database version --- backend/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ee99ef809..2b79e6a08 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0055-consistent_deleted_users', + DB_VERSION: '0056-consistent_transactions_table', 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 From 345123af116a79f2aa2f19e3ed46b22d05d0015c Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:10:35 +0100 Subject: [PATCH 10/16] add user contact for missing users --- .../migrations/0056-consistent_transactions_table.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index 968bcf3b0..f3db927ba 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -19,11 +19,17 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis ) } while (countIds[0] > 0) + const userContact = await queryFn(` + INSERT INTO user_contacts + (type, user_id, email, email_checked, created_at, deleted_at) + VALUES + ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) + await queryFn(` INSERT INTO users - (id, gradido_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) + (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From 251f87554c96f5172da3d7e3a4aa132b805db8bd Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:14:44 +0100 Subject: [PATCH 11/16] kack typescript --- database/migrations/0056-consistent_transactions_table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index f3db927ba..6cd462552 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -29,7 +29,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId ? userContact.insertId : 0}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From fed61bf8884d016108c9cb8c92f18df28495ec3b Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:16:56 +0100 Subject: [PATCH 12/16] kack typescript --- database/migrations/0056-consistent_transactions_table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index 6cd462552..e8e7111ac 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -29,7 +29,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId ? userContact.insertId : 0}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact[0].insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From caa16c04881be152a6550f3bac1c56a26bd43f47 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Thu, 1 Dec 2022 17:36:17 +0100 Subject: [PATCH 13/16] insert correct email id --- database/migrations/0056-consistent_transactions_table.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index e8e7111ac..af7d8988e 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -25,11 +25,13 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis VALUES ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) + const emaiId = Object.values(userContact)[Object.keys(userContact).indexOf('insertId')] + await queryFn(` INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact[0].insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${emaiId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } } From 7638d290521903a8b2eab64fcc76dc312f241f2a Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Fri, 9 Dec 2022 14:09:43 +0100 Subject: [PATCH 14/16] merge --- .../resolver/ContributionMessageResolver.ts | 13 +++--- .../resolver/ContributionResolver.test.ts | 44 ++++++++++++------- .../graphql/resolver/ContributionResolver.ts | 24 +++++----- backend/src/graphql/resolver/UserResolver.ts | 9 ++-- 4 files changed, 50 insertions(+), 40 deletions(-) diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.ts b/backend/src/graphql/resolver/ContributionMessageResolver.ts index 1f47a14d6..38bea804e 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.ts @@ -15,8 +15,7 @@ import Paginated from '@arg/Paginated' import { backendLogger as logger } from '@/server/logger' import { RIGHTS } from '@/auth/RIGHTS' import { Context, getUser } from '@/server/context' -import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' -import CONFIG from '@/config' +import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants' @Resolver() export class ContributionMessageResolver { @@ -139,15 +138,13 @@ export class ContributionMessageResolver { } await sendAddedContributionMessageEmail({ + firstName: contribution.user.firstName, + lastName: contribution.user.lastName, + email: contribution.user.emailContact.email, + language: contribution.user.language, senderFirstName: user.firstName, senderLastName: user.lastName, - recipientFirstName: contribution.user.firstName, - recipientLastName: contribution.user.lastName, - recipientEmail: contribution.user.emailContact.email, - senderEmail: user.emailContact.email, contributionMemo: contribution.memo, - message, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) await queryRunner.commitTransaction() } catch (e) { diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 1223ded0e..387018624 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -21,7 +21,11 @@ import { listContributions, listUnconfirmedContributions, } from '@/seeds/graphql/queries' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { + // sendAccountActivationEmail, + sendContributionConfirmedEmail, + // sendContributionRejectedEmail, +} from '@/emails/sendEmailVariants' import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' @@ -33,13 +37,22 @@ import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' import { User } from '@entity/User' import { EventProtocolType } from '@/event/EventProtocolType' -import { logger } from '@test/testSetup' +import { logger, i18n as localization } from '@test/testSetup' // mock account activation email to avoid console spam -jest.mock('@/mailer/sendContributionConfirmedEmail', () => { +// mock account activation email to avoid console spam +jest.mock('@/emails/sendEmailVariants', () => { + const originalModule = jest.requireActual('@/emails/sendEmailVariants') return { __esModule: true, - sendContributionConfirmedEmail: jest.fn(), + ...originalModule, + // TODO: test the call of … + // sendAccountActivationEmail: jest.fn((a) => originalModule.sendAccountActivationEmail(a)), + sendContributionConfirmedEmail: jest.fn((a) => + originalModule.sendContributionConfirmedEmail(a), + ), + // TODO: test the call of … + // sendContributionRejectedEmail: jest.fn((a) => originalModule.sendContributionRejectedEmail(a)), } }) @@ -50,7 +63,7 @@ let admin: User let result: any beforeAll(async () => { - testEnv = await testEnvironment() + testEnv = await testEnvironment(logger, localization) mutate = testEnv.mutate query = testEnv.query con = testEnv.con @@ -1903,17 +1916,16 @@ describe('ContributionResolver', () => { }) it('calls sendContributionConfirmedEmail', async () => { - expect(sendContributionConfirmedEmail).toBeCalledWith( - expect.objectContaining({ - contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - overviewURL: 'http://localhost/overview', - recipientEmail: 'bibi@bloxberg.de', - recipientFirstName: 'Bibi', - recipientLastName: 'Bloxberg', - senderFirstName: 'Peter', - senderLastName: 'Lustig', - }), - ) + expect(sendContributionConfirmedEmail).toBeCalledWith({ + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + language: 'de', + senderFirstName: 'Peter', + senderLastName: 'Lustig', + contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + contributionAmount: expect.decimalEqual(450), + }) }) it('stores the send confirmation email event in the database', async () => { diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index d3e72c2ff..32c72b9b1 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -45,10 +45,11 @@ import { EventAdminContributionUpdate, } from '@/event/Event' import { eventProtocol } from '@/event/EventProtocolEmitter' -import CONFIG from '@/config' -import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' import { calculateDecay } from '@/util/decay' -import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { + sendContributionConfirmedEmail, + sendContributionRejectedEmail, +} from '@/emails/sendEmailVariants' @Resolver() export class ContributionResolver { @@ -533,14 +534,13 @@ export class ContributionResolver { event.setEventAdminContributionDelete(eventAdminContributionDelete), ) sendContributionRejectedEmail({ + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, senderFirstName: moderator.firstName, senderLastName: moderator.lastName, - recipientEmail: user.emailContact.email, - recipientFirstName: user.firstName, - recipientLastName: user.lastName, contributionMemo: contribution.memo, - contributionAmount: contribution.amount, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) return !!res @@ -628,14 +628,14 @@ export class ContributionResolver { await queryRunner.commitTransaction() logger.info('creation commited successfuly.') sendContributionConfirmedEmail({ + firstName: user.firstName, + lastName: user.lastName, + email: user.emailContact.email, + language: user.language, senderFirstName: moderatorUser.firstName, senderLastName: moderatorUser.lastName, - recipientFirstName: user.firstName, - recipientLastName: user.lastName, - recipientEmail: user.emailContact.email, contributionMemo: contribution.memo, contributionAmount: contribution.amount, - overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) } catch (e) { await queryRunner.rollbackTransaction() diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 0f89110e8..711dc48af 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1051,17 +1051,18 @@ export class UserResolver { } const emailContact = user.emailContact if (emailContact.deletedAt) { - logger.error(`The emailContact: ${email} of htis User is deleted.`) - throw new Error(`The emailContact: ${email} of htis User is deleted.`) + logger.error(`The emailContact: ${email} of this User is deleted.`) + throw new Error(`The emailContact: ${email} of this User is deleted.`) } // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ - link: activationLink(emailContact.emailVerificationCode), firstName: user.firstName, lastName: user.lastName, email, - duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), + language: user.language, + activationLink: activationLink(emailContact.emailVerificationCode), + timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), }) // In case EMails are disabled log the activation link for the user From 052f31d8ec1cb917e9d2305c3b6ab271ef7329d3 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Fri, 9 Dec 2022 14:10:46 +0100 Subject: [PATCH 15/16] lint --- backend/src/graphql/resolver/TransactionResolver.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 486222477..4b5754132 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -1,7 +1,6 @@ /* eslint-disable new-cap */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -<<<<<<< HEAD import Decimal from 'decimal.js-light' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { getCustomRepository, getConnection, In } from '@dbTools/typeorm' @@ -21,7 +20,6 @@ import TransactionSendArgs from '@arg/TransactionSendArgs' import Paginated from '@arg/Paginated' import { backendLogger as logger } from '@/server/logger' -import CONFIG from '@/config' import { Context, getUser } from '@/server/context' import { calculateBalance, isHexPublicKey } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' From 97b169da2e701bf4dfed931ad3c53c1b0da2d3c3 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Thu, 15 Dec 2022 11:49:28 +0100 Subject: [PATCH 16/16] properly typecast and do thing right --- .../0056-consistent_transactions_table.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/database/migrations/0056-consistent_transactions_table.ts b/database/migrations/0056-consistent_transactions_table.ts index af7d8988e..02ed3b7be 100644 --- a/database/migrations/0056-consistent_transactions_table.ts +++ b/database/migrations/0056-consistent_transactions_table.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { v4 as uuidv4 } from 'uuid' +import { OkPacket } from 'mysql' export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { const missingUserIds = await queryFn(` @@ -10,8 +11,8 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis WHERE NOT EXISTS (SELECT id FROM users WHERE id = user_id) GROUP BY user_id;`) for (let i = 0; i < missingUserIds.length; i++) { - let gradidoId = null - let countIds = null + let gradidoId = '' + let countIds: any[] = [] do { gradidoId = uuidv4() countIds = await queryFn( @@ -19,19 +20,17 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis ) } while (countIds[0] > 0) - const userContact = await queryFn(` + const userContact = (await queryFn(` INSERT INTO user_contacts (type, user_id, email, email_checked, created_at, deleted_at) VALUES - ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`) - - const emaiId = Object.values(userContact)[Object.keys(userContact).indexOf('insertId')] + ('EMAIL', ${missingUserIds[i].user_id}, 'deleted.user${missingUserIds[i].user_id}@gradido.net', 0, NOW(), NOW());`)) as unknown as OkPacket await queryFn(` INSERT INTO users (id, gradido_id, email_id, first_name, last_name, deleted_at, password_encryption_type, created_at, language) VALUES - (${missingUserIds[i].user_id}, '${gradidoId}', ${emaiId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) + (${missingUserIds[i].user_id}, '${gradidoId}', ${userContact.insertId}, 'DELETED', 'USER', NOW(), 0, NOW(), 'de');`) } }