diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 5258c9e3b..639326a64 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -28,6 +28,7 @@ export enum RIGHTS { CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', LIST_CONTRIBUTIONS = 'LIST_CONTRIBUTIONS', LIST_ALL_CONTRIBUTIONS = 'LIST_ALL_CONTRIBUTIONS', + UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION', // Admin SEARCH_USERS = 'SEARCH_USERS', SET_USER_ROLE = 'SET_USER_ROLE', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 4a96d4813..935bd94e5 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -26,6 +26,7 @@ export const ROLE_USER = new Role('user', [ RIGHTS.CREATE_CONTRIBUTION, RIGHTS.LIST_CONTRIBUTIONS, RIGHTS.LIST_ALL_CONTRIBUTIONS, + RIGHTS.UPDATE_CONTRIBUTION, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 75175edc2..12cad529c 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -47,11 +47,11 @@ import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import CONFIG from '@/config' import { - getCreationIndex, getUserCreation, getUserCreations, validateContribution, isStartEndDateValid, + updateCreations, } from './util/creations' import { CONTRIBUTIONLINK_MEMO_MAX_CHARS, @@ -321,6 +321,10 @@ export class AdminResolver { throw new Error('user of the pending contribution and send user does not correspond') } + if (contributionToUpdate.moderatorId === null) { + throw new Error('An admin is not allowed to update a user contribution.') + } + const creationDateObj = new Date(creationDate) let creations = await getUserCreation(user.id) if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { @@ -688,13 +692,3 @@ export class AdminResolver { return new ContributionLink(dbContributionLink) } } - -function updateCreations(creations: Decimal[], contribution: Contribution): Decimal[] { - const index = getCreationIndex(contribution.contributionDate.getMonth()) - - if (index < 0) { - throw new Error('You cannot create GDD for a month older than the last three months.') - } - creations[index] = creations[index].plus(contribution.amount.toString()) - return creations -} diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 124adff18..fd9636439 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -2,8 +2,17 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +<<<<<<< HEAD import { createContribution } from '@/seeds/graphql/mutations' import { listAllContributions, listContributions, login } from '@/seeds/graphql/queries' +======= +import { + adminUpdateContribution, + createContribution, + updateContribution, +} from '@/seeds/graphql/mutations' +import { listContributions, login } from '@/seeds/graphql/queries' +>>>>>>> master import { cleanDB, resetToken, testEnvironment } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' @@ -13,6 +22,7 @@ import { peterLustig } from '@/seeds/users/peter-lustig' let mutate: any, query: any, con: any let testEnv: any +let result: any beforeAll(async () => { testEnv = await testEnvironment() @@ -236,6 +246,7 @@ describe('ContributionResolver', () => { }) }) +<<<<<<< HEAD describe('listAllContribution', () => { describe('unauthenticated', () => { it('returns an error', async () => { @@ -247,6 +258,19 @@ describe('ContributionResolver', () => { pageSize: 25, order: 'DESC', filterConfirmed: false, +======= + describe('updateContribution', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: 1, + amount: 100.0, + memo: 'Test Contribution', + creationDate: 'not-valid', +>>>>>>> master }, }), ).resolves.toEqual( @@ -259,16 +283,25 @@ describe('ContributionResolver', () => { describe('authenticated', () => { beforeAll(async () => { +<<<<<<< HEAD await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, peterLustig) creations.forEach(async (creation) => await creationFactory(testEnv, creation!)) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion // await creationFactory(testEnv, creations!) +======= + await userFactory(testEnv, peterLustig) + await userFactory(testEnv, bibiBloxberg) +>>>>>>> master await query({ query: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) +<<<<<<< HEAD await mutate({ +======= + result = await mutate({ +>>>>>>> master mutation: createContribution, variables: { amount: 100.0, @@ -283,6 +316,7 @@ describe('ContributionResolver', () => { resetToken() }) +<<<<<<< HEAD it('returns allCreation', async () => { await expect( query({ @@ -312,6 +346,158 @@ describe('ContributionResolver', () => { }, }), ) +======= + describe('wrong contribution id', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: -1, + amount: 100.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No contribution found to given id.')], + }), + ) + }) + }) + + describe('wrong user tries to update the contribution', () => { + beforeAll(async () => { + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + it('throws an error', async () => { + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: result.data.createContribution.id, + amount: 10.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'user of the pending contribution and send user does not correspond', + ), + ], + }), + ) + }) + }) + + describe('admin tries to update a user contribution', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: adminUpdateContribution, + variables: { + id: result.data.createContribution.id, + email: 'bibi@bloxberg.de', + amount: 10.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('An admin is not allowed to update a user contribution.')], + }), + ) + }) + }) + + describe('update too much so that the limit is exceeded', () => { + beforeAll(async () => { + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + it('throws an error', async () => { + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: result.data.createContribution.id, + amount: 1019.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + }) + + describe('update creation to a date that is older than 3 months', () => { + it('throws an error', async () => { + const date = new Date() + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: result.data.createContribution.id, + amount: 10.0, + memo: 'Test env contribution', + creationDate: date.setMonth(date.getMonth() - 3).toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + }) + + describe('valid input', () => { + it('updates contribution', async () => { + await expect( + mutate({ + mutation: updateContribution, + variables: { + contributionId: result.data.createContribution.id, + amount: 10.0, + memo: 'Test contribution', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updateContribution: { + id: result.data.createContribution.id, + amount: '10', + memo: 'Test contribution', + }, + }, + }), + ) + }) +>>>>>>> master }) }) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index c015496c1..3faffb95d 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -3,7 +3,7 @@ import { Context, getUser } from '@/server/context' import { backendLogger as logger } from '@/server/logger' import { Contribution as dbContribution } from '@entity/Contribution' import { User as dbUser } from '@entity/User' -import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql' +import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { FindOperator, IsNull, Not } from '@dbTools/typeorm' import ContributionArgs from '@arg/ContributionArgs' import Paginated from '@arg/Paginated' @@ -11,7 +11,7 @@ import { Order } from '@enum/Order' import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { User } from '@model/User' -import { validateContribution, getUserCreation } from './util/creations' +import { validateContribution, getUserCreation, updateCreations } from './util/creations' @Resolver() export class ContributionResolver { @@ -97,4 +97,40 @@ export class ContributionResolver { }) return new ContributionListResult(contributions.length, contributions) } + + @Authorized([RIGHTS.UPDATE_CONTRIBUTION]) + @Mutation(() => UnconfirmedContribution) + async updateContribution( + @Arg('contributionId', () => Int) + contributionId: number, + @Args() { amount, memo, creationDate }: ContributionArgs, + @Ctx() context: Context, + ): Promise { + const user = getUser(context) + + const contributionToUpdate = await dbContribution.findOne({ + where: { id: contributionId, confirmedAt: IsNull() }, + }) + if (!contributionToUpdate) { + throw new Error('No contribution found to given id.') + } + if (contributionToUpdate.userId !== user.id) { + throw new Error('user of the pending contribution and send user does not correspond') + } + + const creationDateObj = new Date(creationDate) + let creations = await getUserCreation(user.id) + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { + creations = updateCreations(creations, contributionToUpdate) + } + + // all possible cases not to be true are thrown in this function + validateContribution(creations, amount, creationDateObj) + contributionToUpdate.amount = amount + contributionToUpdate.memo = memo + contributionToUpdate.contributionDate = new Date(creationDate) + dbContribution.save(contributionToUpdate) + + return new UnconfirmedContribution(contributionToUpdate, user, creations) + } } diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index dcdce2bfa..ad15ebec6 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -1,6 +1,7 @@ import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' import { backendLogger as logger } from '@/server/logger' import { getConnection } from '@dbTools/typeorm' +import { Contribution } from '@entity/Contribution' import Decimal from 'decimal.js-light' import { FULL_CREATION_AVAILABLE, MAX_CREATION_AMOUNT } from '../const/const' @@ -117,3 +118,13 @@ export const isStartEndDateValid = ( throw new Error(`The value of validFrom must before or equals the validTo!`) } } + +export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { + const index = getCreationIndex(contribution.contributionDate.getMonth()) + + if (index < 0) { + throw new Error('You cannot create GDD for a month older than the last three months.') + } + creations[index] = creations[index].plus(contribution.amount.toString()) + return creations +} diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index 185485f2c..4e7fa8a90 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -240,3 +240,18 @@ export const createContribution = gql` } } ` + +export const updateContribution = gql` + mutation ($contributionId: Int!, $amount: Decimal!, $memo: String!, $creationDate: String!) { + updateContribution( + contributionId: $contributionId + amount: $amount + memo: $memo + creationDate: $creationDate + ) { + id + amount + memo + } + } +` diff --git a/frontend/src/components/SessionLogoutTimeout.spec.js b/frontend/src/components/SessionLogoutTimeout.spec.js index 0f5d21d36..bd6911d13 100644 --- a/frontend/src/components/SessionLogoutTimeout.spec.js +++ b/frontend/src/components/SessionLogoutTimeout.spec.js @@ -62,12 +62,16 @@ describe('SessionLogoutTimeout', () => { }) }) - describe('token is expired', () => { + describe('token is expired for several seconds', () => { beforeEach(() => { mocks.$store.state.tokenTime = setTokenTime(-60) wrapper = Wrapper() }) + it('has value for remaining seconds equal 0', () => { + expect(wrapper.tokenExpiresInSeconds === 0) + }) + it('emits logout', () => { expect(wrapper.emitted('logout')).toBeTruthy() }) diff --git a/frontend/src/components/SessionLogoutTimeout.vue b/frontend/src/components/SessionLogoutTimeout.vue index 1e5a27998..1ebff752a 100644 --- a/frontend/src/components/SessionLogoutTimeout.vue +++ b/frontend/src/components/SessionLogoutTimeout.vue @@ -65,7 +65,7 @@ export default { this.$timer.restart('tokenExpires') this.$bvModal.show('modalSessionTimeOut') } - if (this.tokenExpiresInSeconds <= 0) { + if (this.tokenExpiresInSeconds === 0) { this.$timer.stop('tokenExpires') this.$emit('logout') } @@ -90,7 +90,10 @@ export default { }, computed: { tokenExpiresInSeconds() { - return Math.floor((new Date(this.$store.state.tokenTime * 1000).getTime() - this.now) / 1000) + const remainingSecs = Math.floor( + (new Date(this.$store.state.tokenTime * 1000).getTime() - this.now) / 1000, + ) + return remainingSecs <= 0 ? 0 : remainingSecs }, }, beforeDestroy() {