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 */