diff --git a/admin/src/plugins/apolloProvider.js b/admin/src/plugins/apolloProvider.js index 95b7aab7e..8b02013f4 100644 --- a/admin/src/plugins/apolloProvider.js +++ b/admin/src/plugins/apolloProvider.js @@ -10,7 +10,7 @@ const authLink = new ApolloLink((operation, forward) => { operation.setContext({ headers: { Authorization: token && token.length > 0 ? `Bearer ${token}` : '', - clientRequestTime: new Date().toString(), + clientTimezoneOffset: new Date().getTimezoneOffset(), }, }) return forward(operation).map((response) => { diff --git a/admin/src/plugins/apolloProvider.test.js b/admin/src/plugins/apolloProvider.test.js index 7889c3318..483862bea 100644 --- a/admin/src/plugins/apolloProvider.test.js +++ b/admin/src/plugins/apolloProvider.test.js @@ -94,7 +94,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: 'Bearer some-token', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) }) @@ -110,7 +110,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: '', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) }) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index f26fce3d8..503bab472 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { objectValuesToArray } from '@/util/utilities' -import { testEnvironment, resetToken, cleanDB } from '@test/helpers' +import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' @@ -83,6 +83,12 @@ let user: User let creation: Contribution | void let result: any +describe('contributionDateFormatter', () => { + it('formats the date correctly', () => { + expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') + }) +}) + describe('AdminResolver', () => { describe('set user role', () => { describe('unauthenticated', () => { @@ -751,7 +757,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -861,7 +867,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -936,19 +942,25 @@ describe('AdminResolver', () => { }) describe('adminCreateContribution', () => { + const now = new Date() + beforeAll(async () => { - const now = new Date() creation = await creationFactory(testEnv, { email: 'peter@lustig.de', amount: 400, memo: 'Herzlich Willkommen bei Gradido!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), }) }) describe('user to create for does not exist', () => { it('throws an error', async () => { jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -969,6 +981,9 @@ describe('AdminResolver', () => { beforeAll(async () => { user = await userFactory(testEnv, stephenHawking) variables.email = 'stephen@hawking.uk' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) }) it('throws an error', async () => { @@ -995,6 +1010,9 @@ describe('AdminResolver', () => { beforeAll(async () => { user = await userFactory(testEnv, garrickOllivander) variables.email = 'garrick@ollivander.com' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) }) it('throws an error', async () => { @@ -1021,6 +1039,7 @@ describe('AdminResolver', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) variables.email = 'bibi@bloxberg.de' + variables.creationDate = 'invalid-date' }) describe('date of creation is not a date string', () => { @@ -1030,30 +1049,22 @@ describe('AdminResolver', () => { mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], + errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], }), ) }) it('logs the error thrown', () => { - expect(logger.error).toBeCalledWith( - 'No information for available creations with the given creationDate=', - 'Invalid Date', - ) + expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) }) }) describe('date of creation is four months ago', () => { it('throws an error', async () => { jest.clearAllMocks() - const now = new Date() - variables.creationDate = new Date( - now.getFullYear(), - now.getMonth() - 4, - 1, - ).toString() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 4, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -1068,7 +1079,7 @@ describe('AdminResolver', () => { it('logs the error thrown', () => { expect(logger.error).toBeCalledWith( 'No information for available creations with the given creationDate=', - variables.creationDate, + new Date(variables.creationDate).toString(), ) }) }) @@ -1076,12 +1087,9 @@ describe('AdminResolver', () => { describe('date of creation is in the future', () => { it('throws an error', async () => { jest.clearAllMocks() - const now = new Date() - variables.creationDate = new Date( - now.getFullYear(), - now.getMonth() + 4, - 1, - ).toString() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() + 4, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -1096,7 +1104,7 @@ describe('AdminResolver', () => { it('logs the error thrown', () => { expect(logger.error).toBeCalledWith( 'No information for available creations with the given creationDate=', - variables.creationDate, + new Date(variables.creationDate).toString(), ) }) }) @@ -1104,7 +1112,7 @@ describe('AdminResolver', () => { describe('amount of creation is too high', () => { it('throws an error', async () => { jest.clearAllMocks() - variables.creationDate = new Date().toString() + variables.creationDate = contributionDateFormatter(now) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -1192,7 +1200,7 @@ describe('AdminResolver', () => { email, amount: new Decimal(500), memo: 'Grundeinkommen', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), } }) @@ -1238,7 +1246,7 @@ describe('AdminResolver', () => { email: 'bob@baumeister.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1268,7 +1276,7 @@ describe('AdminResolver', () => { email: 'stephen@hawking.uk', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1294,7 +1302,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1321,8 +1329,8 @@ describe('AdminResolver', () => { amount: new Decimal(300), memo: 'Danke Bibi!', creationDate: creation - ? creation.contributionDate.toString() - : new Date().toString(), + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1356,8 +1364,8 @@ describe('AdminResolver', () => { amount: new Decimal(1900), memo: 'Danke Peter!', creationDate: creation - ? creation.contributionDate.toString() - : new Date().toString(), + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1390,8 +1398,8 @@ describe('AdminResolver', () => { amount: new Decimal(300), memo: 'Danke Peter!', creationDate: creation - ? creation.contributionDate.toString() - : new Date().toString(), + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1430,8 +1438,8 @@ describe('AdminResolver', () => { amount: new Decimal(200), memo: 'Das war leider zu Viel!', creationDate: creation - ? creation.contributionDate.toString() - : new Date().toString(), + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1554,7 +1562,7 @@ describe('AdminResolver', () => { variables: { amount: 100.0, memo: 'Test env contribution', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }) }) @@ -1633,7 +1641,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: 400, memo: 'Herzlich Willkommen bei Gradido!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), }) }) @@ -1664,7 +1674,9 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: 450, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) }) @@ -1735,13 +1747,17 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: 50, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) c2 = await creationFactory(testEnv, { email: 'bibi@bloxberg.de', amount: 50, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) }) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 479d020ea..80c69a864 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -1,4 +1,4 @@ -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' import { @@ -49,6 +49,7 @@ import { validateContribution, isStartEndDateValid, updateCreations, + isValidDateString, } from './util/creations' import { CONTRIBUTIONLINK_NAME_MAX_CHARS, @@ -86,7 +87,9 @@ export class AdminResolver { async searchUsers( @Args() { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, + @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const userRepository = getCustomRepository(UserRepository) const userFields = [ 'id', @@ -114,7 +117,10 @@ export class AdminResolver { } } - const creations = await getUserCreations(users.map((u) => u.id)) + const creations = await getUserCreations( + users.map((u) => u.id), + clientTimezoneOffset, + ) const adminUsers = await Promise.all( users.map(async (user) => { @@ -237,6 +243,11 @@ export class AdminResolver { logger.info( `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, ) + const clientTimezoneOffset = getClientTimezoneOffset(context) + if (!isValidDateString(creationDate)) { + logger.error(`invalid Date for creationDate=${creationDate}`) + throw new Error(`invalid Date for creationDate=${creationDate}`) + } const emailContact = await UserContact.findOne({ where: { email }, withDeleted: true, @@ -262,11 +273,11 @@ export class AdminResolver { const event = new Event() const moderator = getUser(context) logger.trace('moderator: ', moderator.id) - const creations = await getUserCreation(emailContact.userId) + const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) logger.trace('creations:', creations) const creationDateObj = new Date(creationDate) logger.trace('creationDateObj:', creationDateObj) - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) const contribution = DbContribution.create() contribution.userId = emailContact.userId contribution.amount = amount @@ -289,7 +300,7 @@ export class AdminResolver { event.setEventAdminContributionCreate(eventAdminCreateContribution), ) - return getUserCreation(emailContact.userId) + return getUserCreation(emailContact.userId, clientTimezoneOffset) } @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @@ -325,6 +336,7 @@ export class AdminResolver { @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const emailContact = await UserContact.findOne({ where: { email }, withDeleted: true, @@ -365,17 +377,17 @@ export class AdminResolver { } const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id) + let creations = await getUserCreation(user.id, clientTimezoneOffset) if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate) + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) } else { logger.error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) contributionToUpdate.amount = amount contributionToUpdate.memo = memo contributionToUpdate.contributionDate = new Date(creationDate) @@ -389,7 +401,7 @@ export class AdminResolver { result.memo = contributionToUpdate.memo result.date = contributionToUpdate.contributionDate - result.creation = await getUserCreation(user.id) + result.creation = await getUserCreation(user.id, clientTimezoneOffset) const event = new Event() const eventAdminContributionUpdate = new EventAdminContributionUpdate() @@ -405,7 +417,8 @@ export class AdminResolver { @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Query(() => [UnconfirmedContribution]) - async listUnconfirmedContributions(): Promise { + async listUnconfirmedContributions(@Ctx() context: Context): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const contributions = await getConnection() .createQueryBuilder() .select('c') @@ -419,7 +432,7 @@ export class AdminResolver { } const userIds = contributions.map((p) => p.userId) - const userCreations = await getUserCreations(userIds) + const userCreations = await getUserCreations(userIds, clientTimezoneOffset) const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true, @@ -493,6 +506,7 @@ export class AdminResolver { @Arg('id', () => Int) id: number, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const contribution = await DbContribution.findOne(id) if (!contribution) { logger.error(`Contribution not found for given id: ${id}`) @@ -511,8 +525,13 @@ export class AdminResolver { logger.error('This user was deleted. Cannot confirm a contribution.') throw new Error('This user was deleted. Cannot confirm a contribution.') } - const creations = await getUserCreation(contribution.userId, false) - validateContribution(creations, contribution.amount, contribution.contributionDate) + const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) + validateContribution( + creations, + contribution.amount, + contribution.contributionDate, + clientTimezoneOffset, + ) const receivedCallDate = new Date() diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index a061304b7..15bdbfc2e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,5 +1,5 @@ import { RIGHTS } from '@/auth/RIGHTS' -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' import { Contribution as dbContribution } from '@entity/Contribution' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' @@ -31,6 +31,7 @@ export class ContributionResolver { @Args() { amount, memo, creationDate }: ContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) if (memo.length > MEMO_MAX_CHARS) { logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) @@ -44,10 +45,10 @@ export class ContributionResolver { const event = new Event() const user = getUser(context) - const creations = await getUserCreation(user.id) + const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.trace('creations', creations) const creationDateObj = new Date(creationDate) - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) const contribution = dbContribution.create() contribution.userId = user.id @@ -171,6 +172,7 @@ export class ContributionResolver { @Args() { amount, memo, creationDate }: ContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) if (memo.length > MEMO_MAX_CHARS) { logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) @@ -206,16 +208,16 @@ export class ContributionResolver { ) } const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id) + let creations = await getUserCreation(user.id, clientTimezoneOffset) if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate) + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) } else { logger.error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) const contributionMessage = ContributionMessage.create() contributionMessage.contributionId = contributionId diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index a3156497a..8d648bc65 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,5 +1,5 @@ import { backendLogger as logger } from '@/server/logger' -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { getConnection } from '@dbTools/typeorm' import { Resolver, @@ -169,6 +169,7 @@ export class TransactionLinkResolver { @Arg('code', () => String) code: string, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const user = getUser(context) const now = new Date() @@ -258,9 +259,9 @@ export class TransactionLinkResolver { } } - const creations = await getUserCreation(user.id) + const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.info('open creations', creations) - validateContribution(creations, contributionLink.amount, now) + validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) const contribution = new DbContribution() contribution.userId = user.id contribution.createdAt = now diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 2287ede98..43735c0d6 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,6 +1,6 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' @@ -305,8 +305,9 @@ export class UserResolver { async verifyLogin(@Ctx() context: Context): Promise { logger.info('verifyLogin...') // TODO refactor and do not have duplicate code with login(see below) + const clientTimezoneOffset = getClientTimezoneOffset(context) const userEntity = getUser(context) - const user = new User(userEntity, await getUserCreation(userEntity.id)) + const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset)) // user.pubkey = userEntity.pubKey.toString('hex') // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage(context) @@ -323,6 +324,7 @@ export class UserResolver { @Ctx() context: Context, ): Promise { logger.info(`login with ${email}, ***, ${publisherId} ...`) + const clientTimezoneOffset = getClientTimezoneOffset(context) email = email.trim().toLowerCase() const dbUser = await findUserByEmail(email) if (dbUser.deletedAt) { @@ -353,7 +355,7 @@ export class UserResolver { logger.addContext('user', dbUser.id) logger.debug('validation of login credentials successful...') - const user = new User(dbUser, await getUserCreation(dbUser.id)) + const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset)) logger.debug(`user= ${JSON.stringify(user, null, 2)}`) // Elopage Status & Stored PublisherId diff --git a/backend/src/graphql/resolver/util/creations.test.ts b/backend/src/graphql/resolver/util/creations.test.ts new file mode 100644 index 000000000..8d747e989 --- /dev/null +++ b/backend/src/graphql/resolver/util/creations.test.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { User } from '@entity/User' +import { Contribution } from '@entity/Contribution' +import { userFactory } from '@/seeds/factory/user' +import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations' +import { getUserCreation } from './creations' + +let mutate: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +const setZeroHours = (date: Date): Date => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +describe('util/creation', () => { + let user: User + let admin: User + + const now = new Date() + + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + admin = await userFactory(testEnv, peterLustig) + }) + + describe('getUserCreations', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 250.0, + memo: 'Admin contribution for this month', + creationDate: contributionDateFormatter(now), + }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 160.0, + memo: 'Admin contribution for the last month', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 450.0, + memo: 'Admin contribution for two months ago', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()), + ), + }, + }) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: createContribution, + variables: { + amount: 400.0, + memo: 'Contribution for this month', + creationDate: contributionDateFormatter(now), + }, + }) + await mutate({ + mutation: createContribution, + variables: { + amount: 500.0, + memo: 'Contribution for the last month', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + }, + }) + }) + + it('has the correct data setup', async () => { + await expect(Contribution.find()).resolves.toEqual([ + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours(now), + amount: expect.decimalEqual(250), + memo: 'Admin contribution for this month', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + amount: expect.decimalEqual(160), + memo: 'Admin contribution for the last month', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()), + ), + amount: expect.decimalEqual(450), + memo: 'Admin contribution for two months ago', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours(now), + amount: expect.decimalEqual(400), + memo: 'Contribution for this month', + moderatorId: null, + contributionType: 'USER', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + amount: expect.decimalEqual(500), + memo: 'Contribution for the last month', + moderatorId: null, + contributionType: 'USER', + contributionStatus: 'PENDING', + }), + ]) + }) + + describe('call getUserCreation now', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + + describe('run forward in time one hour before next month', () => { + const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0) + + beforeAll(() => { + jest.useFakeTimers() + setTimeout(jest.fn(), targetDate.getTime() - now.getTime()) + jest.runAllTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('has the clock set correctly', () => { + expect(new Date().toISOString()).toContain( + `${targetDate.getFullYear()}-${targetDate.getMonth() + 1}-${targetDate.getDate()}T23:`, + ) + }) + + describe('call getUserCreation with UTC', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + + describe('call getUserCreation with JST (GMT+0900)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with PST (GMT-0800)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 480, true)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + + describe('run two hours forward to be in the next month in UTC', () => { + const nextMonthTargetDate = new Date() + nextMonthTargetDate.setTime(targetDate.getTime() + 2 * 60 * 60 * 1000) + + beforeAll(() => { + setTimeout(jest.fn(), 2 * 60 * 60 * 1000) + jest.runAllTimers() + }) + + it('has the clock set correctly', () => { + expect(new Date().toISOString()).toContain( + `${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`, + ) + }) + + describe('call getUserCreation with UTC', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with JST (GMT+0900)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with PST (GMT-0800)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 450, true)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index abf4017cb..eb4b6394d 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -13,9 +13,10 @@ export const validateContribution = ( creations: Decimal[], amount: Decimal, creationDate: Date, + timezoneOffset: number, ): void => { logger.trace('isContributionValid: ', creations, amount, creationDate) - const index = getCreationIndex(creationDate.getMonth()) + const index = getCreationIndex(creationDate.getMonth(), timezoneOffset) if (index < 0) { logger.error( @@ -37,10 +38,11 @@ export const validateContribution = ( export const getUserCreations = async ( ids: number[], + timezoneOffset: number, includePending = true, ): Promise => { logger.trace('getUserCreations:', ids, includePending) - const months = getCreationMonths() + const months = getCreationMonths(timezoneOffset) logger.trace('getUserCreations months', months) const queryRunner = getConnection().createQueryRunner() @@ -87,24 +89,29 @@ export const getUserCreations = async ( }) } -export const getUserCreation = async (id: number, includePending = true): Promise => { - logger.trace('getUserCreation', id, includePending) - const creations = await getUserCreations([id], includePending) +export const getUserCreation = async ( + id: number, + timezoneOffset: number, + includePending = true, +): Promise => { + logger.trace('getUserCreation', id, includePending, timezoneOffset) + const creations = await getUserCreations([id], timezoneOffset, includePending) logger.trace('getUserCreation creations=', creations) return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE } -export const getCreationMonths = (): number[] => { - const now = new Date(Date.now()) +const getCreationMonths = (timezoneOffset: number): number[] => { + const clientNow = new Date() + clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000) return [ - now.getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, - ].reverse() + new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1, + new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1, + clientNow.getMonth() + 1, + ] } -export const getCreationIndex = (month: number): number => { - return getCreationMonths().findIndex((el) => el === month + 1) +const getCreationIndex = (month: number, timezoneOffset: number): number => { + return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1) } export const isStartEndDateValid = ( @@ -128,8 +135,12 @@ export const isStartEndDateValid = ( } } -export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { - const index = getCreationIndex(contribution.contributionDate.getMonth()) +export const updateCreations = ( + creations: Decimal[], + contribution: Contribution, + timezoneOffset: number, +): Decimal[] => { + const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset) if (index < 0) { throw new Error('You cannot create GDD for a month older than the last three months.') @@ -137,3 +148,7 @@ export const updateCreations = (creations: Decimal[], contribution: Contribution creations[index] = creations[index].plus(contribution.amount.toString()) return creations } + +export const isValidDateString = (dateString: string): boolean => { + return new Date(dateString).toString() !== 'Invalid Date' +} diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index c5a55cb84..3675d381d 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -29,6 +29,7 @@ const context = { // eslint-disable-next-line @typescript-eslint/no-empty-function forEach: (): void => {}, }, + clientTimezoneOffset: 0, } export const cleanDB = async () => { diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts index 5bfc22e72..8ba590dd3 100644 --- a/backend/src/server/context.ts +++ b/backend/src/server/context.ts @@ -9,7 +9,7 @@ export interface Context { setHeaders: { key: string; value: string }[] role?: Role user?: dbUser - clientRequestTime?: string + clientTimezoneOffset?: number // hack to use less DB calls for Balance Resolver lastTransaction?: dbTransaction transactionCount?: number @@ -19,7 +19,7 @@ export interface Context { const context = (args: ExpressContext): Context => { const authorization = args.req.headers.authorization - const clientRequestTime = args.req.headers.clientrequesttime + const clientTimezoneOffset = args.req.headers.clienttimezoneoffset const context: Context = { token: null, setHeaders: [], @@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => { if (authorization) { context.token = authorization.replace(/^Bearer /, '') } - if (clientRequestTime && typeof clientRequestTime === 'string') { - context.clientRequestTime = clientRequestTime + if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') { + context.clientTimezoneOffset = Number(clientTimezoneOffset) } return context } @@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => { throw new Error('No user given in context!') } +export const getClientTimezoneOffset = (context: Context): number => { + if ( + (context.clientTimezoneOffset || context.clientTimezoneOffset === 0) && + Math.abs(context.clientTimezoneOffset) <= 27 * 60 + ) { + return context.clientTimezoneOffset + } + throw new Error('No valid client time zone offset in context!') +} + export default context diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 6e1856b63..53fd21607 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -16,6 +16,7 @@ const context = { push: headerPushMock, forEach: jest.fn(), }, + clientTimezoneOffset: 0, } export const cleanDB = async () => { @@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => { export const resetToken = () => { context.token = '' } + +// format date string as it comes from the frontend for the contribution date +export const contributionDateFormatter = (date: Date): string => { + return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}` +} + +export const setClientTimezoneOffset = (offset: number): void => { + context.clientTimezoneOffset = offset +} diff --git a/frontend/src/plugins/apolloProvider.js b/frontend/src/plugins/apolloProvider.js index 05954d36b..7a37bcf62 100644 --- a/frontend/src/plugins/apolloProvider.js +++ b/frontend/src/plugins/apolloProvider.js @@ -12,7 +12,7 @@ const authLink = new ApolloLink((operation, forward) => { operation.setContext({ headers: { Authorization: token && token.length > 0 ? `Bearer ${token}` : '', - clientRequestTime: new Date().toString(), + clientTimezoneOffset: new Date().getTimezoneOffset(), }, }) return forward(operation).map((response) => { diff --git a/frontend/src/plugins/apolloProvider.test.js b/frontend/src/plugins/apolloProvider.test.js index 31e1a664b..584014213 100644 --- a/frontend/src/plugins/apolloProvider.test.js +++ b/frontend/src/plugins/apolloProvider.test.js @@ -98,7 +98,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: 'Bearer some-token', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) }) @@ -114,7 +114,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: '', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) })