diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac60cfdf2..412154b04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,6 @@ jobs: build_test_admin: name: Docker Build Test - Admin Interface runs-on: ubuntu-latest - #needs: [nothing] steps: ########################################################################## # CHECKOUT CODE ########################################################## @@ -437,7 +436,7 @@ jobs: report_name: Coverage Frontend type: lcov result_path: ./coverage/lcov.info - min_coverage: 91 + min_coverage: 93 token: ${{ github.token }} ############################################################################## diff --git a/CHANGELOG.md b/CHANGELOG.md index 19957a309..358e4670a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,76 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.17.1](https://github.com/gradido/gradido/compare/1.17.0...1.17.1) + +- refactor(frontend): change contribution memo add word-break [`#2583`](https://github.com/gradido/gradido/pull/2583) +- refactor(admin): add text-break on all table memo fields [`#2584`](https://github.com/gradido/gradido/pull/2584) +- fix(frontend): throw proper frontend warning errors [`#2586`](https://github.com/gradido/gradido/pull/2586) +- refactor(frontend): equalize en with fr languages. [`#2585`](https://github.com/gradido/gradido/pull/2585) +- refactor(frontend): forgot password unit tests [`#2582`](https://github.com/gradido/gradido/pull/2582) +- fix(frontend): fix min value for hours input [`#2581`](https://github.com/gradido/gradido/pull/2581) +- fix(frontend): change dropdown placement calender no-flip true [`#2580`](https://github.com/gradido/gradido/pull/2580) +- refactor(frontend): link send result style [`#2577`](https://github.com/gradido/gradido/pull/2577) +- refactor(frontend): remove vertical scrolling & small fixes [`#2578`](https://github.com/gradido/gradido/pull/2578) +- refactor(frontend): tyle mobile device auth template [`#2576`](https://github.com/gradido/gradido/pull/2576) + +#### [1.17.0](https://github.com/gradido/gradido/compare/1.16.0...1.17.0) + +> 18 January 2023 + +- chore(release): v1.17.0 [`#2575`](https://github.com/gradido/gradido/pull/2575) +- fix(frontend): submit contribution text [`#2573`](https://github.com/gradido/gradido/pull/2573) +- fix(backend): admin cannot delete confirmed contribution [`#2571`](https://github.com/gradido/gradido/pull/2571) +- fix(frontend): english locales - horas -> hours [`#2572`](https://github.com/gradido/gradido/pull/2572) +- fix(frontend): mobil divices datepicker add props dropleft [`#2570`](https://github.com/gradido/gradido/pull/2570) +- fix(frontend): pagination [`#2569`](https://github.com/gradido/gradido/pull/2569) +- fix(frontend): add a watch on gdt prop to assure propper loading when mounted [`#2568`](https://github.com/gradido/gradido/pull/2568) +- refactor(frontend): creation step in quarter hour set [`#2566`](https://github.com/gradido/gradido/pull/2566) +- fix(frontend): tunneled email on right side last transactions [`#2561`](https://github.com/gradido/gradido/pull/2561) +- feat(frontend): test transaction page [`#2555`](https://github.com/gradido/gradido/pull/2555) +- refactor(backend): statistics with field resolvers [`#2553`](https://github.com/gradido/gradido/pull/2553) +- fix(frontend): normalized amount transaction if processed again [`#2550`](https://github.com/gradido/gradido/pull/2550) +- fix(backend): semaphore deadlock [`#2551`](https://github.com/gradido/gradido/pull/2551) +- fix(frontend): mobile design [`#2552`](https://github.com/gradido/gradido/pull/2552) +- refactor(frontend): slots for right sidebar and header [`#2548`](https://github.com/gradido/gradido/pull/2548) +- fix(frontend): creation menu highlighted on all submenus [`#2527`](https://github.com/gradido/gradido/pull/2527) +- refactor(frontend): computed hours for open creations [`#2545`](https://github.com/gradido/gradido/pull/2545) +- feat(other): add description for daily backup cronjob [`#2532`](https://github.com/gradido/gradido/pull/2532) +- fix(frontend): editing transaction does not work [`#2543`](https://github.com/gradido/gradido/pull/2543) +- refactor(frontend): remove open creations from store [`#2541`](https://github.com/gradido/gradido/pull/2541) +- feat(other): vscode extensions [`#2524`](https://github.com/gradido/gradido/pull/2524) +- fix(backend): remove jest from dependecies [`#2533`](https://github.com/gradido/gradido/pull/2533) +- fix(frontend): initials without space [`#2546`](https://github.com/gradido/gradido/pull/2546) +- fix(backend): fix backend not confirmable [`#2539`](https://github.com/gradido/gradido/pull/2539) +- fix(frontend): send gdd and send link gdd is running [`#2534`](https://github.com/gradido/gradido/pull/2534) +- fix(other): update browser list [`#2540`](https://github.com/gradido/gradido/pull/2540) +- test(backend): increase backend coverage to 78% [`#2542`](https://github.com/gradido/gradido/pull/2542) +- fix(frontend): pagination gdt [`#2525`](https://github.com/gradido/gradido/pull/2525) +- fix(frontend): leaves are over the user symbol [`#2526`](https://github.com/gradido/gradido/pull/2526) +- fix(frontend): input-email label and placeholder are displayed correctly per language [`#2528`](https://github.com/gradido/gradido/pull/2528) +- feat(backend): add hideAmountGDD & hideAmountGDT to users table. [`#2506`](https://github.com/gradido/gradido/pull/2506) +- fix(frontend): avatar initials always has 2 letters [`#2530`](https://github.com/gradido/gradido/pull/2530) +- refactor(backend): seed contributions as user [`#2460`](https://github.com/gradido/gradido/pull/2460) +- fix(backend): fix logger middleware [`#2503`](https://github.com/gradido/gradido/pull/2503) +- fix(backend): fix email text [`#2523`](https://github.com/gradido/gradido/pull/2523) +- feat(other): new scopes for lint pr [`#2489`](https://github.com/gradido/gradido/pull/2489) +- fix(backend): fix config - some typos [`#2477`](https://github.com/gradido/gradido/pull/2477) +- style(frontend): new Design [`#2297`](https://github.com/gradido/gradido/pull/2297) +- refactor(other): adjust some texts and translations [`#2504`](https://github.com/gradido/gradido/pull/2504) +- test(other): fix tests breaking with the new year [`#2505`](https://github.com/gradido/gradido/pull/2505) +- feat(backend): federation implement exchange of api versions persist in table [`#2427`](https://github.com/gradido/gradido/pull/2427) +- feat(backend): semaphore to lock transaction table [`#2458`](https://github.com/gradido/gradido/pull/2458) +- feat(backend): design html emails and adjust texts [`#2472`](https://github.com/gradido/gradido/pull/2472) +- feat(backend): test semaphore [`#2468`](https://github.com/gradido/gradido/pull/2468) +- fix(admin): reduce triggers of success toast on deleted user form to exactly one [`#2471`](https://github.com/gradido/gradido/pull/2471) +- refactor(other): build nginx docker image in workflow independent of other builds [`#2470`](https://github.com/gradido/gradido/pull/2470) +- feat(backend): setup unit tests for federation [`#2465`](https://github.com/gradido/gradido/pull/2465) + #### [1.16.0](https://github.com/gradido/gradido/compare/1.15.0...1.16.0) +> 15 December 2022 + +- feat(release): version 1.16.0 [`#2467`](https://github.com/gradido/gradido/pull/2467) - refactor(backend): cleaning user related old password junk [`#2426`](https://github.com/gradido/gradido/pull/2426) - fix(database): consistent transaction table [`#2453`](https://github.com/gradido/gradido/pull/2453) - refactor(backend): dissolve admin resolver [`#2416`](https://github.com/gradido/gradido/pull/2416) diff --git a/admin/package.json b/admin/package.json index 58eb48d09..8270c4da6 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "1.16.0", + "version": "1.17.1", "license": "Apache-2.0", "private": false, "scripts": { diff --git a/admin/src/components/CreationTransactionList.vue b/admin/src/components/CreationTransactionList.vue index 2ce143c7f..950afcebc 100644 --- a/admin/src/components/CreationTransactionList.vue +++ b/admin/src/components/CreationTransactionList.vue @@ -88,7 +88,7 @@ export default { return `${value} GDD` }, }, - { key: 'memo', label: this.$t('transactionlist.memo') }, + { key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' }, ], } }, diff --git a/admin/src/components/Tables/OpenCreationsTable.spec.js b/admin/src/components/Tables/OpenCreationsTable.spec.js index 2eb149e4f..9e43fd66c 100644 --- a/admin/src/components/Tables/OpenCreationsTable.spec.js +++ b/admin/src/components/Tables/OpenCreationsTable.spec.js @@ -57,7 +57,7 @@ const propsData = { return value + ' GDD' }, }, - { key: 'memo', label: 'text' }, + { key: 'memo', label: 'text', class: 'text-break' }, { key: 'date', label: 'date', diff --git a/admin/src/components/TransactionLinkList.vue b/admin/src/components/TransactionLinkList.vue index d850083cc..564865440 100644 --- a/admin/src/components/TransactionLinkList.vue +++ b/admin/src/components/TransactionLinkList.vue @@ -67,7 +67,7 @@ export default { return `${value} GDD` }, }, - { key: 'memo', label: this.$t('transactionlist.memo') }, + { key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' }, { key: 'validUntil', label: this.$t('transactionlink.valid_until'), diff --git a/admin/src/graphql/communityStatistics.js b/admin/src/graphql/communityStatistics.js index 868bfd02a..3159ee258 100644 --- a/admin/src/graphql/communityStatistics.js +++ b/admin/src/graphql/communityStatistics.js @@ -4,12 +4,14 @@ export const communityStatistics = gql` query { communityStatistics { totalUsers - activeUsers deletedUsers totalGradidoCreated totalGradidoDecayed - totalGradidoAvailable - totalGradidoUnbookedDecayed + dynamicStatisticsFields { + activeUsers + totalGradidoAvailable + totalGradidoUnbookedDecayed + } } } ` diff --git a/admin/src/pages/CommunityStatistic.spec.js b/admin/src/pages/CommunityStatistic.spec.js index 50e04d11f..528548f02 100644 --- a/admin/src/pages/CommunityStatistic.spec.js +++ b/admin/src/pages/CommunityStatistic.spec.js @@ -17,12 +17,14 @@ const defaultData = () => { return { communityStatistics: { totalUsers: 3113, - activeUsers: 1057, deletedUsers: 35, totalGradidoCreated: '4083774.05000000000000000000', totalGradidoDecayed: '-1062639.13634129622923372197', - totalGradidoAvailable: '2513565.869444365732411569', - totalGradidoUnbookedDecayed: '-500474.6738366222166261272', + dynamicStatisticsFields: { + activeUsers: 1057, + totalGradidoAvailable: '2513565.869444365732411569', + totalGradidoUnbookedDecayed: '-500474.6738366222166261272', + }, }, } } diff --git a/admin/src/pages/CommunityStatistic.vue b/admin/src/pages/CommunityStatistic.vue index 3b4865ee3..e656fd2c8 100644 --- a/admin/src/pages/CommunityStatistic.vue +++ b/admin/src/pages/CommunityStatistic.vue @@ -31,7 +31,9 @@ export default { return communityStatistics }, update({ communityStatistics }) { - this.statistics = communityStatistics + const totals = { ...communityStatistics.dynamicStatisticsFields } + this.statistics = { ...communityStatistics, ...totals } + delete this.statistics.dynamicStatisticsFields }, error({ message }) { this.toastError(message) diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index 50a61865f..aac9dd0a0 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -142,7 +142,7 @@ export default { return value + ' GDD' }, }, - { key: 'memo', label: this.$t('text') }, + { key: 'memo', label: this.$t('text'), class: 'text-break' }, { key: 'date', label: this.$t('date'), diff --git a/backend/package.json b/backend/package.json index 1fb27f05f..bfcd61d5b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.16.0", + "version": "1.17.1", "description": "Gradido unified backend providing an API-Service for Gradido Transactions", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/backend", diff --git a/backend/src/graphql/model/CommunityStatistics.ts b/backend/src/graphql/model/CommunityStatistics.ts index 61354115c..87ef595af 100644 --- a/backend/src/graphql/model/CommunityStatistics.ts +++ b/backend/src/graphql/model/CommunityStatistics.ts @@ -2,13 +2,25 @@ import { ObjectType, Field } from 'type-graphql' import Decimal from 'decimal.js-light' @ObjectType() -export class CommunityStatistics { - @Field(() => Number) - totalUsers: number - +export class DynamicStatisticsFields { @Field(() => Number) activeUsers: number + @Field(() => Decimal) + totalGradidoAvailable: Decimal + + @Field(() => Decimal) + totalGradidoUnbookedDecayed: Decimal +} + +@ObjectType() +export class CommunityStatistics { + @Field(() => Number) + allUsers: number + + @Field(() => Number) + totalUsers: number + @Field(() => Number) deletedUsers: number @@ -18,9 +30,7 @@ export class CommunityStatistics { @Field(() => Decimal) totalGradidoDecayed: Decimal - @Field(() => Decimal) - totalGradidoAvailable: Decimal - - @Field(() => Decimal) - totalGradidoUnbookedDecayed: Decimal + // be carefull querying this, takes longer than 2 secs. + @Field(() => DynamicStatisticsFields) + dynamicStatisticsFields: DynamicStatisticsFields } diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 9a7fb76f2..abae8e446 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -3,6 +3,7 @@ import Decimal from 'decimal.js-light' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { bobBaumeister } from '@/seeds/users/bob-baumeister' import { stephenHawking } from '@/seeds/users/stephen-hawking' import { garrickOllivander } from '@/seeds/users/garrick-ollivander' import { @@ -26,7 +27,13 @@ import { sendContributionConfirmedEmail, // sendContributionRejectedEmail, } from '@/emails/sendEmailVariants' -import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers' +import { + cleanDB, + resetToken, + testEnvironment, + contributionDateFormatter, + resetEntity, +} from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' @@ -1818,6 +1825,49 @@ describe('ContributionResolver', () => { ) }) }) + + describe('creation already confirmed', () => { + it('throws an error', async () => { + await userFactory(testEnv, bobBaumeister) + await query({ + query: login, + variables: { email: 'bob@baumeister.de', password: 'Aa12345_' }, + }) + const { + data: { createContribution: confirmedContribution }, + } = await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Confirmed Contribution', + creationDate: contributionDateFormatter(new Date()), + }, + }) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: confirmContribution, + variables: { + id: confirmedContribution.id ? confirmedContribution.id : -1, + }, + }) + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: confirmedContribution.id ? confirmedContribution.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('A confirmed contribution can not be deleted')], + }), + ) + await resetEntity(DbTransaction) + }) + }) }) describe('confirmContribution', () => { diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 7771c62ca..a71e4767e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -512,6 +512,10 @@ export class ContributionResolver { logger.error(`Contribution not found for given id: ${id}`) throw new Error('Contribution not found for given id.') } + if (contribution.confirmedAt) { + logger.error('A confirmed contribution can not be deleted') + throw new Error('A confirmed contribution can not be deleted') + } const moderator = getUser(context) if ( contribution.contributionType === ContributionType.USER && @@ -557,7 +561,6 @@ export class ContributionResolver { ): Promise { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - try { const clientTimezoneOffset = getClientTimezoneOffset(context) const contribution = await DbContribution.findOne(id) @@ -664,7 +667,6 @@ export class ContributionResolver { } finally { releaseLock() } - return true } diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index f6c2b9e22..e91840f10 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -1,81 +1,113 @@ import Decimal from 'decimal.js-light' -import { Resolver, Query, Authorized } from 'type-graphql' +import { Resolver, Query, Authorized, FieldResolver } from 'type-graphql' import { getConnection } from '@dbTools/typeorm' import { Transaction as DbTransaction } from '@entity/Transaction' import { User as DbUser } from '@entity/User' -import { CommunityStatistics } from '@model/CommunityStatistics' +import { CommunityStatistics, DynamicStatisticsFields } from '@model/CommunityStatistics' import { RIGHTS } from '@/auth/RIGHTS' import { calculateDecay } from '@/util/decay' -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - -@Resolver() +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +@Resolver((of) => CommunityStatistics) export class StatisticsResolver { @Authorized([RIGHTS.COMMUNITY_STATISTICS]) @Query(() => CommunityStatistics) async communityStatistics(): Promise { - const allUsers = await DbUser.count({ withDeleted: true }) - const totalUsers = await DbUser.count() - const deletedUsers = allUsers - totalUsers + return new CommunityStatistics() + } + @FieldResolver(() => Decimal) + async allUsers(): Promise { + return await DbUser.count({ withDeleted: true }) + } + + @FieldResolver() + async totalUsers(): Promise { + return await DbUser.count() + } + + @FieldResolver() + async deletedUsers(): Promise { + return (await this.allUsers()) - (await this.totalUsers()) + } + + @FieldResolver() + async totalGradidoCreated(): Promise { + const queryRunner = getConnection().createQueryRunner() + try { + await queryRunner.connect() + const { totalGradidoCreated } = await queryRunner.manager + .createQueryBuilder() + .select('SUM(transaction.amount) AS totalGradidoCreated') + .from(DbTransaction, 'transaction') + .where('transaction.typeId = 1') + .getRawOne() + return totalGradidoCreated + } finally { + await queryRunner.release() + } + } + + @FieldResolver() + async totalGradidoDecayed(): Promise { + const queryRunner = getConnection().createQueryRunner() + try { + await queryRunner.connect() + const { totalGradidoDecayed } = await queryRunner.manager + .createQueryBuilder() + .select('SUM(transaction.decay) AS totalGradidoDecayed') + .from(DbTransaction, 'transaction') + .where('transaction.decay IS NOT NULL') + .getRawOne() + return totalGradidoDecayed + } finally { + await queryRunner.release() + } + } + + @FieldResolver() + async dynamicStatisticsFields(): Promise { let totalGradidoAvailable: Decimal = new Decimal(0) let totalGradidoUnbookedDecayed: Decimal = new Decimal(0) const receivedCallDate = new Date() const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() + try { + await queryRunner.connect() - const lastUserTransactions = await queryRunner.manager - .createQueryBuilder(DbUser, 'user') - .select('transaction.balance', 'balance') - .addSelect('transaction.balance_date', 'balanceDate') - .innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id') - .where( - `transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`, - ) - .orderBy('transaction.balance_date', 'DESC') - .addOrderBy('transaction.id', 'DESC') - .getRawMany() + const lastUserTransactions = await queryRunner.manager + .createQueryBuilder(DbUser, 'user') + .select('transaction.balance', 'balance') + .addSelect('transaction.balance_date', 'balanceDate') + .innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id') + .where( + `transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`, + ) + .orderBy('transaction.balance_date', 'DESC') + .addOrderBy('transaction.id', 'DESC') + .getRawMany() - const activeUsers = lastUserTransactions.length + const activeUsers = lastUserTransactions.length - lastUserTransactions.forEach(({ balance, balanceDate }) => { - const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate) - if (decay) { - totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString()) - totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString()) + lastUserTransactions.forEach(({ balance, balanceDate }) => { + const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate) + if (decay) { + totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString()) + totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString()) + } + }) + + return { + activeUsers, + totalGradidoAvailable, + totalGradidoUnbookedDecayed, } - }) - - const { totalGradidoCreated } = await queryRunner.manager - .createQueryBuilder() - .select('SUM(transaction.amount) AS totalGradidoCreated') - .from(DbTransaction, 'transaction') - .where('transaction.typeId = 1') - .getRawOne() - - const { totalGradidoDecayed } = await queryRunner.manager - .createQueryBuilder() - .select('SUM(transaction.decay) AS totalGradidoDecayed') - .from(DbTransaction, 'transaction') - .where('transaction.decay IS NOT NULL') - .getRawOne() - - await queryRunner.release() - - return { - totalUsers, - activeUsers, - deletedUsers, - totalGradidoCreated, - totalGradidoDecayed, - totalGradidoAvailable, - totalGradidoUnbookedDecayed, + } finally { + await queryRunner.release() } } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 9f7d30244..50f9b0414 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -4,7 +4,7 @@ import { transactionLinkCode } from './TransactionLinkResolver' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' -import { cleanDB, testEnvironment, resetToken } from '@test/helpers' +import { cleanDB, testEnvironment, resetToken, resetEntity } from '@test/helpers' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' import { userFactory } from '@/seeds/factory/user' @@ -50,238 +50,340 @@ afterAll(async () => { }) describe('TransactionLinkResolver', () => { - // TODO: have this test separated into a transactionLink and a contributionLink part (if possible) - describe('redeem daily Contribution Link', () => { - const now = new Date() - let contributionLink: DbContributionLink | undefined - 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 () => { - await mutate({ - mutation: login, - 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 - }) - - it('does not allow the user to redeem the contribution link', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - errors: [ - new GraphQLError( - 'Creation from contribution link was not successful. Error: 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', () => { - 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( - 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 () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - errors: [ - new GraphQLError( - 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', - ), - ], - }) - }) - - describe('after one day', () => { + describe('redeemTransactionLink', () => { + describe('contributionLink', () => { + describe('input not valid', () => { beforeAll(async () => { - jest.useFakeTimers() - setTimeout(jest.fn(), 1000 * 60 * 60 * 24) - jest.runAllTimers() await mutate({ mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) - afterAll(() => { - jest.useRealTimers() - }) - - it('allows the user to redeem the contribution link again', async () => { + it('throws error when link does not exists', 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 () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), + code: 'CL-123456', }, }), ).resolves.toMatchObject({ errors: [ new GraphQLError( - 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + 'Creation from contribution link was not successful. Error: No contribution link found to given code: CL-123456', ), ], }) }) + + it('throws error when link is not valid yet', async () => { + 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: 'DAILY', + validFrom: new Date(now.getFullYear() + 1, 0, 1).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. Error: Contribution link not valid yet', + ), + ], + }) + await resetEntity(DbContributionLink) + }) + + it('throws error when contributionLink cycle is invalid', async () => { + 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. Error: Contribution link has unknown cycle', + ), + ], + }) + await resetEntity(DbContributionLink) + }) + + it('throws error when link is no longer valid', async () => { + 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: 'DAILY', + validFrom: new Date(now.getFullYear() - 1, 0, 1).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. Error: Contribution link is no longer valid', + ), + ], + }) + await resetEntity(DbContributionLink) + }) }) - }) - }) - describe('transaction links list', () => { - const variables = { - userId: 1, // dummy, may be replaced - filters: null, - currentPage: 1, - pageSize: 5, - } + // 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 - // TODO: there is a test not cleaning up after itself! Fix it! - beforeAll(async () => { - await cleanDB() - resetToken() - }) - - describe('unauthenticated', () => { - it('returns an error', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables, - }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [new GraphQLError('401 Unauthorized')], - }), - ) - }) - }) - - describe('authenticated', () => { - describe('without admin rights', () => { beforeAll(async () => { - user = await userFactory(testEnv, bibiBloxberg) await mutate({ mutation: login, - variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + 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, + }, }) }) - afterAll(async () => { - await cleanDB() - resetToken() + 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 () => { + await mutate({ + mutation: login, + 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 + }) + + it('does not allow the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: 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', () => { + 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( + 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 () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. 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 () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + }) + }) + }) + }) + + describe('transaction links list', () => { + const variables = { + userId: 1, // dummy, may be replaced + filters: null, + currentPage: 1, + pageSize: 5, + } + + // TODO: there is a test not cleaning up after itself! Fix it! + beforeAll(async () => { + await cleanDB() + resetToken() + }) + + describe('unauthenticated', () => { it('returns an error', async () => { await expect( query({ @@ -296,40 +398,22 @@ describe('TransactionLinkResolver', () => { }) }) - describe('with admin rights', () => { - beforeAll(async () => { - // admin 'peter@lustig.de' has to exists for 'creationFactory' - await userFactory(testEnv, peterLustig) - - user = await userFactory(testEnv, bibiBloxberg) - variables.userId = user.id - variables.pageSize = 25 - // bibi needs GDDs - const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await creationFactory(testEnv, bibisCreation!) - // bibis transaktion links - const bibisTransaktionLinks = transactionLinks.filter( - (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', - ) - for (let i = 0; i < bibisTransaktionLinks.length; i++) { - await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) - } - - // admin: only now log in - await mutate({ - mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) }) - }) - afterAll(async () => { - await cleanDB() - resetToken() - }) + afterAll(async () => { + await cleanDB() + resetToken() + }) - describe('without any filters', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { + it('returns an error', async () => { await expect( query({ query: listTransactionLinksAdmin, @@ -337,185 +421,235 @@ describe('TransactionLinkResolver', () => { }), ).resolves.toEqual( expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, + errors: [new GraphQLError('401 Unauthorized')], }), ) }) }) - describe('all filters are null', () => { - it('finds 6 open transaction links and no deleted or redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: null, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.not.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) - }) - }) + describe('with admin rights', () => { + beforeAll(async () => { + // admin 'peter@lustig.de' has to exists for 'creationFactory' + await userFactory(testEnv, peterLustig) - describe('filter with deleted', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), + user = await userFactory(testEnv, bibiBloxberg) + variables.userId = user.id + variables.pageSize = 25 + // bibi needs GDDs + const bibisCreation = creations.find( + (creation) => creation.email === 'bibi@bloxberg.de', ) - }) - }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + // bibis transaktion links + const bibisTransaktionLinks = transactionLinks.filter( + (transactionLink) => transactionLink.email === 'bibi@bloxberg.de', + ) + for (let i = 0; i < bibisTransaktionLinks.length; i++) { + await transactionLinkFactory(testEnv, bibisTransaktionLinks[i]) + } - describe('filter by expired', () => { - it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withExpired: true, - }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 7, - linkList: expect.arrayContaining([ - expect.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), - }, - }, - }), - ) + // admin: only now log in + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) }) - }) - // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory - describe.skip('filter by redeemed', () => { - it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { - await expect( - query({ - query: listTransactionLinksAdmin, - variables: { - ...variables, - filters: { - withDeleted: null, - withExpired: null, - withRedeemed: true, + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, }, - }, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - listTransactionLinksAdmin: { - linkCount: 6, - linkList: expect.arrayContaining([ - expect.not.objectContaining({ - memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', - createdAt: expect.any(String), - }), - expect.objectContaining({ - memo: 'Yeah, eingelöst!', - redeemedAt: expect.any(String), - redeemedBy: expect.any(Number), - }), - expect.not.objectContaining({ - memo: 'Da habe ich mich wohl etwas übernommen.', - deletedAt: expect.any(String), - }), - ]), + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds 6 open transaction links and no deleted or redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: null, + }, }, - }, - }), - ) + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.not.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter with deleted', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + describe('filter by expired', () => { + it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withExpired: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 7, + linkList: expect.arrayContaining([ + expect.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) + }) + + // TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory + describe.skip('filter by redeemed', () => { + it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { + await expect( + query({ + query: listTransactionLinksAdmin, + variables: { + ...variables, + filters: { + withDeleted: null, + withExpired: null, + withRedeemed: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listTransactionLinksAdmin: { + linkCount: 6, + linkList: expect.arrayContaining([ + expect.not.objectContaining({ + memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', + createdAt: expect.any(String), + }), + expect.objectContaining({ + memo: 'Yeah, eingelöst!', + redeemedAt: expect.any(String), + redeemedBy: expect.any(Number), + }), + expect.not.objectContaining({ + memo: 'Da habe ich mich wohl etwas übernommen.', + deletedAt: expect.any(String), + }), + ]), + }, + }, + }), + ) + }) }) }) }) }) }) -}) -describe('transactionLinkCode', () => { - const date = new Date() + describe('transactionLinkCode', () => { + const date = new Date() - it('returns a string of length 24', () => { - expect(transactionLinkCode(date)).toHaveLength(24) - }) + it('returns a string of length 24', () => { + expect(transactionLinkCode(date)).toHaveLength(24) + }) - it('returns a string that ends with the hex value of date', () => { - const regexp = new RegExp(date.getTime().toString(16) + '$') - expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) + it('returns a string that ends with the hex value of date', () => { + const regexp = new RegExp(date.getTime().toString(16) + '$') + expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) + }) }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 897cf9252..df70b4bc9 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -170,148 +170,154 @@ export class TransactionLinkResolver { if (code.match(/^CL-/)) { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - logger.info('redeem contribution link...') - const now = new Date() - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('REPEATABLE READ') try { - const contributionLink = await queryRunner.manager - .createQueryBuilder() - .select('contributionLink') - .from(DbContributionLink, 'contributionLink') - .where('contributionLink.code = :code', { code: code.replace('CL-', '') }) - .getOne() - if (!contributionLink) { - logger.error('no contribution link found to given code:', code) - throw new Error('No contribution link found') - } - logger.info('...contribution link found with id', contributionLink.id) - if (new Date(contributionLink.validFrom).getTime() > now.getTime()) { - logger.error( - 'contribution link is not valid yet. Valid from: ', - contributionLink.validFrom, - ) - throw new Error('Contribution link not valid yet') - } - if (contributionLink.validTo) { - if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) { - logger.error('contribution link is depricated. Valid to: ', contributionLink.validTo) - throw new Error('Contribution link is depricated') + logger.info('redeem contribution link...') + const now = new Date() + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('REPEATABLE READ') + try { + const contributionLink = await queryRunner.manager + .createQueryBuilder() + .select('contributionLink') + .from(DbContributionLink, 'contributionLink') + .where('contributionLink.code = :code', { code: code.replace('CL-', '') }) + .getOne() + if (!contributionLink) { + logger.error('no contribution link found to given code:', code) + throw new Error(`No contribution link found to given code: ${code}`) } - } - let alreadyRedeemed: DbContribution | undefined - switch (contributionLink.cycle) { - case ContributionCycleType.ONCE: { - alreadyRedeemed = await queryRunner.manager - .createQueryBuilder() - .select('contribution') - .from(DbContribution, 'contribution') - .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { - linkId: contributionLink.id, - id: user.id, - }) - .getOne() - if (alreadyRedeemed) { + logger.info('...contribution link found with id', contributionLink.id) + if (new Date(contributionLink.validFrom).getTime() > now.getTime()) { + logger.error( + 'contribution link is not valid yet. Valid from: ', + contributionLink.validFrom, + ) + throw new Error('Contribution link not valid yet') + } + if (contributionLink.validTo) { + if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) { logger.error( - 'contribution link with rule ONCE already redeemed by user with id', - user.id, + 'contribution link is no longer valid. Valid to: ', + contributionLink.validTo, ) - throw new Error('Contribution link already redeemed') + throw new Error('Contribution link is no longer valid') } - break } - case ContributionCycleType.DAILY: { - const start = new Date() - start.setHours(0, 0, 0, 0) - const end = new Date() - end.setHours(23, 59, 59, 999) - alreadyRedeemed = await queryRunner.manager - .createQueryBuilder() - .select('contribution') - .from(DbContribution, 'contribution') - .where( - `contribution.contributionLinkId = :linkId AND contribution.userId = :id - AND Date(contribution.confirmedAt) BETWEEN :start AND :end`, - { + let alreadyRedeemed: DbContribution | undefined + switch (contributionLink.cycle) { + case ContributionCycleType.ONCE: { + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { linkId: contributionLink.id, id: user.id, - start, - end, - }, - ) - .getOne() - if (alreadyRedeemed) { - logger.error( - 'contribution link with rule DAILY already redeemed by user with id', - user.id, - ) - throw new Error('Contribution link already redeemed today') + }) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule ONCE already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed') + } + break + } + case ContributionCycleType.DAILY: { + const start = new Date() + start.setHours(0, 0, 0, 0) + const end = new Date() + end.setHours(23, 59, 59, 999) + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where( + `contribution.contributionLinkId = :linkId AND contribution.userId = :id + AND Date(contribution.confirmedAt) BETWEEN :start AND :end`, + { + linkId: contributionLink.id, + id: user.id, + start, + end, + }, + ) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule DAILY already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed today') + } + break + } + default: { + logger.error('contribution link has unknown cycle', contributionLink.cycle) + throw new Error('Contribution link has unknown cycle') } - break } - default: { - logger.error('contribution link has unknown cycle', contributionLink.cycle) - throw new Error('Contribution link has unknown cycle') + + const creations = await getUserCreation(user.id, clientTimezoneOffset) + logger.info('open creations', creations) + validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) + const contribution = new DbContribution() + contribution.userId = user.id + contribution.createdAt = now + contribution.contributionDate = now + contribution.memo = contributionLink.memo + contribution.amount = contributionLink.amount + contribution.contributionLinkId = contributionLink.id + contribution.contributionType = ContributionType.LINK + contribution.contributionStatus = ContributionStatus.CONFIRMED + + await queryRunner.manager.insert(DbContribution, contribution) + + const lastTransaction = await queryRunner.manager + .createQueryBuilder() + .select('transaction') + .from(DbTransaction, 'transaction') + .where('transaction.userId = :id', { id: user.id }) + .orderBy('transaction.id', 'DESC') + .getOne() + let newBalance = new Decimal(0) + + let decay: Decay | null = null + if (lastTransaction) { + decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) + newBalance = decay.balance } + newBalance = newBalance.add(contributionLink.amount.toString()) + + const transaction = new DbTransaction() + transaction.typeId = TransactionTypeId.CREATION + transaction.memo = contribution.memo + transaction.userId = contribution.userId + transaction.previous = lastTransaction ? lastTransaction.id : null + transaction.amount = contribution.amount + transaction.creationDate = contribution.contributionDate + transaction.balance = newBalance + transaction.balanceDate = now + transaction.decay = decay ? decay.decay : new Decimal(0) + transaction.decayStart = decay ? decay.start : null + await queryRunner.manager.insert(DbTransaction, transaction) + + contribution.confirmedAt = now + contribution.transactionId = transaction.id + await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) + + await queryRunner.commitTransaction() + logger.info('creation from contribution link commited successfuly.') + } catch (e) { + await queryRunner.rollbackTransaction() + logger.error(`Creation from contribution link was not successful: ${e}`) + throw new Error(`Creation from contribution link was not successful. ${e}`) + } finally { + await queryRunner.release() } - - const creations = await getUserCreation(user.id, clientTimezoneOffset) - logger.info('open creations', creations) - validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) - const contribution = new DbContribution() - contribution.userId = user.id - contribution.createdAt = now - contribution.contributionDate = now - contribution.memo = contributionLink.memo - contribution.amount = contributionLink.amount - contribution.contributionLinkId = contributionLink.id - contribution.contributionType = ContributionType.LINK - contribution.contributionStatus = ContributionStatus.CONFIRMED - - await queryRunner.manager.insert(DbContribution, contribution) - - const lastTransaction = await queryRunner.manager - .createQueryBuilder() - .select('transaction') - .from(DbTransaction, 'transaction') - .where('transaction.userId = :id', { id: user.id }) - .orderBy('transaction.id', 'DESC') - .getOne() - let newBalance = new Decimal(0) - - let decay: Decay | null = null - if (lastTransaction) { - decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) - newBalance = decay.balance - } - newBalance = newBalance.add(contributionLink.amount.toString()) - - const transaction = new DbTransaction() - transaction.typeId = TransactionTypeId.CREATION - transaction.memo = contribution.memo - transaction.userId = contribution.userId - transaction.previous = lastTransaction ? lastTransaction.id : null - transaction.amount = contribution.amount - transaction.creationDate = contribution.contributionDate - transaction.balance = newBalance - transaction.balanceDate = now - transaction.decay = decay ? decay.decay : new Decimal(0) - transaction.decayStart = decay ? decay.start : null - await queryRunner.manager.insert(DbTransaction, transaction) - - contribution.confirmedAt = now - contribution.transactionId = transaction.id - await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution) - - await queryRunner.commitTransaction() - logger.info('creation from contribution link commited successfuly.') - } catch (e) { - await queryRunner.rollbackTransaction() - logger.error(`Creation from contribution link was not successful: ${e}`) - throw new Error(`Creation from contribution link was not successful. ${e}`) } finally { - await queryRunner.release() releaseLock() } return true diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 33914583e..2f97596b2 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -45,29 +45,28 @@ export const executeTransaction = async ( recipient: dbUser, transactionLink?: dbTransactionLink | null, ): Promise => { - logger.info( - `executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`, - ) - - if (sender.id === recipient.id) { - logger.error(`Sender and Recipient are the same.`) - throw new Error('Sender and Recipient are the same.') - } - - 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)`) - } - - if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) - throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) - } - // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() - try { + logger.info( + `executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`, + ) + + if (sender.id === recipient.id) { + logger.error(`Sender and Recipient are the same.`) + throw new Error('Sender and Recipient are the same.') + } + + 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)`) + } + + if (memo.length < MEMO_MIN_CHARS) { + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) + throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) + } + // validate amount const receivedCallDate = new Date() const sendBalance = await calculateBalance( @@ -187,10 +186,10 @@ export const executeTransaction = async ( }) } logger.info(`finished executeTransaction successfully`) - return true } finally { releaseLock() } + return true } @Resolver() diff --git a/database/package.json b/database/package.json index 0c69941b4..f4e1c7e84 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.16.0", + "version": "1.17.1", "description": "Gradido Database Tool to execute database migrations", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/database", diff --git a/frontend/package.json b/frontend/package.json index 9c9f47e53..02a6aa2f0 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.16.0", + "version": "1.17.1", "private": true, "scripts": { "start": "node run/server.js", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1933e380b..3ec57ca70 100755 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,7 +30,7 @@ export default { font-family: 'WorkSans', sans-serif !important; } .appContent { - min-width: 360px; + min-width: 330px; max-width: 1320px; margin-right: auto; margin-left: auto; diff --git a/frontend/src/assets/News/news.json b/frontend/src/assets/News/news.json index 284fc711c..9246be2e3 100644 --- a/frontend/src/assets/News/news.json +++ b/frontend/src/assets/News/news.json @@ -18,7 +18,7 @@ "date": "01 janvier 2023", "text": "Compte Gradido 2023 : nouveau design et communautés décentralisées", "url": "https://gradido.net/fr/gradido-konto-2023-neues-design-und-dezentrale-communities/", - "extra": "Ce sont souvent les personnes les plus discrètes qui créent silencieusement, avec application et passion, les bases de grands développements. Notre site Développeur ont effectué ces derniers mois un travail préparatoire formidable qui sera mis à profit en 2023." + "extra": "Ce sont souvent les personnes les plus discrètes qui créent silencieusement, avec application et passion, les bases de grands développements. Nos développeurs ont effectués ces derniers mois un travail préparatoire formidable qui sera mis à profit en 2023." }, { "locale": "es", diff --git a/frontend/src/assets/scss/gradido-template.scss b/frontend/src/assets/scss/gradido-template.scss index 59b461dce..9edb72a4f 100644 --- a/frontend/src/assets/scss/gradido-template.scss +++ b/frontend/src/assets/scss/gradido-template.scss @@ -11,7 +11,7 @@ body { .bg-gradient { background: rgb(4 112 6); - background: linear-gradient(90deg, rgb(4 112 6 / 100%) 73%, rgb(197 141 56 / 100%) 100%); + background: linear-gradient(90deg, rgb(4 112 6 / 100%) 22%, rgb(197 141 56 / 100%) 98%); color: white; } diff --git a/frontend/src/components/Auth/AuthNavbar.vue b/frontend/src/components/Auth/AuthNavbar.vue index ade6340a6..7d8c6f08a 100644 --- a/frontend/src/components/Auth/AuthNavbar.vue +++ b/frontend/src/components/Auth/AuthNavbar.vue @@ -53,6 +53,7 @@ export default { .auth-header { font-family: 'Open Sans', sans-serif !important; + height: 150px; } .sheet-img { @@ -61,6 +62,17 @@ export default { max-width: 64%; } +@media screen and (max-width: 1024px) { + .auth-header { + height: 100px; + } +} + +@media screen and (max-width: 768px) { + .auth-header { + height: 70px; + } +} @media screen and (max-width: 450px) { .sheet-img { top: -15px; diff --git a/frontend/src/components/ClipboardCopy.vue b/frontend/src/components/ClipboardCopy.vue index 7c4e014ec..4374f7182 100644 --- a/frontend/src/components/ClipboardCopy.vue +++ b/frontend/src/components/ClipboardCopy.vue @@ -1,17 +1,31 @@ -
{{ $t('lastMonth') }}
-
- - +
+
+ + - + - + - - + + +
{ const mocks = { $t: jest.fn((t) => t), + $n: jest.fn((n) => n), $i18n: { locale: jest.fn(() => 'en'), }, - $n: jest.fn((n) => String(n)), $route: { params: {}, }, @@ -46,13 +46,14 @@ describe('InputAmount', () => { describe('amount normalization', () => { describe('if invalid', () => { - beforeEach(() => { + beforeEach(async () => { + await wrapper.setProps({ value: '12m34' }) valid = false }) it('is not normalized', () => { - wrapper.vm.normalizeAmount(valid) - expect(wrapper.vm.amountValue).toBe(0.0) + wrapper.vm.normalizeAmount(false) + expect(wrapper.vm.currentValue).toBe('12m34') }) }) @@ -97,13 +98,14 @@ describe('InputAmount', () => { describe('amount normalization', () => { describe('if invalid', () => { - beforeEach(() => { + beforeEach(async () => { + await wrapper.setProps({ value: '12m34' }) valid = false }) it('is not normalized', () => { wrapper.vm.normalizeAmount(valid) - expect(wrapper.vm.amountValue).toBe(0.0) + expect(wrapper.vm.currentValue).toBe('12m34') }) }) diff --git a/frontend/src/components/Inputs/InputAmount.vue b/frontend/src/components/Inputs/InputAmount.vue index 7a72a3a77..3268a0ec1 100644 --- a/frontend/src/components/Inputs/InputAmount.vue +++ b/frontend/src/components/Inputs/InputAmount.vue @@ -20,7 +20,7 @@ trim v-focus="amountFocused" @focus="amountFocused = true" - @blur="normalizeAmount(true)" + @blur="normalizeAmount(valid)" :disabled="disabled" autocomplete="off" > @@ -90,5 +90,8 @@ export default { this.currentValue = this.$n(this.amountValue, 'ungroupedDecimal') }, }, + mounted() { + if (this.value !== '') this.normalizeAmount(true) + }, } diff --git a/frontend/src/components/Inputs/InputHour.spec.js b/frontend/src/components/Inputs/InputHour.spec.js index e47a7aa66..293c8a332 100644 --- a/frontend/src/components/Inputs/InputHour.spec.js +++ b/frontend/src/components/Inputs/InputHour.spec.js @@ -74,7 +74,7 @@ describe('InputHour', () => { it('emits input with new value', async () => { await wrapper.find('input').setValue('12') expect(wrapper.emitted('input')).toBeTruthy() - expect(wrapper.emitted('input')).toEqual([['12']]) + expect(wrapper.emitted('input')).toEqual([[12]]) }) }) diff --git a/frontend/src/components/Inputs/InputHour.vue b/frontend/src/components/Inputs/InputHour.vue index 10fc00363..b6e685865 100644 --- a/frontend/src/components/Inputs/InputHour.vue +++ b/frontend/src/components/Inputs/InputHour.vue @@ -15,7 +15,7 @@ :placeholder="placeholder" type="number" :state="validated ? valid : false" - step="0.5" + step="0.25" min="0" :max="validMaxTime" class="bg-248" @@ -32,11 +32,11 @@ export default { type: Object, default: () => {}, }, - name: { type: String, required: true, default: 'Time' }, - label: { type: String, required: true, default: 'Time' }, - placeholder: { type: String, required: true, default: 'Time' }, + name: { type: String, required: true }, + label: { type: String, required: true }, + placeholder: { type: String, required: true }, value: { type: Number, required: true, default: 0 }, - validMaxTime: { type: Number, required: true, default: 0 }, + validMaxTime: { type: Number, required: true }, }, data() { return { @@ -50,7 +50,7 @@ export default { }, watch: { currentValue() { - this.$emit('input', this.currentValue) + this.$emit('input', Number(this.currentValue)) }, value() { if (this.value !== this.currentValue) this.currentValue = this.value diff --git a/frontend/src/components/Inputs/InputTextarea.vue b/frontend/src/components/Inputs/InputTextarea.vue index e8dcf2840..3881c4b16 100644 --- a/frontend/src/components/Inputs/InputTextarea.vue +++ b/frontend/src/components/Inputs/InputTextarea.vue @@ -18,6 +18,7 @@ rows="4" max-rows="4" :disabled="disabled" + no-resize > {{ errors[0] }} diff --git a/frontend/src/components/Menu/Navbar.vue b/frontend/src/components/Menu/Navbar.vue index 309e3ffd5..03217a5ae 100644 --- a/frontend/src/components/Menu/Navbar.vue +++ b/frontend/src/components/Menu/Navbar.vue @@ -1,64 +1,49 @@ @@ -91,6 +76,10 @@ export default { diff --git a/frontend/src/components/Menu/Sidebar.vue b/frontend/src/components/Menu/Sidebar.vue index 864d42906..9388b0e64 100644 --- a/frontend/src/components/Menu/Sidebar.vue +++ b/frontend/src/components/Menu/Sidebar.vue @@ -1,6 +1,11 @@