Merge branch 'master' into separate-admin-interface-workflow

This commit is contained in:
mahula 2023-02-23 12:10:33 +01:00 committed by GitHub
commit cbbac1afdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1005 additions and 744 deletions

View File

@ -1,9 +1,10 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import CONFIG from '@/config/' import CONFIG from '@/config/'
import { CustomJwtPayload } from './CustomJwtPayload' import { CustomJwtPayload } from './CustomJwtPayload'
import LogError from '@/server/LogError'
export const decode = (token: string): CustomJwtPayload | null => { export const decode = (token: string): CustomJwtPayload | null => {
if (!token) throw new Error('401 Unauthorized') if (!token) throw new LogError('401 Unauthorized')
try { try {
return <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET) return <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
} catch (err) { } catch (err) {

View File

@ -7,6 +7,7 @@ import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '@/auth/ROLES'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS' import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS'
import { User } from '@entity/User' import { User } from '@entity/User'
import LogError from '@/server/LogError'
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => { const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
context.role = ROLE_UNAUTHORIZED // unauthorized user context.role = ROLE_UNAUTHORIZED // unauthorized user
@ -17,13 +18,13 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
// Do we have a token? // Do we have a token?
if (!context.token) { if (!context.token) {
throw new Error('401 Unauthorized') throw new LogError('401 Unauthorized')
} }
// Decode the token // Decode the token
const decoded = decode(context.token) const decoded = decode(context.token)
if (!decoded) { if (!decoded) {
throw new Error('403.13 - Client certificate revoked') throw new LogError('403.13 - Client certificate revoked')
} }
// Set context gradidoID // Set context gradidoID
context.gradidoID = decoded.gradidoID context.gradidoID = decoded.gradidoID
@ -39,13 +40,13 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER context.role = user.isAdmin ? ROLE_ADMIN : ROLE_USER
} catch { } catch {
// in case the database query fails (user deleted) // in case the database query fails (user deleted)
throw new Error('401 Unauthorized') throw new LogError('401 Unauthorized')
} }
// check for correct rights // check for correct rights
const missingRights = (<RIGHTS[]>rights).filter((right) => !context.role.hasRight(right)) const missingRights = (<RIGHTS[]>rights).filter((right) => !context.role.hasRight(right))
if (missingRights.length !== 0) { if (missingRights.length !== 0) {
throw new Error('401 Unauthorized') throw new LogError('401 Unauthorized')
} }
// set new header token // set new header token

View File

@ -257,17 +257,13 @@ describe('Contribution Links', () => {
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [new GraphQLError('A Start-Date must be set')],
new GraphQLError('Start-Date is not initialized. A Start-Date must be set!'),
],
}), }),
) )
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith('A Start-Date must be set')
'Start-Date is not initialized. A Start-Date must be set!',
)
}) })
it('returns an error if missing endDate', async () => { it('returns an error if missing endDate', async () => {
@ -282,15 +278,13 @@ describe('Contribution Links', () => {
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('End-Date is not initialized. An End-Date must be set!')], errors: [new GraphQLError('An End-Date must be set')],
}), }),
) )
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith('An End-Date must be set')
'End-Date is not initialized. An End-Date must be set!',
)
}) })
it('returns an error if endDate is before startDate', async () => { it('returns an error if endDate is before startDate', async () => {
@ -307,7 +301,7 @@ describe('Contribution Links', () => {
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError(`The value of validFrom must before or equals the validTo!`), new GraphQLError(`The value of validFrom must before or equals the validTo`),
], ],
}), }),
) )
@ -315,7 +309,7 @@ describe('Contribution Links', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
`The value of validFrom must before or equals the validTo!`, `The value of validFrom must before or equals the validTo`,
) )
}) })

View File

@ -33,10 +33,14 @@ export class ContributionMessageResolver {
try { try {
const contribution = await DbContribution.findOne({ id: contributionId }) const contribution = await DbContribution.findOne({ id: contributionId })
if (!contribution) { if (!contribution) {
throw new Error('Contribution not found') throw new LogError('Contribution not found', contributionId)
} }
if (contribution.userId !== user.id) { if (contribution.userId !== user.id) {
throw new Error('Can not send message to contribution of another user') throw new LogError(
'Can not send message to contribution of another user',
contribution.userId,
user.id,
)
} }
contributionMessage.contributionId = contributionId contributionMessage.contributionId = contributionId

View File

@ -23,7 +23,7 @@ import {
import { import {
listAllContributions, listAllContributions,
listContributions, listContributions,
listUnconfirmedContributions, adminListAllContributions,
} from '@/seeds/graphql/queries' } from '@/seeds/graphql/queries'
import { sendContributionConfirmedEmail } from '@/emails/sendEmailVariants' import { sendContributionConfirmedEmail } from '@/emails/sendEmailVariants'
import { import {
@ -46,9 +46,10 @@ import { EventProtocolType } from '@/event/EventProtocolType'
import { logger, i18n as localization } from '@test/testSetup' import { logger, i18n as localization } from '@test/testSetup'
import { UserInputError } from 'apollo-server-express' import { UserInputError } from 'apollo-server-express'
import { raeuberHotzenplotz } from '@/seeds/users/raeuber-hotzenplotz' import { raeuberHotzenplotz } from '@/seeds/users/raeuber-hotzenplotz'
import { UnconfirmedContribution } from '../model/UnconfirmedContribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { ContributionListResult } from '../model/Contribution' import { ContributionListResult } from '@model/Contribution'
import { ContributionStatus } from '../enum/ContributionStatus' import { ContributionStatus } from '@enum/ContributionStatus'
import { Order } from '@enum/Order'
// mock account activation email to avoid console spam // mock account activation email to avoid console spam
jest.mock('@/emails/sendEmailVariants', () => { jest.mock('@/emails/sendEmailVariants', () => {
@ -245,8 +246,8 @@ describe('ContributionResolver', () => {
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations for the given date',
'Invalid Date', expect.any(Date),
) )
}) })
@ -268,8 +269,8 @@ describe('ContributionResolver', () => {
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations for the given date',
'Invalid Date', expect.any(Date),
) )
}) })
}) })
@ -526,14 +527,16 @@ describe('ContributionResolver', () => {
}) })
expect(errorObjects).toEqual([ expect(errorObjects).toEqual([
new GraphQLError( new GraphQLError(
'The amount (1019 GDD) to be created exceeds the amount (600 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
), ),
]) ])
}) })
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'The amount (1019 GDD) to be created exceeds the amount (600 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
new Decimal(1019),
new Decimal(600),
) )
}) })
}) })
@ -875,6 +878,7 @@ describe('ContributionResolver', () => {
describe('other user sends a deleteContribution', () => { describe('other user sends a deleteContribution', () => {
beforeAll(async () => { beforeAll(async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
@ -886,7 +890,6 @@ describe('ContributionResolver', () => {
}) })
it('returns an error', async () => { it('returns an error', async () => {
jest.clearAllMocks()
const { errors: errorObjects }: { errors: [GraphQLError] } = await mutate({ const { errors: errorObjects }: { errors: [GraphQLError] } = await mutate({
mutation: deleteContribution, mutation: deleteContribution,
variables: { variables: {
@ -909,6 +912,7 @@ describe('ContributionResolver', () => {
describe('User deletes own contribution', () => { describe('User deletes own contribution', () => {
beforeAll(async () => { beforeAll(async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
@ -1687,20 +1691,6 @@ describe('ContributionResolver', () => {
}) })
}) })
describe('listUnconfirmedContributions', () => {
it('returns an error', async () => {
await expect(
query({
query: listUnconfirmedContributions,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('adminDeleteContribution', () => { describe('adminDeleteContribution', () => {
it('returns an error', async () => { it('returns an error', async () => {
await expect( await expect(
@ -1795,20 +1785,6 @@ describe('ContributionResolver', () => {
}) })
}) })
describe('listUnconfirmedContributions', () => {
it('returns an error', async () => {
await expect(
query({
query: listUnconfirmedContributions,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('adminDeleteContribution', () => { describe('adminDeleteContribution', () => {
it('returns an error', async () => { it('returns an error', async () => {
await expect( await expect(
@ -2008,8 +1984,8 @@ describe('ContributionResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations for the given date',
new Date(variables.creationDate).toString(), new Date(variables.creationDate),
) )
}) })
}) })
@ -2033,8 +2009,8 @@ describe('ContributionResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations for the given date',
new Date(variables.creationDate).toString(), new Date(variables.creationDate),
) )
}) })
}) })
@ -2049,7 +2025,7 @@ describe('ContributionResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'The amount (2000 GDD) to be created exceeds the amount (790 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
), ),
], ],
}), }),
@ -2058,7 +2034,9 @@ describe('ContributionResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'The amount (2000 GDD) to be created exceeds the amount (790 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
new Decimal(2000),
new Decimal(790),
) )
}) })
}) })
@ -2098,7 +2076,7 @@ describe('ContributionResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'The amount (1000 GDD) to be created exceeds the amount (590 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
), ),
], ],
}), }),
@ -2107,7 +2085,9 @@ describe('ContributionResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'The amount (1000 GDD) to be created exceeds the amount (590 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
new Decimal(1000),
new Decimal(590),
) )
}) })
}) })
@ -2300,7 +2280,7 @@ describe('ContributionResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
), ),
], ],
}), }),
@ -2309,7 +2289,9 @@ describe('ContributionResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', 'The amount to be created exceeds the amount still available for this month',
new Decimal(1900),
new Decimal(1000),
) )
}) })
}) })
@ -2397,100 +2379,6 @@ describe('ContributionResolver', () => {
}) })
}) })
describe('listUnconfirmedContributions', () => {
it('returns four pending creations', async () => {
await expect(
query({
query: listUnconfirmedContributions,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listUnconfirmedContributions: expect.arrayContaining([
expect.objectContaining({
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'],
}),
expect.objectContaining({
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'],
}),
expect.not.objectContaining({
id: expect.any(Number),
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
date: expect.any(String),
memo: 'Test contribution to delete',
amount: '100',
moderator: null,
creation: ['1000', '1000', '90'],
}),
expect.objectContaining({
id: expect.any(Number),
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
date: expect.any(String),
memo: 'Test PENDING contribution update',
amount: '10',
moderator: null,
creation: ['1000', '1000', '90'],
}),
expect.objectContaining({
id: expect.any(Number),
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
date: expect.any(String),
memo: 'Test IN_PROGRESS contribution',
amount: '100',
moderator: null,
creation: ['1000', '1000', '90'],
}),
expect.objectContaining({
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', '90'],
}),
expect.objectContaining({
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', '90'],
}),
]),
},
}),
)
})
})
describe('adminDeleteContribution', () => { describe('adminDeleteContribution', () => {
describe('creation id does not exist', () => { describe('creation id does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
@ -2831,4 +2719,320 @@ describe('ContributionResolver', () => {
}) })
}) })
}) })
describe('adminListAllContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: adminListAllContributions,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated as user', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
resetToken()
})
it('returns an error', async () => {
await expect(
query({
query: adminListAllContributions,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated as admin', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(() => {
resetToken()
})
it('returns 19 creations in total', async () => {
const {
data: { adminListAllContributions: contributionListObject },
}: { data: { adminListAllContributions: ContributionListResult } } = await query({
query: adminListAllContributions,
})
expect(contributionListObject.contributionList).toHaveLength(19)
expect(contributionListObject).toMatchObject({
contributionCount: 19,
contributionList: expect.arrayContaining([
expect.objectContaining({
amount: expect.decimalEqual(50),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(50),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(450),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Bob',
id: expect.any(Number),
lastName: 'der Baumeister',
memo: 'Confirmed Contribution',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(400),
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Herzlich Willkommen bei Gradido!',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Test env contribution',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(200),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Aktives Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(500),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(500),
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(10),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Test PENDING contribution update',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: expect.decimalEqual(200),
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Das war leider zu Viel!',
messagesCount: 0,
state: 'DELETED',
}),
expect.objectContaining({
amount: expect.decimalEqual(166),
firstName: 'Räuber',
id: expect.any(Number),
lastName: 'Hotzenplotz',
memo: 'Whatever contribution',
messagesCount: 0,
state: 'DELETED',
}),
expect.objectContaining({
amount: expect.decimalEqual(166),
firstName: 'Räuber',
id: expect.any(Number),
lastName: 'Hotzenplotz',
memo: 'Whatever contribution',
messagesCount: 0,
state: 'DENIED',
}),
expect.objectContaining({
amount: expect.decimalEqual(166),
firstName: 'Räuber',
id: expect.any(Number),
lastName: 'Hotzenplotz',
memo: 'Whatever contribution',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Test IN_PROGRESS contribution',
messagesCount: 0,
state: 'IN_PROGRESS',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Test contribution to confirm',
messagesCount: 0,
state: 'CONFIRMED',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Test contribution to deny',
messagesCount: 0,
state: 'DENIED',
}),
expect.objectContaining({
amount: expect.decimalEqual(100),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Test contribution to delete',
messagesCount: 0,
state: 'DELETED',
}),
expect.objectContaining({
amount: expect.decimalEqual(1000),
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Herzlich Willkommen bei Gradido!',
messagesCount: 0,
state: 'CONFIRMED',
}),
]),
})
})
it('returns five pending creations with page size set to 5', async () => {
const {
data: { adminListAllContributions: contributionListObject },
}: { data: { adminListAllContributions: ContributionListResult } } = await query({
query: adminListAllContributions,
variables: {
currentPage: 1,
pageSize: 5,
order: Order.DESC,
statusFilter: ['PENDING'],
},
})
expect(contributionListObject.contributionList).toHaveLength(5)
expect(contributionListObject).toMatchObject({
contributionCount: 6,
contributionList: expect.arrayContaining([
expect.objectContaining({
amount: '400',
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Herzlich Willkommen bei Gradido!',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: '200',
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Aktives Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: '500',
firstName: 'Bibi',
id: expect.any(Number),
lastName: 'Bloxberg',
memo: 'Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: '500',
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Grundeinkommen',
messagesCount: 0,
state: 'PENDING',
}),
expect.objectContaining({
amount: '100',
firstName: 'Peter',
id: expect.any(Number),
lastName: 'Lustig',
memo: 'Test env contribution',
messagesCount: 0,
state: 'PENDING',
}),
expect.not.objectContaining({
state: 'DENIED',
}),
expect.not.objectContaining({
state: 'DELETED',
}),
expect.not.objectContaining({
state: 'CONFIRMED',
}),
expect.not.objectContaining({
state: 'IN_PROGRESS',
}),
]),
})
})
})
})
}) })

View File

@ -1,6 +1,6 @@
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { FindOperator, IsNull, In, getConnection } from '@dbTools/typeorm' import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
import { Contribution as DbContribution } from '@entity/Contribution' import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionMessage } from '@entity/ContributionMessage' import { ContributionMessage } from '@entity/ContributionMessage'
@ -30,12 +30,11 @@ import { backendLogger as logger } from '@/server/logger'
import { import {
getCreationDates, getCreationDates,
getUserCreation, getUserCreation,
getUserCreations,
validateContribution, validateContribution,
updateCreations, updateCreations,
isValidDateString, isValidDateString,
} from './util/creations' } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS, FULL_CREATION_AVAILABLE } from './const/const' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { import {
EVENT_CONTRIBUTION_CREATE, EVENT_CONTRIBUTION_CREATE,
EVENT_CONTRIBUTION_DELETE, EVENT_CONTRIBUTION_DELETE,
@ -56,6 +55,7 @@ import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import LogError from '@/server/LogError' import LogError from '@/server/LogError'
import { getLastTransaction } from './util/getLastTransaction' import { getLastTransaction } from './util/getLastTransaction'
import { findContributions } from './util/findContributions'
@Resolver() @Resolver()
export class ContributionResolver { export class ContributionResolver {
@ -168,25 +168,14 @@ export class ContributionResolver {
@Arg('statusFilter', () => [ContributionStatus], { nullable: true }) @Arg('statusFilter', () => [ContributionStatus], { nullable: true })
statusFilter?: ContributionStatus[], statusFilter?: ContributionStatus[],
): Promise<ContributionListResult> { ): Promise<ContributionListResult> {
const where: { const [dbContributions, count] = await findContributions(
contributionStatus?: FindOperator<string> | null order,
} = {} currentPage,
pageSize,
false,
statusFilter,
)
if (statusFilter && statusFilter.length) {
where.contributionStatus = In(statusFilter)
}
const [dbContributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.innerJoinAndSelect('c.user', 'u')
.leftJoinAndSelect('c.messages', 'm')
.where(where)
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
.getManyAndCount()
return new ContributionListResult( return new ContributionListResult(
count, count,
dbContributions.map((contribution) => new Contribution(contribution, contribution.user)), dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
@ -425,40 +414,25 @@ export class ContributionResolver {
} }
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution]) @Query(() => ContributionListResult) // [UnconfirmedContribution]
async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> { async adminListAllContributions(
const clientTimezoneOffset = getClientTimezoneOffset(context) @Args()
const contributions = await getConnection() { currentPage = 1, pageSize = 3, order = Order.DESC }: Paginated,
.createQueryBuilder() @Arg('statusFilter', () => [ContributionStatus], { nullable: true })
.select('c') statusFilter?: ContributionStatus[],
.from(DbContribution, 'c') ): Promise<ContributionListResult> {
.leftJoinAndSelect('c.messages', 'm') const [dbContributions, count] = await findContributions(
.where({ confirmedAt: IsNull() }) order,
.andWhere({ deniedAt: IsNull() }) currentPage,
.getMany() pageSize,
true,
statusFilter,
)
if (contributions.length === 0) { return new ContributionListResult(
return [] count,
} dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
)
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]) @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])

View File

@ -8,6 +8,7 @@ import { Context, getUser } from '@/server/context'
import CONFIG from '@/config' import CONFIG from '@/config'
import { apiGet, apiPost } from '@/apis/HttpRequest' import { apiGet, apiPost } from '@/apis/HttpRequest'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import LogError from '@/server/LogError'
@Resolver() @Resolver()
export class GdtResolver { export class GdtResolver {
@ -25,11 +26,11 @@ export class GdtResolver {
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.emailContact.email}/${currentPage}/${pageSize}/${order}`, `${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.emailContact.email}/${currentPage}/${pageSize}/${order}`,
) )
if (!resultGDT.success) { if (!resultGDT.success) {
throw new Error(resultGDT.data) throw new LogError(resultGDT.data)
} }
return new GdtEntryList(resultGDT.data) return new GdtEntryList(resultGDT.data)
} catch (err) { } catch (err) {
throw new Error('GDT Server is not reachable.') throw new LogError('GDT Server is not reachable')
} }
} }
@ -42,7 +43,7 @@ export class GdtResolver {
email: user.emailContact.email, email: user.emailContact.email,
}) })
if (!resultGDTSum.success) { if (!resultGDTSum.success) {
throw new Error('Call not successful') throw new LogError('Call not successful')
} }
return Number(resultGDTSum.data.sum) || 0 return Number(resultGDTSum.data.sum) || 0
} catch (err) { } catch (err) {
@ -59,7 +60,7 @@ export class GdtResolver {
// load user // load user
const resultPID = await apiGet(`${CONFIG.GDT_API_URL}/publishers/checkPidApi/${pid}`) const resultPID = await apiGet(`${CONFIG.GDT_API_URL}/publishers/checkPidApi/${pid}`)
if (!resultPID.success) { if (!resultPID.success) {
throw new Error(resultPID.data) throw new LogError(resultPID.data)
} }
return resultPID.data.pid return resultPID.data.pid
} }

View File

@ -53,65 +53,81 @@ afterAll(async () => {
describe('TransactionLinkResolver', () => { describe('TransactionLinkResolver', () => {
describe('createTransactionLink', () => { describe('createTransactionLink', () => {
beforeAll(async () => { describe('unauthenticated', () => {
await mutate({ it('throws an error', async () => {
mutation: login, jest.clearAllMocks()
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, resetToken()
await expect(
mutate({ mutation: createTransactionLink, variables: { amount: 0, memo: 'Test' } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
}) })
}) })
it('throws error when amount is zero', async () => { describe('authenticated', () => {
jest.clearAllMocks() beforeAll(async () => {
await expect( await mutate({
mutate({ mutation: login,
mutation: createTransactionLink, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
variables: { })
amount: 0,
memo: 'Test',
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Amount must be a positive number')],
}) })
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Amount must be a positive number', new Decimal(0))
})
it('throws error when amount is negative', async () => { it('throws error when amount is zero', async () => {
jest.clearAllMocks() jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: createTransactionLink, mutation: createTransactionLink,
variables: { variables: {
amount: -10, amount: 0,
memo: 'Test', memo: 'Test',
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
errors: [new GraphQLError('Amount must be a positive number')], errors: [new GraphQLError('Amount must be a positive number')],
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Amount must be a positive number', new Decimal(0))
}) })
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Amount must be a positive number', new Decimal(-10))
})
it('throws error when user has not enough GDD', async () => { it('throws error when amount is negative', async () => {
jest.clearAllMocks() jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: createTransactionLink, mutation: createTransactionLink,
variables: { variables: {
amount: 1001, amount: -10,
memo: 'Test', memo: 'Test',
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
errors: [new GraphQLError('User has not enough GDD')], errors: [new GraphQLError('Amount must be a positive number')],
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Amount must be a positive number', new Decimal(-10))
})
it('throws error when user has not enough GDD', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createTransactionLink,
variables: {
amount: 1001,
memo: 'Test',
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('User has not enough GDD')],
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User has not enough GDD', expect.any(Number))
}) })
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User has not enough GDD', expect.any(Number))
}) })
}) })
@ -121,236 +137,37 @@ describe('TransactionLinkResolver', () => {
resetToken() resetToken()
}) })
describe('contributionLink', () => { describe('unauthenticated', () => {
describe('input not valid', () => { it('throws an error', async () => {
beforeAll(async () => { jest.clearAllMocks()
await mutate({ resetToken()
mutation: login, await expect(
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, mutate({ mutation: redeemTransactionLink, variables: { code: 'CL-123456' } }),
}) ).resolves.toEqual(
}) expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
it('throws error when link does not exists', async () => { }),
jest.clearAllMocks() )
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-123456',
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No contribution link found to given code',
'CL-123456',
)
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('No contribution link found to given code'),
)
})
const now = new Date()
const validFrom = new Date(now.getFullYear() + 1, 0, 1)
it('throws error when link is not valid yet', async () => {
jest.clearAllMocks()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: validFrom.toISOString(),
validTo: new Date(now.getFullYear() + 1, 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
await resetEntity(DbContributionLink)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link is not valid yet', validFrom)
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link is not valid yet'),
)
})
it('throws error when contributionLink cycle is invalid', async () => {
jest.clearAllMocks()
const now = new Date()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'INVALID',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
await resetEntity(DbContributionLink)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link has unknown cycle', 'INVALID')
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link has unknown cycle'),
)
})
const validTo = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 0)
it('throws error when link is no longer valid', async () => {
jest.clearAllMocks()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear() - 1, 0, 1).toISOString(),
validTo: validTo.toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
await resetEntity(DbContributionLink)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link is no longer valid', validTo)
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link is no longer valid'),
)
})
}) })
})
// TODO: have this test separated into a transactionLink and a contributionLink part describe('authenticated', () => {
describe('redeem daily Contribution Link', () => { describe('contributionLink', () => {
const now = new Date() describe('input not valid', () => {
let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
})
it('has a daily contribution link in the database', async () => {
const cls = await DbContributionLink.find()
expect(cls).toHaveLength(1)
contributionLink = cls[0]
expect(contributionLink).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
validFrom: new Date(now.getFullYear(), 0, 1),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
cycle: 'DAILY',
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(5),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => { beforeAll(async () => {
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
}) })
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
}) })
it('does not allow the user to redeem the contribution link', async () => { it('throws error when link does not exists', async () => {
jest.clearAllMocks() jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: redeemTransactionLink, mutation: redeemTransactionLink,
variables: { variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''), code: 'CL-123456',
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
@ -359,85 +176,247 @@ describe('TransactionLinkResolver', () => {
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No contribution link found to given code',
'CL-123456',
)
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful', 'Creation from contribution link was not successful',
new Error( new Error('No contribution link found to given code'),
'The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
),
) )
}) })
})
describe('user has no pending contributions that would not allow to redeem the link', () => { const now = new Date()
beforeAll(async () => { const validFrom = new Date(now.getFullYear() + 1, 0, 1)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
})
it('allows the user to redeem the contribution link', async () => { it('throws error when link is not valid yet', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: validFrom.toISOString(),
validTo: new Date(now.getFullYear() + 1, 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect( await expect(
mutate({ mutate({
mutation: redeemTransactionLink, mutation: redeemTransactionLink,
variables: { variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''), code: 'CL-' + contributionLink.code,
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')], errors: [new GraphQLError('Creation from contribution link was not successful')],
}) })
await resetEntity(DbContributionLink)
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link is not valid yet', validFrom)
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful', 'Creation from contribution link was not successful',
new Error('Contribution link already redeemed today'), new Error('Contribution link is not valid yet'),
) )
}) })
describe('after one day', () => { it('throws error when contributionLink cycle is invalid', async () => {
jest.clearAllMocks()
const now = new Date()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'INVALID',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
await resetEntity(DbContributionLink)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link has unknown cycle', 'INVALID')
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link has unknown cycle'),
)
})
const validTo = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 0)
it('throws error when link is no longer valid', async () => {
jest.clearAllMocks()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear() - 1, 0, 1).toISOString(),
validTo: validTo.toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
await resetEntity(DbContributionLink)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution link is no longer valid', validTo)
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link is no longer valid'),
)
})
})
// TODO: have this test separated into a transactionLink and a contributionLink part
describe('redeem daily Contribution Link', () => {
const now = new Date()
let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
})
it('has a daily contribution link in the database', async () => {
const cls = await DbContributionLink.find()
expect(cls).toHaveLength(1)
contributionLink = cls[0]
expect(contributionLink).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
validFrom: new Date(now.getFullYear(), 0, 1),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
cycle: 'DAILY',
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(5),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers()
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
}) })
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
}) })
afterAll(() => { it('does not allow the user to redeem the contribution link', async () => {
jest.useRealTimers() jest.clearAllMocks()
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
}) })
it('allows the user to redeem the contribution link again', async () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error(
'The amount to be created exceeds the amount still available for this month',
),
)
})
})
describe('user has no pending contributions that would not allow to redeem the link', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
})
it('allows the user to redeem the contribution link', async () => {
await expect( await expect(
mutate({ mutate({
mutation: redeemTransactionLink, mutation: redeemTransactionLink,
@ -473,6 +452,59 @@ describe('TransactionLinkResolver', () => {
new Error('Contribution link already redeemed today'), new Error('Contribution link already redeemed today'),
) )
}) })
describe('after one day', () => {
beforeAll(async () => {
jest.useFakeTimers()
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [new GraphQLError('Creation from contribution link was not successful')],
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Creation from contribution link was not successful',
new Error('Contribution link already redeemed today'),
)
})
})
}) })
}) })
}) })

View File

@ -86,8 +86,8 @@ export class TransactionLinkResolver {
transactionLink.code = transactionLinkCode(createdDate) transactionLink.code = transactionLinkCode(createdDate)
transactionLink.createdAt = createdDate transactionLink.createdAt = createdDate
transactionLink.validUntil = validUntil transactionLink.validUntil = validUntil
await DbTransactionLink.save(transactionLink).catch(() => { await DbTransactionLink.save(transactionLink).catch((e) => {
throw new Error('Unable to save transaction link') throw new LogError('Unable to save transaction link', e)
}) })
return new TransactionLink(transactionLink, new User(user)) return new TransactionLink(transactionLink, new User(user))
@ -103,19 +103,23 @@ export class TransactionLinkResolver {
const transactionLink = await DbTransactionLink.findOne({ id }) const transactionLink = await DbTransactionLink.findOne({ id })
if (!transactionLink) { if (!transactionLink) {
throw new Error('Transaction Link not found!') throw new LogError('Transaction link not found', id)
} }
if (transactionLink.userId !== user.id) { if (transactionLink.userId !== user.id) {
throw new Error('Transaction Link cannot be deleted!') throw new LogError(
'Transaction link cannot be deleted by another user',
transactionLink.userId,
user.id,
)
} }
if (transactionLink.redeemedBy) { if (transactionLink.redeemedBy) {
throw new Error('Transaction Link already redeemed!') throw new LogError('Transaction link already redeemed', transactionLink.redeemedBy)
} }
await transactionLink.softRemove().catch(() => { await transactionLink.softRemove().catch((e) => {
throw new Error('Transaction Link could not be deleted!') throw new LogError('Transaction link could not be deleted', e)
}) })
return true return true
@ -312,18 +316,18 @@ export class TransactionLinkResolver {
) )
if (user.id === linkedUser.id) { if (user.id === linkedUser.id) {
throw new Error('Cannot redeem own transaction link.') throw new LogError('Cannot redeem own transaction link', user.id)
} }
// TODO: The now check should be done within the semaphore lock, // TODO: The now check should be done within the semaphore lock,
// since the program might wait a while till it is ready to proceed // since the program might wait a while till it is ready to proceed
// writing the transaction. // writing the transaction.
if (transactionLink.validUntil.getTime() < now.getTime()) { if (transactionLink.validUntil.getTime() < now.getTime()) {
throw new Error('Transaction Link is not valid anymore.') throw new LogError('Transaction link is not valid anymore', transactionLink.validUntil)
} }
if (transactionLink.redeemedBy) { if (transactionLink.redeemedBy) {
throw new Error('Transaction Link already redeemed.') throw new LogError('Transaction link already redeemed', transactionLink.redeemedBy)
} }
await executeTransaction( await executeTransaction(

View File

@ -1,3 +1,4 @@
import LogError from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution' import { Contribution } from '@entity/Contribution'
@ -19,19 +20,14 @@ export const validateContribution = (
const index = getCreationIndex(creationDate.getMonth(), timezoneOffset) const index = getCreationIndex(creationDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
logger.error( throw new LogError('No information for available creations for the given date', creationDate)
'No information for available creations with the given creationDate=',
creationDate.toString(),
)
throw new Error('No information for available creations for the given date')
} }
if (amount.greaterThan(creations[index].toString())) { if (amount.greaterThan(creations[index].toString())) {
logger.error( throw new LogError(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, 'The amount to be created exceeds the amount still available for this month',
) amount,
throw new Error( creations[index],
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
) )
} }
} }
@ -126,19 +122,16 @@ export const isStartEndDateValid = (
endDate: string | null | undefined, endDate: string | null | undefined,
): void => { ): void => {
if (!startDate) { if (!startDate) {
logger.error('Start-Date is not initialized. A Start-Date must be set!') throw new LogError('A Start-Date must be set')
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
} }
if (!endDate) { if (!endDate) {
logger.error('End-Date is not initialized. An End-Date must be set!') throw new LogError('An End-Date must be set')
throw new Error('End-Date is not initialized. An End-Date must be set!')
} }
// check if endDate is before startDate // check if endDate is before startDate
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) { if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
logger.error(`The value of validFrom must before or equals the validTo!`) throw new LogError(`The value of validFrom must before or equals the validTo`)
throw new Error(`The value of validFrom must before or equals the validTo!`)
} }
} }
@ -150,7 +143,7 @@ export const updateCreations = (
const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset) const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.') throw new LogError('You cannot create GDD for a month older than the last three months')
} }
creations[index] = creations[index].plus(contribution.amount.toString()) creations[index] = creations[index].plus(contribution.amount.toString())
return creations return creations

View File

@ -0,0 +1,24 @@
import { ContributionStatus } from '@enum/ContributionStatus'
import { Order } from '@enum/Order'
import { Contribution as DbContribution } from '@entity/Contribution'
import { In } from '@dbTools/typeorm'
export const findContributions = async (
order: Order,
currentPage: number,
pageSize: number,
withDeleted: boolean,
statusFilter?: ContributionStatus[],
): Promise<[DbContribution[], number]> =>
DbContribution.findAndCount({
where: {
...(statusFilter && statusFilter.length && { contributionStatus: In(statusFilter) }),
},
withDeleted: withDeleted,
order: {
createdAt: order,
},
relations: ['user'],
skip: (currentPage - 1) * pageSize,
take: pageSize,
})

View File

@ -1,4 +1,5 @@
import CONFIG from '@/config' import CONFIG from '@/config'
import LogError from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { User } from '@entity/User' import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType' import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
@ -16,11 +17,10 @@ export const SecretKeyCryptographyCreateKey = (salt: string, password: string):
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex') const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex') const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) { if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error( throw new LogError(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, 'ServerKey has an invalid size',
) configLoginServerKey.length,
throw new Error( sodium.crypto_shorthash_KEYBYTES,
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
) )
} }
@ -52,20 +52,13 @@ export const SecretKeyCryptographyCreateKey = (salt: string, password: string):
export const getUserCryptographicSalt = (dbUser: User): string => { export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) { switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD: { case PasswordEncryptionType.NO_PASSWORD:
logger.error('Password not set for user ' + dbUser.id) throw new LogError('User has no password set', dbUser.id)
throw new Error('Password not set for user ' + dbUser.id) // user has no password case PasswordEncryptionType.EMAIL:
}
case PasswordEncryptionType.EMAIL: {
return dbUser.emailContact.email return dbUser.emailContact.email
break case PasswordEncryptionType.GRADIDO_ID:
}
case PasswordEncryptionType.GRADIDO_ID: {
return dbUser.gradidoID return dbUser.gradidoID
break
}
default: default:
logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`) throw new LogError('Unknown password encryption type', dbUser.passwordEncryptionType)
throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
} }
} }

View File

@ -186,6 +186,40 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusF
contributionCount contributionCount
contributionList { contributionList {
id id
firstName
lastName
amount
memo
createdAt
confirmedAt
confirmedBy
contributionDate
state
messagesCount
deniedAt
deniedBy
}
}
}
`
// from admin interface
export const adminListAllContributions = gql`
query (
$currentPage: Int = 1
$pageSize: Int = 25
$order: Order = DESC
$statusFilter: [ContributionStatus!]
) {
adminListAllContributions(
currentPage: $currentPage
pageSize: $pageSize
order: $order
statusFilter: $statusFilter
) {
contributionCount
contributionList {
id
firstName firstName
lastName lastName
amount amount
@ -198,24 +232,7 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusF
messagesCount messagesCount
deniedAt deniedAt
deniedBy deniedBy
} }
}
}
`
// from admin interface
export const listUnconfirmedContributions = gql`
query {
listUnconfirmedContributions {
id
firstName
lastName
email
amount
memo
date
moderator
creation
} }
} }
` `

View File

@ -3,6 +3,7 @@ import { User as dbUser } from '@entity/User'
import { Transaction as dbTransaction } from '@entity/Transaction' import { Transaction as dbTransaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { ExpressContext } from 'apollo-server-express' import { ExpressContext } from 'apollo-server-express'
import LogError from './LogError'
export interface Context { export interface Context {
token: string | null token: string | null
@ -35,7 +36,7 @@ const context = (args: ExpressContext): Context => {
export const getUser = (context: Context): dbUser => { export const getUser = (context: Context): dbUser => {
if (context.user) return context.user if (context.user) return context.user
throw new Error('No user given in context!') throw new LogError('No user given in context')
} }
export const getClientTimezoneOffset = (context: Context): number => { export const getClientTimezoneOffset = (context: Context): number => {
@ -45,7 +46,7 @@ export const getClientTimezoneOffset = (context: Context): number => {
) { ) {
return context.clientTimezoneOffset return context.clientTimezoneOffset
} }
throw new Error('No valid client time zone offset in context!') throw new LogError('No valid client time zone offset in context')
} }
export default context export default context

View File

@ -1,6 +1,7 @@
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import CONFIG from '@/config' import CONFIG from '@/config'
import { Decay } from '@model/Decay' import { Decay } from '@model/Decay'
import LogError from '@/server/LogError'
// TODO: externalize all those definitions and functions into an external decay library // TODO: externalize all those definitions and functions into an external decay library
@ -22,7 +23,7 @@ function calculateDecay(
const startBlockMs = startBlock.getTime() const startBlockMs = startBlock.getTime()
if (toMs < fromMs) { if (toMs < fromMs) {
throw new Error('to < from, reverse decay calculation is invalid') throw new LogError('calculateDecay: to < from, reverse decay calculation is invalid')
} }
// Initialize with no decay // Initialize with no decay

View File

@ -1,11 +1,12 @@
import connection from '@/typeorm/connection' import connection from '@/typeorm/connection'
import { getKlickTippUser } from '@/apis/KlicktippController' import { getKlickTippUser } from '@/apis/KlicktippController'
import { User } from '@entity/User' import { User } from '@entity/User'
import LogError from '@/server/LogError'
export async function retrieveNotRegisteredEmails(): Promise<string[]> { export async function retrieveNotRegisteredEmails(): Promise<string[]> {
const con = await connection() const con = await connection()
if (!con) { if (!con) {
throw new Error('No connection to database') throw new LogError('No connection to database')
} }
const users = await User.find({ relations: ['emailContact'] }) const users = await User.find({ relations: ['emailContact'] })
const notRegisteredUser = [] const notRegisteredUser = []

View File

@ -2,19 +2,19 @@
<div class="nav-community container"> <div class="nav-community container">
<b-row class="nav-row"> <b-row class="nav-row">
<b-col cols="12" lg="4" md="4" class="px-0"> <b-col cols="12" lg="4" md="4" class="px-0">
<b-btn active-class="btn-active" block variant="link" to="/community#edit"> <b-btn to="contribute" active-class="btn-active" block variant="link">
<b-icon icon="pencil" class="mr-2" /> <b-icon icon="pencil" class="mr-2" />
{{ $t('community.submitContribution') }} {{ $t('community.submitContribution') }}
</b-btn> </b-btn>
</b-col> </b-col>
<b-col cols="12" lg="4" md="4" class="px-0"> <b-col cols="12" lg="4" md="4" class="px-0">
<b-btn active-class="btn-active" block variant="link" to="/community#my"> <b-btn to="contributions" active-class="btn-active" block variant="link">
<b-icon icon="person" class="mr-2" /> <b-icon icon="person" class="mr-2" />
{{ $t('community.myContributions') }} {{ $t('community.myContributions') }}
</b-btn> </b-btn>
</b-col> </b-col>
<b-col cols="12" lg="4" md="4" class="px-0"> <b-col cols="12" lg="4" md="4" class="px-0">
<b-btn active-class="btn-active" block variant="link" to="/community#all"> <b-btn to="community" active-class="btn-active" block variant="link">
<b-icon icon="people" class="mr-2" /> <b-icon icon="people" class="mr-2" />
{{ $t('community.community') }} {{ $t('community.community') }}
</b-btn> </b-btn>

View File

@ -1,76 +1,10 @@
<template> <template>
<div class="contribution-info d-none d-lg-block"> <div class="contribution-info d-none d-lg-block">
<div v-if="hash === '#my'"> <slot :name="$route.params.tab" />
<h4 class="alert-heading">{{ $t('community.myContributions') }}</h4>
<p>
{{ $t('contribution.alert.myContributionNoteList') }}
</p>
<ul>
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="question-square" variant="warning"></b-icon>
{{ $t('contribution.alert.in_progress') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
<li>
<b-icon icon="x-circle" variant="warning"></b-icon>
{{ $t('contribution.alert.denied') }}
</li>
<li>
<b-icon icon="trash" variant="danger"></b-icon>
{{ $t('contribution.alert.deleted') }}
</li>
</ul>
</div>
<div v-if="hash === '#all'" show fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('navigation.community') }}</h4>
<p>
{{ $t('contribution.alert.communityNoteList') }}
</p>
<ul>
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="question-square" variant="warning"></b-icon>
{{ $t('contribution.alert.in_progress') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
<li>
<b-icon icon="x-circle" variant="warning"></b-icon>
{{ $t('contribution.alert.denied') }}
</li>
</ul>
</div>
<div v-if="hash === '#edit'" show fade variant="secondary" class="text-dark">
<div>
<h3>{{ $t('contribution.formText.yourContribution') }}</h3>
{{ $t('contribution.formText.bringYourTalentsTo') }}
<div class="my-3">
<b>{{ $t('contribution.formText.describeYourCommunity') }}</b>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'ContributionInfo', name: 'ContributionInfo',
computed: {
hash() {
return this.$route.hash
},
},
} }
</script> </script>

View File

@ -121,11 +121,7 @@
</b-col> </b-col>
<!-- Right Side Mobil --> <!-- Right Side Mobil -->
<b-col class="d-block d-lg-none"> <b-col class="d-block d-lg-none">
<right-side <right-side>
:transactions="transactions"
:transactionCount="transactionCount"
:transactionLinkCount="transactionLinkCount"
>
<template #transactions> <template #transactions>
<last-transactions <last-transactions
:transactions="transactions" :transactions="transactions"
@ -135,7 +131,7 @@
/> />
</template> </template>
<template #community> <template #community>
<contribution-info /> <community-template />
</template> </template>
<template #empty /> <template #empty />
</right-side> </right-side>
@ -162,11 +158,7 @@
</b-col> </b-col>
<!-- RightSide Desktop --> <!-- RightSide Desktop -->
<b-col cols="3" class="d-none d-lg-block"> <b-col cols="3" class="d-none d-lg-block">
<right-side <right-side>
:transactions="transactions"
:transactionCount="transactionCount"
:transactionLinkCount="transactionLinkCount"
>
<template #transactions> <template #transactions>
<last-transactions <last-transactions
:transactions="transactions" :transactions="transactions"
@ -175,10 +167,10 @@
@set-tunneled-email="setTunneledEmail" @set-tunneled-email="setTunneledEmail"
/> />
</template> </template>
<template #community>
<contribution-info />
</template>
<template #empty /> <template #empty />
<template #community>
<community-template />
</template>
</right-side> </right-side>
</b-col> </b-col>
</b-row> </b-row>
@ -194,6 +186,7 @@
</template> </template>
<script> <script>
import ContentHeader from '@/layouts/templates/ContentHeader.vue' import ContentHeader from '@/layouts/templates/ContentHeader.vue'
import CommunityTemplate from '@/layouts/templates/CommunityTemplate.vue'
import Breadcrumb from '@/components/Breadcrumb/breadcrumb.vue' import Breadcrumb from '@/components/Breadcrumb/breadcrumb.vue'
import RightSide from '@/layouts/templates/RightSide.vue' import RightSide from '@/layouts/templates/RightSide.vue'
import SkeletonOverview from '@/components/skeleton/Overview.vue' import SkeletonOverview from '@/components/skeleton/Overview.vue'
@ -211,7 +204,6 @@ import GdtAmount from '@/components/Template/ContentHeader/GdtAmount.vue'
import CommunityMember from '@/components/Template/ContentHeader/CommunityMember.vue' import CommunityMember from '@/components/Template/ContentHeader/CommunityMember.vue'
import NavCommunity from '@/components/Template/ContentHeader/NavCommunity.vue' import NavCommunity from '@/components/Template/ContentHeader/NavCommunity.vue'
import LastTransactions from '@/components/Template/RightSide/LastTransactions.vue' import LastTransactions from '@/components/Template/RightSide/LastTransactions.vue'
import ContributionInfo from '@/components/Template/RightSide/ContributionInfo.vue'
export default { export default {
name: 'DashboardLayout', name: 'DashboardLayout',
@ -231,7 +223,7 @@ export default {
CommunityMember, CommunityMember,
NavCommunity, NavCommunity,
LastTransactions, LastTransactions,
ContributionInfo, CommunityTemplate,
}, },
data() { data() {
return { return {

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ContributionInfo from './ContributionInfo' import CommunityTemplate from './CommunityTemplate'
const localVue = global.localVue const localVue = global.localVue
@ -10,15 +10,17 @@ const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: jest.fn((d) => d),
$route: { $route: {
hash: '', params: {
tab: 'contribute',
},
}, },
} }
describe('ContributionInfo', () => { describe('CommunityTemplate', () => {
let wrapper let wrapper
const Wrapper = () => { const Wrapper = () => {
return mount(ContributionInfo, { localVue, mocks }) return mount(CommunityTemplate, { localVue, mocks })
} }
describe('mount', () => { describe('mount', () => {
beforeEach(() => { beforeEach(() => {
@ -29,9 +31,9 @@ describe('ContributionInfo', () => {
expect(wrapper.findComponent({ name: 'ContributionInfo' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'ContributionInfo' }).exists()).toBe(true)
}) })
describe('mounted with hash #my', () => { describe('mounted with parameter contributions', () => {
beforeEach(() => { beforeEach(() => {
mocks.$route.hash = '#my' mocks.$route.params.tab = 'contributions'
}) })
it('has a header related to "my contribitions"', () => { it('has a header related to "my contribitions"', () => {
@ -59,9 +61,9 @@ describe('ContributionInfo', () => {
}) })
}) })
describe('mounted with hash #all', () => { describe('mounted with parameter community', () => {
beforeEach(() => { beforeEach(() => {
mocks.$route.hash = '#all' mocks.$route.params.tab = 'community'
}) })
it('has a header related to "the community"', () => { it('has a header related to "the community"', () => {
@ -89,9 +91,9 @@ describe('ContributionInfo', () => {
}) })
}) })
describe('mounted with hash #edit', () => { describe('mounted with parameter contribute', () => {
beforeEach(() => { beforeEach(() => {
mocks.$route.hash = '#edit' mocks.$route.params.tab = 'contribute'
}) })
it('has a header related to "the community"', () => { it('has a header related to "the community"', () => {

View File

@ -0,0 +1,82 @@
<template>
<contribution-info>
<template #contribute>
<div show fade variant="secondary" class="text-dark">
<div>
<h3>{{ $t('contribution.formText.yourContribution') }}</h3>
{{ $t('contribution.formText.bringYourTalentsTo') }}
<div class="my-3">
<b>{{ $t('contribution.formText.describeYourCommunity') }}</b>
</div>
</div>
</div>
</template>
<template #contributions>
<div show fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('community.myContributions') }}</h4>
<p>
{{ $t('contribution.alert.myContributionNoteList') }}
</p>
<ul>
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="question-square" variant="warning"></b-icon>
{{ $t('contribution.alert.in_progress') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
<li>
<b-icon icon="x-circle" variant="warning"></b-icon>
{{ $t('contribution.alert.denied') }}
</li>
<li>
<b-icon icon="trash" variant="danger"></b-icon>
{{ $t('contribution.alert.deleted') }}
</li>
</ul>
</div>
</template>
<template #community>
<div show fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('navigation.community') }}</h4>
<p>
{{ $t('contribution.alert.communityNoteList') }}
</p>
<ul>
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="question-square" variant="warning"></b-icon>
{{ $t('contribution.alert.in_progress') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
<li>
<b-icon icon="x-circle" variant="warning"></b-icon>
{{ $t('contribution.alert.denied') }}
</li>
</ul>
</div>
</template>
</contribution-info>
</template>
<script>
import ContributionInfo from '@/components/Template/RightSide/ContributionInfo.vue'
export default {
name: 'CommunityTemplate',
components: {
ContributionInfo,
},
}
</script>

View File

@ -9,7 +9,7 @@ export default {
name: 'ContentHeader', name: 'ContentHeader',
computed: { computed: {
path() { path() {
return this.$route.path.replace(/^\//, '') return this.$route.path.replace(/^\/(.+?)(\/.+)?$/, '$1')
}, },
}, },
} }

View File

@ -10,7 +10,7 @@ export default {
name: 'RightSide', name: 'RightSide',
computed: { computed: {
name() { name() {
switch (this.$route.path.replace(/^\//, '')) { switch (this.$route.path.replace(/^\/(.+?)(\/.+)?$/, '$1')) {
case 'settings': case 'settings':
return 'empty' return 'empty'
case 'community': case 'community':

View File

@ -215,7 +215,9 @@ describe('Community', () => {
push: routerPushMock, push: routerPushMock,
}, },
$route: { $route: {
hash: '#edit', params: {
tab: 'contribute',
},
}, },
} }
@ -260,7 +262,11 @@ describe('Community', () => {
}) })
it('check for correct tabIndex if state is "IN_PROGRESS" or not', () => { it('check for correct tabIndex if state is "IN_PROGRESS" or not', () => {
expect(routerPushMock).toBeCalledWith({ path: '/community#my' }) expect(routerPushMock).toBeCalledWith({ params: { tab: 'contributions' } })
})
it('sets tab index to 1', () => {
expect(wrapper.vm.tabIndex).toBe(1)
}) })
it('toasts an info', () => { it('toasts an info', () => {
@ -268,16 +274,6 @@ describe('Community', () => {
}) })
}) })
describe('API calls after creation', () => {
it('has a DIV .community-page', () => {
expect(wrapper.find('div.community-page').exists()).toBe(true)
})
it('emits update transactions', () => {
expect(wrapper.emitted('update-transactions')).toEqual([[0]])
})
})
describe('save contrubtion', () => { describe('save contrubtion', () => {
describe('with error', () => { describe('with error', () => {
const now = new Date().toISOString() const now = new Date().toISOString()
@ -491,6 +487,10 @@ describe('Community', () => {
it('sets tab index back to 0', () => { it('sets tab index back to 0', () => {
expect(wrapper.vm.tabIndex).toBe(0) expect(wrapper.vm.tabIndex).toBe(0)
}) })
it('pushes contribute parameter to router', () => {
expect(routerPushMock).toBeCalledWith({ params: { tab: 'contribute' } })
})
}) })
describe('update list all contributions', () => { describe('update list all contributions', () => {

View File

@ -64,6 +64,8 @@ import ContributionList from '@/components/Contributions/ContributionList.vue'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations' import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions, openCreations } from '@/graphql/queries' import { listContributions, listAllContributions, openCreations } from '@/graphql/queries'
const COMMUNITY_TABS = ['contribute', 'contributions', 'community']
export default { export default {
name: 'Community', name: 'Community',
components: { components: {
@ -73,8 +75,6 @@ export default {
}, },
data() { data() {
return { return {
hashLink: '',
tabLinkHashes: ['#edit', '#my', '#all'],
tabIndex: 0, tabIndex: 0,
items: [], items: [],
itemsAll: [], itemsAll: [],
@ -97,10 +97,7 @@ export default {
} }
}, },
mounted() { mounted() {
this.$nextTick(() => { this.updateTabIndex()
this.tabIndex = this.tabLinkHashes.findIndex((hashLink) => hashLink === this.$route.hash)
this.hashLink = this.$route.hash
})
}, },
apollo: { apollo: {
OpenCreations: { OpenCreations: {
@ -122,13 +119,13 @@ export default {
query() { query() {
return listAllContributions return listAllContributions
}, },
fetchPolicy: 'network-only',
variables() { variables() {
return { return {
currentPage: this.currentPageAll, currentPage: this.currentPageAll,
pageSize: this.pageSizeAll, pageSize: this.pageSizeAll,
} }
}, },
fetchPolicy: 'no-cache',
update({ listAllContributions }) { update({ listAllContributions }) {
this.contributionCountAll = listAllContributions.contributionCount this.contributionCountAll = listAllContributions.contributionCount
this.itemsAll = listAllContributions.contributionList this.itemsAll = listAllContributions.contributionList
@ -153,9 +150,8 @@ export default {
this.items = listContributions.contributionList this.items = listContributions.contributionList
if (this.items.find((item) => item.state === 'IN_PROGRESS')) { if (this.items.find((item) => item.state === 'IN_PROGRESS')) {
this.tabIndex = 1 this.tabIndex = 1
if (this.$route.hash !== '#my') { if (this.$route.params.tab !== 'contributions')
this.$router.push({ path: '/community#my' }) this.$router.push({ params: { tab: 'contributions' } })
}
this.toastInfo(this.$t('contribution.alert.answerQuestionToast')) this.toastInfo(this.$t('contribution.alert.answerQuestionToast'))
} }
}, },
@ -165,21 +161,8 @@ export default {
}, },
}, },
watch: { watch: {
$route(to, from) { '$route.params.tab'() {
this.tabIndex = this.tabLinkHashes.findIndex((hashLink) => hashLink === to.hash) this.updateTabIndex()
this.hashLink = to.hash
this.closeAllOpenCollapse()
},
tabIndex(num) {
if (num !== 0) {
this.form = {
id: null,
date: new Date(),
memo: '',
hours: 0,
amount: '',
}
}
}, },
}, },
computed: { computed: {
@ -211,6 +194,11 @@ export default {
}, },
}, },
methods: { methods: {
updateTabIndex() {
const index = COMMUNITY_TABS.indexOf(this.$route.params.tab)
this.tabIndex = index > -1 ? index : 0
this.closeAllOpenCollapse()
},
closeAllOpenCollapse() { closeAllOpenCollapse() {
this.$el.querySelectorAll('.collapse.show').forEach((value) => { this.$el.querySelectorAll('.collapse.show').forEach((value) => {
this.$root.$emit('bv::toggle::collapse', value.id) this.$root.$emit('bv::toggle::collapse', value.id)
@ -294,8 +282,8 @@ export default {
this.form.amount = item.amount this.form.amount = item.amount
this.form.hours = item.amount / 20 this.form.hours = item.amount / 20
this.updateAmount = item.amount this.updateAmount = item.amount
this.$router.push({ path: '#edit' })
this.tabIndex = 0 this.tabIndex = 0
this.$router.push({ params: { tab: 'contribute' } })
}, },
updateTransactions(pagination) { updateTransactions(pagination) {
this.$emit('update-transactions', pagination) this.$emit('update-transactions', pagination)
@ -304,11 +292,6 @@ export default {
this.items.find((item) => item.id === id).state = 'PENDING' this.items.find((item) => item.id === id).state = 'PENDING'
}, },
}, },
created() {
this.updateTransactions(0)
this.tabIndex = 0
this.$router.push({ path: '/community#edit' })
},
} }
</script> </script>
<style scoped> <style scoped>

View File

@ -49,8 +49,8 @@ describe('router', () => {
expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' }) expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' })
}) })
it('has sixteen routes defined', () => { it('has 19 routes defined', () => {
expect(routes).toHaveLength(18) expect(routes).toHaveLength(19)
}) })
describe('overview', () => { describe('overview', () => {
@ -75,7 +75,19 @@ describe('router', () => {
}) })
}) })
describe('community', () => { describe('community without tab parameter', () => {
it('requires authorization', () => {
expect(routes.find((r) => r.path === '/community').meta.requiresAuth).toBeTruthy()
})
it('redirects to contribute tab', async () => {
expect(routes.find((r) => r.path === '/community').redirect()).toEqual({
path: '/community/contribute',
})
})
})
describe('community with tab parameter', () => {
it('requires authorization', () => { it('requires authorization', () => {
expect(routes.find((r) => r.path === '/community').meta.requiresAuth).toBeTruthy() expect(routes.find((r) => r.path === '/community').meta.requiresAuth).toBeTruthy()
}) })

View File

@ -58,6 +58,17 @@ const routes = [
requiresAuth: true, requiresAuth: true,
pageTitle: 'community', pageTitle: 'community',
}, },
redirect: (to) => {
return { path: '/community/contribute' }
},
},
{
path: '/community/:tab',
component: () => import('@/pages/Community.vue'),
meta: {
requiresAuth: true,
pageTitle: 'community',
},
}, },
{ {
path: '/information', path: '/information',