diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34ebeff11..dc9e1d513 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: gradido test CI -on: [push] +on: push jobs: ############################################################################## @@ -308,7 +308,7 @@ jobs: # JOB: LOCALES ADMIN ######################################################### ############################################################################## locales_admin: - name: Locales - Admin + name: Locales - Admin Interface runs-on: ubuntu-latest needs: [build_test_admin] steps: @@ -553,3 +553,110 @@ jobs: run: docker-compose -f docker-compose.yml run -T database yarn up - name: database | reset run: docker-compose -f docker-compose.yml run -T database yarn reset + + ############################################################################## + # JOB: END-TO-END TESTS ##################################################### + ############################################################################## + end-to-end-tests: + name: End-to-End Tests + runs-on: ubuntu-latest + # needs: [build_test_mariadb, build_test_database_up, build_test_backend, build_test_admin, build_test_frontend, build_test_nginx] + steps: + ########################################################################## + # CHECKOUT CODE ########################################################## + ########################################################################## + - name: Checkout code + uses: actions/checkout@v3 + ########################################################################## + # DOWNLOAD DOCKER IMAGES ################################################# + ########################################################################## + # - name: Download Docker Image (Mariadb) + # uses: actions/download-artifact@v3 + # with: + # name: docker-mariadb-test + # path: /tmp + # - name: Load Docker Image (Mariadb) + # run: docker load < /tmp/mariadb.tar + # - name: Download Docker Image (Database Up) + # uses: actions/download-artifact@v3 + # with: + # name: docker-database-test_up + # path: /tmp + # - name: Load Docker Image (Database Up) + # run: docker load < /tmp/database_up.tar + # - name: Download Docker Image (Backend) + # uses: actions/download-artifact@v3 + # with: + # name: docker-backend-test + # path: /tmp + # - name: Load Docker Image (Backend) + # run: docker load < /tmp/backend.tar + # - name: Download Docker Image (Frontend) + # uses: actions/download-artifact@v3 + # with: + # name: docker-frontend-test + # path: /tmp + # - name: Load Docker Image (Frontend) + # run: docker load < /tmp/frontend.tar + # - name: Download Docker Image (Admin Interface) + # uses: actions/download-artifact@v3 + # with: + # name: docker-admin-test + # path: /tmp + # - name: Load Docker Image (Admin Interface) + # run: docker load < /tmp/admin.tar + # - name: Download Docker Image (Nginx) + # uses: actions/download-artifact@v3 + # with: + # name: docker-nginx-test + # path: /tmp + # - name: Load Docker Image (Nginx) + # run: docker load < /tmp/nginx.tar + ########################################################################## + # BOOT UP THE TEST SYSTEM ################################################ + ########################################################################## + - name: Boot up test system | docker-compose mariadb + run: docker-compose up --detach mariadb + + - name: Sleep for 30 seconds + run: sleep 30s + + - name: Boot up test system | docker-compose database + run: docker-compose up --detach --no-deps database + + - name: Boot up test system | docker-compose backend + run: docker-compose up --detach --no-deps backend + + - name: Sleep for 90 seconds + run: sleep 90s + + - name: Boot up test system | seed backend + run: | + sudo chown runner:docker -R * + cd database + yarn && yarn dev_reset + cd ../backend + yarn && yarn seed + cd .. + + - name: Boot up test system | docker-compose frontends + run: docker-compose up --detach --no-deps frontend admin nginx + + - name: Sleep for 2.5 minutes + run: sleep 150s + + ########################################################################## + # END-TO-END TESTS ####################################################### + ########################################################################## + - name: End-to-end tests | run tests + id: e2e-tests + run: | + cd e2e-tests/cypress/tests/ + yarn + yarn run cypress run --spec cypress/e2e/User.Authentication.feature + - name: End-to-end tests | if tests failed, upload screenshots + if: steps.e2e-tests.outcome == 'failure' + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots + path: /home/runner/work/gradido/gradido/e2e-tests/cypress/tests/cypress/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4eb48283..754566658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,37 @@ 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.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3) + +- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312) +- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302) +- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320) +- bug: 2295 remove horizontal scrollbar in admin overview [`#2311`](https://github.com/gradido/gradido/pull/2311) +- 2292 community information contact [`#2313`](https://github.com/gradido/gradido/pull/2313) +- bug: 2315 Contribution Month and TEST FAIL in MASTER [`#2316`](https://github.com/gradido/gradido/pull/2316) +- 2291 add button for close contribution messages box [`#2314`](https://github.com/gradido/gradido/pull/2314) + +#### [1.13.2](https://github.com/gradido/gradido/compare/1.13.1...1.13.2) + +> 28 October 2022 + +- release: Version 1.13.2 [`#2307`](https://github.com/gradido/gradido/pull/2307) +- fix: 🍰 Links In Contribution Messages Target Blank [`#2306`](https://github.com/gradido/gradido/pull/2306) +- fix: Link in Contribution Messages [`#2305`](https://github.com/gradido/gradido/pull/2305) +- Refactor: 🍰 Change the query so that we only look on the ``contributions`` table. [`#2217`](https://github.com/gradido/gradido/pull/2217) +- Refactor: Admin Resolver Events and Logging [`#2244`](https://github.com/gradido/gradido/pull/2244) +- contibution messages, links are recognised [`#2248`](https://github.com/gradido/gradido/pull/2248) +- fix: Include Deleted Email Contacts in User Search [`#2281`](https://github.com/gradido/gradido/pull/2281) +- fix: Pagination Contributions jumps to wrong Page [`#2284`](https://github.com/gradido/gradido/pull/2284) +- fix: Changed some texts in E-Mails and Frontend [`#2276`](https://github.com/gradido/gradido/pull/2276) +- Feat: 🍰 Add `deletedBy` To Contributions And Admin Can Not Delete Own User Contribution [`#2236`](https://github.com/gradido/gradido/pull/2236) +- deleted contributions are displayed to the user [`#2277`](https://github.com/gradido/gradido/pull/2277) + #### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1) +> 20 October 2022 + +- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279) - Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273) - Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231) diff --git a/admin/package.json b/admin/package.json index 2db889771..370f504b8 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.13.1", + "version": "1.13.3", "license": "Apache-2.0", "private": false, "scripts": { diff --git a/admin/src/components/ContentFooter.vue b/admin/src/components/ContentFooter.vue index bab3f5d12..a875100f6 100644 --- a/admin/src/components/ContentFooter.vue +++ b/admin/src/components/ContentFooter.vue @@ -1,7 +1,7 @@ diff --git a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js index b8aaba502..c1a4e65c6 100644 --- a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js +++ b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js @@ -125,4 +125,68 @@ describe('ContributionMessagesListItem', () => { }) }) }) + + describe('links in contribtion message', () => { + const propsData = { + message: { + id: 111, + message: 'Lorem ipsum?', + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'DIALOG', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const ModeratorItemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('message of only one link', () => { + beforeEach(() => { + propsData.message.message = 'https://gradido.net/de/' + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toBe('https://gradido.net/de/') + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + + describe('message with text and two links', () => { + beforeEach(() => { + propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido` + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)') + }) + + it('contains the whole text', () => { + expect(messageField.text()) + .toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido`) + }) + + it('contains the two links', () => { + expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/') + expect(messageField.findAll('a').at(1).attributes('href')).toBe( + 'https://github.com/gradido/gradido', + ) + }) + }) + }) }) diff --git a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue index 796ff5f30..30960bd33 100644 --- a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue +++ b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue @@ -1,23 +1,28 @@ diff --git a/admin/src/pages/Overview.spec.js b/admin/src/pages/Overview.spec.js index 1861c5330..affd018a7 100644 --- a/admin/src/pages/Overview.spec.js +++ b/admin/src/pages/Overview.spec.js @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils' import Overview from './Overview.vue' -import { listContributionLinks } from '@/graphql/listContributionLinks.js' import { communityStatistics } from '@/graphql/communityStatistics.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' @@ -36,27 +35,6 @@ const apolloQueryMock = jest }, }, }) - .mockResolvedValueOnce({ - data: { - listContributionLinks: { - links: [ - { - id: 1, - name: 'Meditation', - memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l', - amount: '200', - validFrom: '2022-04-01', - validTo: '2022-08-01', - cycle: 'täglich', - maxPerCycle: '3', - maxAmountPerMonth: 0, - link: 'https://localhost/redeem/CL-1a2345678', - }, - ], - count: 1, - }, - }, - }) .mockResolvedValue({ data: { listUnconfirmedContributions: [ @@ -118,14 +96,6 @@ describe('Overview', () => { ) }) - it('calls listContributionLinks', () => { - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ - query: listContributionLinks, - }), - ) - }) - it('commits three pending creations to store', () => { expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3) }) diff --git a/admin/src/pages/Overview.vue b/admin/src/pages/Overview.vue index cfa247b8e..57bf7ff8c 100644 --- a/admin/src/pages/Overview.vue +++ b/admin/src/pages/Overview.vue @@ -28,31 +28,21 @@ - diff --git a/admin/src/router/router.test.js b/admin/src/router/router.test.js index eb9b646cb..22273c15b 100644 --- a/admin/src/router/router.test.js +++ b/admin/src/router/router.test.js @@ -45,7 +45,7 @@ describe('router', () => { describe('routes', () => { it('has seven routes defined', () => { - expect(routes).toHaveLength(7) + expect(routes).toHaveLength(8) }) it('has "/overview" as default', async () => { @@ -81,6 +81,13 @@ describe('router', () => { }) }) + describe('contribution-links', () => { + it('loads the "ContributionLinks" component', async () => { + const component = await routes.find((r) => r.path === '/contribution-links').component() + expect(component.default.name).toBe('ContributionLinks') + }) + }) + describe('not found page', () => { it('renders the "NotFound" component', async () => { const component = await routes.find((r) => r.path === '*').component() diff --git a/admin/src/router/routes.js b/admin/src/router/routes.js index 72e7b1ac5..ee82f128e 100644 --- a/admin/src/router/routes.js +++ b/admin/src/router/routes.js @@ -23,6 +23,10 @@ const routes = [ path: '/creation-confirm', component: () => import('@/pages/CreationConfirm.vue'), }, + { + path: '/contribution-links', + component: () => import('@/pages/ContributionLinks.vue'), + }, { path: '*', component: () => import('@/components/NotFoundPage.vue'), diff --git a/backend/package.json b/backend/package.json index 6cd886735..77d3b8064 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.13.1", + "version": "1.13.3", "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/event/Event.ts b/backend/src/event/Event.ts index d86ddf15f..09a31d4e0 100644 --- a/backend/src/event/Event.ts +++ b/backend/src/event/Event.ts @@ -66,6 +66,9 @@ export class EventTransactionCreation extends EventBasicTx {} export class EventTransactionReceive extends EventBasicTxX {} export class EventTransactionReceiveRedeem extends EventBasicTxX {} export class EventContributionCreate extends EventBasicCt {} +export class EventAdminContributionCreate extends EventBasicCt {} +export class EventAdminContributionDelete extends EventBasicCt {} +export class EventAdminContributionUpdate extends EventBasicCt {} export class EventUserCreateContributionMessage extends EventBasicCtMsg {} export class EventAdminCreateContributionMessage extends EventBasicCtMsg {} export class EventContributionDelete extends EventBasicCt {} @@ -74,6 +77,14 @@ export class EventContributionConfirm extends EventBasicCtX {} export class EventContributionDeny extends EventBasicCtX {} export class EventContributionLinkDefine extends EventBasicCt {} export class EventContributionLinkActivateRedeem extends EventBasicCt {} +export class EventDeleteUser extends EventBasicUserId {} +export class EventUndeleteUser extends EventBasicUserId {} +export class EventChangeUserRole extends EventBasicUserId {} +export class EventAdminUpdateContribution extends EventBasicCt {} +export class EventAdminDeleteContribution extends EventBasicCt {} +export class EventCreateContributionLink extends EventBasicCt {} +export class EventDeleteContributionLink extends EventBasicCt {} +export class EventUpdateContributionLink extends EventBasicCt {} export class Event { constructor() @@ -289,6 +300,27 @@ export class Event { return this } + public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_CREATE + + return this + } + + public setEventAdminContributionDelete(ev: EventAdminContributionDelete): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_DELETE + + return this + } + + public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE + + return this + } + public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event { this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE @@ -345,6 +377,62 @@ export class Event { return this } + public setEventDeleteUser(ev: EventDeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.DELETE_USER + + return this + } + + public setEventUndeleteUser(ev: EventUndeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.UNDELETE_USER + + return this + } + + public setEventChangeUserRole(ev: EventChangeUserRole): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.CHANGE_USER_ROLE + + return this + } + + public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION + + return this + } + + public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION + + return this + } + + public setEventCreateContributionLink(ev: EventCreateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK + + return this + } + + public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK + + return this + } + + public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK + + return this + } + setByBasicUser(userId: number): Event { this.setEventBasic() this.userId = userId diff --git a/backend/src/event/EventProtocolType.ts b/backend/src/event/EventProtocolType.ts index d53eb6961..b7c2f0151 100644 --- a/backend/src/event/EventProtocolType.ts +++ b/backend/src/event/EventProtocolType.ts @@ -33,6 +33,17 @@ export enum EventProtocolType { CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', + ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE', + ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE', + ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE', USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE', ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE', + DELETE_USER = 'DELETE_USER', + UNDELETE_USER = 'UNDELETE_USER', + CHANGE_USER_ROLE = 'CHANGE_USER_ROLE', + ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION', + ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION', + CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK', + DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK', + UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK', } diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 281f2b99b..b5711cd57 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -42,6 +42,9 @@ import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { EventProtocol } from '@entity/EventProtocol' +import { EventProtocolType } from '@/event/EventProtocolType' +import { logger } from '@test/testSetup' // mock account activation email to avoid console spam jest.mock('@/mailer/sendAccountActivationEmail', () => { @@ -144,6 +147,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('change role with success', () => { @@ -196,6 +203,9 @@ describe('AdminResolver', () => { }), ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Administrator can not change his own role!') + }) }) describe('user has already role to be set', () => { @@ -213,6 +223,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already admin!') + }) }) describe('to usual user', () => { @@ -229,6 +243,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already a usual user!') + }) }) }) }) @@ -297,6 +315,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('delete self', () => { @@ -309,6 +331,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') + }) }) describe('delete with success', () => { @@ -338,6 +364,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) + }) }) }) }) @@ -405,6 +435,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('user to undelete is not deleted', () => { @@ -422,6 +456,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is not deleted') + }) + describe('undelete deleted user', () => { beforeAll(async () => { await mutate({ mutation: deleteUser, variables: { userId: user.id } }) @@ -909,6 +947,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find user with email: bibi@bloxberg.de', + ) + }) }) describe('user to create for is deleted', () => { @@ -928,6 +972,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'This user was deleted. Cannot create a contribution.', + ) + }) }) describe('user to create for has email not confirmed', () => { @@ -947,6 +997,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Contribution could not be saved, Email is not activated', + ) + }) }) describe('valid user to create for', () => { @@ -967,6 +1023,13 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + 'Invalid Date', + ) + }) }) describe('date of creation is four months ago', () => { @@ -987,6 +1050,13 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + variables.creationDate, + ) + }) }) describe('date of creation is in the future', () => { @@ -1007,6 +1077,13 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + variables.creationDate, + ) + }) }) describe('amount of creation is too high', () => { @@ -1024,6 +1101,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) describe('creation is valid', () => { @@ -1039,6 +1122,15 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, + userId: admin.id, + }), + ) + }) }) describe('second creation surpasses the available amount ', () => { @@ -1056,6 +1148,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ) + }) }) }) }) @@ -1134,6 +1232,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find UserContact with email: bob@baumeister.de', + ) + }) }) describe('user for creation to update is deleted', () => { @@ -1155,6 +1259,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') + }) }) describe('creation does not exist', () => { @@ -1176,6 +1284,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id.') + }) }) describe('user email does not match creation user', () => { @@ -1188,7 +1300,9 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1201,11 +1315,17 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) }) describe('creation update is not valid', () => { // as this test has not clearly defined that date, it is a false positive - it.skip('throws an error', async () => { + it('throws an error', async () => { await expect( mutate({ mutation: adminUpdateContribution, @@ -1214,24 +1334,32 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(1900), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( expect.objectContaining({ errors: [ new GraphQLError( - 'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.', + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', ), ], }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) - describe('creation update is successful changing month', () => { + describe.skip('creation update is successful changing month', () => { // skipped as changing the month is currently disable - it.skip('returns update creation object', async () => { + it('returns update creation object', async () => { await expect( mutate({ mutation: adminUpdateContribution, @@ -1240,7 +1368,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(300), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1250,17 +1380,26 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Danke Peter!', amount: '300', - creation: ['1000', '1000', '200'], + creation: ['1000', '700', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) describe('creation update is successful without changing month', () => { // actually this mutation IS changing the month - it.skip('returns update creation object', async () => { + it('returns update creation object', async () => { await expect( mutate({ mutation: adminUpdateContribution, @@ -1269,7 +1408,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(200), memo: 'Das war leider zu Viel!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1279,12 +1420,21 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Das war leider zu Viel!', amount: '200', - creation: ['1000', '1000', '300'], + creation: ['1000', '800', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) }) @@ -1304,10 +1454,10 @@ describe('AdminResolver', () => { lastName: 'Lustig', email: 'peter@lustig.de', date: expect.any(String), - memo: 'Herzlich Willkommen bei Gradido!', - amount: '400', + memo: 'Das war leider zu Viel!', + amount: '200', moderator: admin.id, - creation: ['1000', '600', '500'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1318,7 +1468,7 @@ describe('AdminResolver', () => { memo: 'Grundeinkommen', amount: '500', moderator: admin.id, - creation: ['1000', '600', '500'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1365,6 +1515,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) }) describe('admin deletes own user contribution', () => { @@ -1414,6 +1568,15 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin delete contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, + userId: admin.id, + }), + ) + }) }) }) @@ -1433,6 +1596,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) }) describe('confirm own creation', () => { @@ -1460,6 +1627,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') + }) }) describe('confirm creation for other user', () => { @@ -1488,6 +1659,14 @@ describe('AdminResolver', () => { ) }) + it('stores the contribution confirm event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CONFIRM, + }), + ) + }) + it('creates a transaction', async () => { const transaction = await DbTransaction.find() expect(transaction[0].amount.toString()).toBe('450') @@ -1512,6 +1691,14 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the send confirmation email event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.SEND_CONFIRMATION_EMAIL, + }), + ) + }) }) describe('confirm two creations one after the other quickly', () => { @@ -2052,6 +2239,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + it('returns an error if missing endDate', async () => { await expect( mutate({ @@ -2068,6 +2261,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + it('returns an error if endDate is before startDate', async () => { await expect( mutate({ @@ -2087,6 +2286,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + it('returns an error if name is an empty string', async () => { await expect( mutate({ @@ -2103,6 +2308,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + it('returns an error if name is shorter than 5 characters', async () => { await expect( mutate({ @@ -2123,6 +2332,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if name is longer than 100 characters', async () => { await expect( mutate({ @@ -2143,6 +2358,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if memo is an empty string', async () => { await expect( mutate({ @@ -2159,6 +2380,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + it('returns an error if memo is shorter than 5 characters', async () => { await expect( mutate({ @@ -2179,6 +2404,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if memo is longer than 255 characters', async () => { await expect( mutate({ @@ -2199,6 +2430,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if amount is not positive', async () => { await expect( mutate({ @@ -2216,6 +2453,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) }) describe('listContributionLinks', () => { @@ -2271,6 +2514,10 @@ describe('AdminResolver', () => { }) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + describe('valid id', () => { let linkId: number beforeAll(async () => { @@ -2336,6 +2583,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) }) describe('valid id', () => { diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 4fd85dc0f..aab84e911 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -64,6 +64,15 @@ import { ContributionMessageType } from '@enum/MessageType' import { ContributionMessage } from '@model/ContributionMessage' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' +import { eventProtocol } from '@/event/EventProtocolEmitter' +import { + Event, + EventAdminContributionCreate, + EventAdminContributionDelete, + EventAdminContributionUpdate, + EventContributionConfirm, + EventSendConfirmationEmail, +} from '@/event/Event' import { ContributionListResult } from '../model/Contribution' // const EMAIL_OPT_IN_REGISTER = 1 @@ -145,11 +154,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // administrator user changes own role? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Administrator can not change his own role!') throw new Error('Administrator can not change his own role!') } // change isAdmin @@ -158,6 +169,7 @@ export class AdminResolver { if (isAdmin === true) { user.isAdmin = new Date() } else { + logger.error('User is already a usual user!') throw new Error('User is already a usual user!') } break @@ -165,6 +177,7 @@ export class AdminResolver { if (isAdmin === false) { user.isAdmin = null } else { + logger.error('User is already admin!') throw new Error('User is already admin!') } break @@ -183,11 +196,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // moderator user disabled own account? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Moderator can not delete his own account!') throw new Error('Moderator can not delete his own account!') } // soft-delete user @@ -201,9 +216,11 @@ export class AdminResolver { async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } if (!user.deletedAt) { + logger.error('User is not deleted') throw new Error('User is not deleted') } await user.recover() @@ -240,6 +257,8 @@ export class AdminResolver { logger.error('Contribution could not be saved, Email is not activated') throw new Error('Contribution could not be saved, Email is not activated') } + + const event = new Event() const moderator = getUser(context) logger.trace('moderator: ', moderator.id) const creations = await getUserCreation(emailContact.userId) @@ -258,7 +277,17 @@ export class AdminResolver { contribution.contributionStatus = ContributionStatus.PENDING logger.trace('contribution to save', contribution) + await DbContribution.save(contribution) + + const eventAdminCreateContribution = new EventAdminContributionCreate() + eventAdminCreateContribution.userId = moderator.id + eventAdminCreateContribution.amount = amount + eventAdminCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionCreate(eventAdminCreateContribution), + ) + return getUserCreation(emailContact.userId) } @@ -319,7 +348,6 @@ export class AdminResolver { const contributionToUpdate = await DbContribution.findOne({ where: { id, confirmedAt: IsNull() }, }) - if (!contributionToUpdate) { logger.error('No contribution found to given id.') throw new Error('No contribution found to given id.') @@ -337,6 +365,7 @@ export class AdminResolver { const creationDateObj = new Date(creationDate) let creations = await getUserCreation(user.id) + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { creations = updateCreations(creations, contributionToUpdate) } else { @@ -353,6 +382,7 @@ export class AdminResolver { contributionToUpdate.contributionStatus = ContributionStatus.PENDING await DbContribution.save(contributionToUpdate) + const result = new AdminUpdateContribution() result.amount = amount result.memo = contributionToUpdate.memo @@ -360,6 +390,15 @@ export class AdminResolver { result.creation = await getUserCreation(user.id) + const event = new Event() + const eventAdminContributionUpdate = new EventAdminContributionUpdate() + eventAdminContributionUpdate.userId = user.id + eventAdminContributionUpdate.amount = amount + eventAdminContributionUpdate.contributionId = contributionToUpdate.id + await eventProtocol.writeEvent( + event.setEventAdminContributionUpdate(eventAdminContributionUpdate), + ) + return result } @@ -420,6 +459,16 @@ export class AdminResolver { contribution.deletedBy = moderator.id await contribution.save() const res = await contribution.softRemove() + + const event = new Event() + const eventAdminContributionDelete = new EventAdminContributionDelete() + eventAdminContributionDelete.userId = contribution.userId + eventAdminContributionDelete.amount = contribution.amount + eventAdminContributionDelete.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionDelete(eventAdminContributionDelete), + ) + return !!res } @@ -515,6 +564,13 @@ export class AdminResolver { } finally { await queryRunner.release() } + + const event = new Event() + const eventContributionConfirm = new EventContributionConfirm() + eventContributionConfirm.userId = user.id + eventContributionConfirm.amount = contribution.amount + eventContributionConfirm.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) return true } @@ -576,6 +632,13 @@ export class AdminResolver { // In case EMails are disabled log the activation link for the user if (!emailSent) { logger.info(`Account confirmation link: ${activationLink}`) + } else { + const event = new Event() + const eventSendConfirmationEmail = new EventSendConfirmationEmail() + eventSendConfirmationEmail.userId = user.id + await eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendConfirmationEmail), + ) } return true @@ -768,9 +831,11 @@ export class AdminResolver { relations: ['user'], }) if (!contribution) { + logger.error('Contribution not found') throw new Error('Contribution not found') } if (contribution.userId === user.id) { + logger.error('Admin can not answer on own contribution') throw new Error('Admin can not answer on own contribution') } if (!contribution.user.emailContact) { diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index b0c061d91..7bfae319e 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -63,6 +63,8 @@ export class StatisticsResolver { .where('transaction.decay IS NOT NULL') .getRawOne() + await queryRunner.release() + return { totalUsers, activeUsers, diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 3d40adbf6..275242bd3 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -6,8 +6,15 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' import { cleanDB, testEnvironment } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' -import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations' +import { + login, + createContributionLink, + redeemTransactionLink, + createContribution, + updateContribution, +} from '@/seeds/graphql/mutations' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' @@ -32,6 +39,7 @@ describe('TransactionLinkResolver', () => { describe('redeem daily Contribution Link', () => { const now = new Date() let contributionLink: DbContributionLink | undefined + let contribution: UnconfirmedContribution | undefined beforeAll(async () => { await mutate({ @@ -79,56 +87,59 @@ describe('TransactionLinkResolver', () => { ) }) - 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('user has pending contribution of 1000 GDD', () => { beforeAll(async () => { - jest.useFakeTimers() - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - setTimeout(() => {}, 1000 * 60 * 60 * 24) - jest.runAllTimers() await mutate({ mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const result = await mutate({ + mutation: createContribution, + variables: { + amount: new Decimal(1000), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + contribution = result.data.createContribution + }) + + 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(), + }, }) }) - afterAll(() => { - jest.useRealTimers() - }) - - it('allows the user to redeem the contribution link again', async () => { + it('allows the user to redeem the contribution link', async () => { await expect( mutate({ mutation: redeemTransactionLink, @@ -160,6 +171,56 @@ describe('TransactionLinkResolver', () => { ], }) }) + + describe('after one day', () => { + beforeAll(async () => { + jest.useFakeTimers() + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + setTimeout(() => {}, 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', + ), + ], + }) + }) + }) }) }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 4724956b4..74c531c54 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -74,10 +74,7 @@ export class TransactionLinkResolver { const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay) // validate amount - const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate) - if (!sendBalance) { - throw new Error("user hasn't enough GDD or amount is < 0") - } + await calculateBalance(user.id, holdAvailableAmount, createdDate) const transactionLink = dbTransactionLink.create() transactionLink.userId = user.id @@ -261,7 +258,7 @@ export class TransactionLinkResolver { } } - const creations = await getUserCreation(user.id, false) + const creations = await getUserCreation(user.id) logger.info('open creations', creations) validateContribution(creations, contributionLink.amount, now) const contribution = new DbContribution() diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts new file mode 100644 index 000000000..d391f8ab9 --- /dev/null +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -0,0 +1,366 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { EventProtocolType } from '@/event/EventProtocolType' +import { userFactory } from '@/seeds/factory/user' +import { + confirmContribution, + createContribution, + login, + sendCoins, +} from '@/seeds/graphql/mutations' +import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { EventProtocol } from '@entity/EventProtocol' +import { Transaction } from '@entity/Transaction' +import { User } from '@entity/User' +import { cleanDB, resetToken, testEnvironment } from '@test/helpers' +import { logger } from '@test/testSetup' +import { GraphQLError } from 'graphql' +import { findUserByEmail } from './UserResolver' + +let mutate: any, query: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment(logger) + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +let bobData: any +let peterData: any +let user: User[] + +describe('send coins', () => { + beforeAll(async () => { + await userFactory(testEnv, peterLustig) + await userFactory(testEnv, bobBaumeister) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + + bobData = { + email: 'bob@baumeister.de', + password: 'Aa12345_', + } + + peterData = { + email: 'peter@lustig.de', + password: 'Aa12345_', + } + + user = await User.find({ relations: ['emailContact'] }) + }) + + afterAll(async () => { + await cleanDB() + }) + + describe('unknown recipient', () => { + it('throws an error', async () => { + await mutate({ + mutation: login, + variables: bobData, + }) + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'wrong@email.com', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No user with this credentials')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`) + }) + + describe('deleted recipient', () => { + it('throws an error', async () => { + await mutate({ + mutation: login, + variables: peterData, + }) + + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'stephen@hawking.uk', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The recipient account was deleted')], + }), + ) + }) + + it('logs the error thrown', async () => { + // find peter to check the log + const user = await findUserByEmail(peterData.email) + expect(logger.error).toBeCalledWith( + `The recipient account was deleted: recipientUser=${user}`, + ) + }) + }) + + describe('recipient account not activated', () => { + it('throws an error', async () => { + await mutate({ + mutation: login, + variables: peterData, + }) + + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'garrick@ollivander.com', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The recipient account is not activated')], + }), + ) + }) + + it('logs the error thrown', async () => { + // find peter to check the log + const user = await findUserByEmail(peterData.email) + expect(logger.error).toBeCalledWith( + `The recipient account is not activated: recipientUser=${user}`, + ) + }) + }) + }) + + describe('errors in the transaction itself', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: bobData, + }) + }) + + describe('sender and recipient are the same', () => { + it('throws an error', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'bob@baumeister.de', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Sender and Recipient are the same.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Sender and Recipient are the same.') + }) + }) + + describe('memo text is too long', () => { + it('throws an error', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('memo text is too long (255 characters maximum)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('memo text is too long: memo.length=256 > 255') + }) + }) + + describe('memo text is too short', () => { + it('throws an error', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('memo text is too short (5 characters minimum)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5') + }) + }) + + describe('user has not enough GDD', () => { + it('throws an error', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'testing', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`User has not received any GDD yet`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `No prior transaction found for user with id: ${user[1].id}`, + ) + }) + }) + + describe('sending negative amount', () => { + it('throws an error', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: -50, + memo: 'testing negative', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Transaction amount must be greater than 0')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50') + }) + }) + }) + + describe('user has some GDD', () => { + beforeAll(async () => { + resetToken() + + // login as bob again + await query({ mutation: login, variables: bobData }) + + // create contribution as user bob + const contribution = await mutate({ + mutation: createContribution, + variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() }, + }) + + // login as admin + await query({ mutation: login, variables: peterData }) + + // confirm the contribution + await mutate({ + mutation: confirmContribution, + variables: { id: contribution.data.createContribution.id }, + }) + + // login as bob again + await query({ mutation: login, variables: bobData }) + }) + + describe('good transaction', () => { + it('sends the coins', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 50, + memo: 'unrepeatable memo', + }, + }), + ).toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + }) + + it('stores the send transaction event in the database', async () => { + // Find the exact transaction (sent one is the one with user[1] as user) + const transaction = await Transaction.find({ + userId: user[1].id, + memo: 'unrepeatable memo', + }) + + expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.TRANSACTION_SEND, + userId: user[1].id, + transactionId: transaction[0].id, + xUserId: user[0].id, + }), + ) + }) + + it('stores the receive event in the database', async () => { + // Find the exact transaction (received one is the one with user[0] as user) + const transaction = await Transaction.find({ + userId: user[0].id, + memo: 'unrepeatable memo', + }) + + expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.TRANSACTION_RECEIVE, + userId: user[0].id, + transactionId: transaction[0].id, + xUserId: user[1].id, + }), + ) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 50eed4502..f0fb2f452 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -37,6 +37,9 @@ import { BalanceResolver } from './BalanceResolver' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { findUserByEmail } from './UserResolver' import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' +import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' +import { eventProtocol } from '@/event/EventProtocolEmitter' +import { Decay } from '../model/Decay' export const executeTransaction = async ( amount: Decimal, @@ -55,28 +58,19 @@ export const executeTransaction = async ( } if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${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}`) + 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( - sender.id, - amount.mul(-1), - receivedCallDate, - transactionLink, - ) - logger.debug(`calculated Balance=${sendBalance}`) - if (!sendBalance) { - logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`) - throw new Error("user hasn't enough GDD or amount is < 0") - } + + const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink) const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() @@ -106,7 +100,24 @@ export const executeTransaction = async ( transactionReceive.userId = recipient.id transactionReceive.linkedUserId = sender.id transactionReceive.amount = amount - const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + + // state received balance + let receiveBalance: { + balance: Decimal + decay: Decay + lastTransactionId: number + } | null + + // try received balance + try { + receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + } catch (e) { + logger.info( + `User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`, + ) + receiveBalance = null + } + transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount transactionReceive.balanceDate = receivedCallDate transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) @@ -135,6 +146,20 @@ export const executeTransaction = async ( await queryRunner.commitTransaction() logger.info(`commit Transaction successful...`) + + const eventTransactionSend = new EventTransactionSend() + eventTransactionSend.userId = transactionSend.userId + eventTransactionSend.xUserId = transactionSend.linkedUserId + eventTransactionSend.transactionId = transactionSend.id + eventTransactionSend.amount = transactionSend.amount.mul(-1) + await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend)) + + const eventTransactionReceive = new EventTransactionReceive() + eventTransactionReceive.userId = transactionReceive.userId + eventTransactionReceive.xUserId = transactionReceive.linkedUserId + eventTransactionReceive.transactionId = transactionReceive.id + eventTransactionReceive.amount = transactionReceive.amount + await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive)) } catch (e) { await queryRunner.rollbackTransaction() logger.error(`Transaction was not successful: ${e}`) @@ -316,6 +341,10 @@ export class TransactionResolver { } */ // const recipientUser = await dbUser.findOne({ id: emailContact.userId }) + + /* Code inside this if statement is unreachable (useless by so), + in findUserByEmail() an error is already thrown if the user is not found + */ if (!recipientUser) { logger.error(`unknown recipient to UserContact: email=${email}`) throw new Error('unknown recipient') diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index 9987dfae6..abf4017cb 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -1,4 +1,3 @@ -import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' import { backendLogger as logger } from '@/server/logger' import { getConnection } from '@dbTools/typeorm' import { Contribution } from '@entity/Contribution' @@ -50,27 +49,27 @@ export const getUserCreations = async ( const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' logger.trace('getUserCreations dateFilter=', dateFilter) - const unionString = includePending - ? ` - UNION - SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions - WHERE user_id IN (${ids.toString()}) - AND contribution_date >= ${dateFilter} - AND confirmed_at IS NULL AND deleted_at IS NULL` - : '' - logger.trace('getUserCreations unionString=', unionString) + const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager + .createQueryBuilder(Contribution, 'c') + .select('month(contribution_date)', 'month') + .addSelect('user_id', 'userId') + .addSelect('sum(amount)', 'sum') + .where(`user_id in (${ids.toString()})`) + .andWhere(`contribution_date >= ${dateFilter}`) + .andWhere('deleted_at IS NULL') + .andWhere('denied_at IS NULL') + .groupBy('month') + .addGroupBy('userId') + .orderBy('month', 'DESC') - const unionQuery = await queryRunner.manager.query(` - SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM - (SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions - WHERE user_id IN (${ids.toString()}) - AND type_id = ${TransactionTypeId.CREATION} - AND creation_date >= ${dateFilter} - ${unionString}) AS result - GROUP BY month, userId - ORDER BY date DESC - `) - logger.trace('getUserCreations unionQuery=', unionQuery) + if (!includePending) { + sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL') + } + + const sumAmountContributionPerUserAndLast3Month = + await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany() + + logger.trace(sumAmountContributionPerUserAndLast3Month) await queryRunner.release() @@ -78,9 +77,9 @@ export const getUserCreations = async ( return { id, creations: months.map((month) => { - const creation = unionQuery.find( - (raw: { month: string; id: string; creation: number[] }) => - parseInt(raw.month) === month && parseInt(raw.id) === id, + const creation = sumAmountContributionPerUserAndLast3Month.find( + (raw: { month: string; userId: string; creation: number[] }) => + parseInt(raw.month) === month && parseInt(raw.userId) === id, ) return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) }), diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index 8b3e29859..c20ef85ff 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -28,8 +28,8 @@ export class UserRepository extends Repository { ): Promise<[DbUser[], number]> { const query = this.createQueryBuilder('user') .select(select) - .leftJoinAndSelect('user.emailContact', 'emailContact') .withDeleted() + .leftJoinAndSelect('user.emailContact', 'emailContact') .where( new Brackets((qb) => { qb.where( diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts index 9abb31554..65214ebb5 100644 --- a/backend/src/util/utilities.ts +++ b/backend/src/util/utilities.ts @@ -1,5 +1,17 @@ +import Decimal from 'decimal.js-light' + export const objectValuesToArray = (obj: { [x: string]: string }): Array => { return Object.keys(obj).map(function (key) { return obj[key] }) } + +// to improve code readability, as String is needed, it is handled inside this utility function +export const decimalAddition = (a: Decimal, b: Decimal): Decimal => { + return a.add(b.toString()) +} + +// to improve code readability, as String is needed, it is handled inside this utility function +export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => { + return a.minus(b.toString()) +} diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 8d1c90ca4..9640cc614 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -5,6 +5,8 @@ import { Decay } from '@model/Decay' import { getCustomRepository } from '@dbTools/typeorm' import { TransactionLinkRepository } from '@repository/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { decimalSubtraction, decimalAddition } from './utilities' +import { backendLogger as logger } from '@/server/logger' function isStringBoolean(value: string): boolean { const lowerValue = value.toLowerCase() @@ -23,14 +25,26 @@ async function calculateBalance( amount: Decimal, time: Date, transactionLink?: dbTransactionLink | null, -): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { +): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> { + // negative or empty amount should not be allowed + if (amount.lessThanOrEqualTo(0)) { + logger.error(`Transaction amount must be greater than 0: ${amount}`) + throw new Error('Transaction amount must be greater than 0') + } + + // check if user has prior transactions const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } }) - if (!lastTransaction) return null + + if (!lastTransaction) { + logger.error(`No prior transaction found for user with id: ${userId}`) + throw new Error('User has not received any GDD yet') + } const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) - // TODO why we have to use toString() here? - const balance = decay.balance.add(amount.toString()) + // new balance is the old balance minus the amount used + const balance = decimalSubtraction(decay.balance, amount) + const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time) @@ -38,11 +52,16 @@ async function calculateBalance( // else we cannot redeem links which are more or equal to half of what an account actually owns const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0) - if ( - balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0) - ) { - return null + const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount) + + if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) { + logger.error( + `Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`, + ) + throw new Error('Not enough funds for transaction') } + + logger.debug(`calculated Balance=${balance}`) return { balance, lastTransactionId: lastTransaction.id, decay } } diff --git a/database/package.json b/database/package.json index 0a97b5135..096c7a9bd 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.13.1", + "version": "1.13.3", "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 2fc892f9f..4e983d716 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.13.1", + "version": "1.13.3", "private": true, "scripts": { "start": "node run/server.js", diff --git a/frontend/src/assets/scss/custom/gradido-custom/_color.scss b/frontend/src/assets/scss/custom/gradido-custom/_color.scss index 20fcbefd6..f42555adf 100644 --- a/frontend/src/assets/scss/custom/gradido-custom/_color.scss +++ b/frontend/src/assets/scss/custom/gradido-custom/_color.scss @@ -33,7 +33,9 @@ $indigo: #5603ad !default; $purple: #8965e0 !default; $pink: #f3a4b5 !default; $red: #f5365c !default; -$orange: #fb6340 !default; + +// $orange: #fb6340 !default; +$orange: #8c0505 !default; $yellow: #ffd600 !default; $green: #2dce89 !default; $teal: #11cdef !default; diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesList.vue b/frontend/src/components/ContributionMessages/ContributionMessagesList.vue index 5f1c03b22..4b7045a40 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesList.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesList.vue @@ -5,15 +5,23 @@ - -
- - {{ $t('form.close') }} + + + + +
+ + + {{ $t('form.close') }} +
@@ -55,4 +63,7 @@ export default { .temp-message { margin-top: 50px; } +.clearboth { + clear: both; +} diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js index 7504854d0..2dc9fb3ce 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js @@ -175,4 +175,68 @@ describe('ContributionMessagesListItem', () => { }) }) }) + + describe('links in contribtion message', () => { + const propsData = { + message: { + id: 111, + message: 'Lorem ipsum?', + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'DIALOG', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const ModeratorItemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('message of only one link', () => { + beforeEach(() => { + propsData.message.message = 'https://gradido.net/de/' + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toBe('https://gradido.net/de/') + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + + describe('message with text and two links', () => { + beforeEach(() => { + propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido` + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('contains the whole text', () => { + expect(messageField.text()) + .toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido`) + }) + + it('contains the two links', () => { + expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/') + expect(messageField.findAll('a').at(1).attributes('href')).toBe( + 'https://github.com/gradido/gradido', + ) + }) + }) + }) }) diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue index 6c2e555f2..9c7a3a0f2 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue @@ -1,24 +1,29 @@ diff --git a/frontend/src/components/Contributions/ContributionForm.spec.js b/frontend/src/components/Contributions/ContributionForm.spec.js index 8f35948f9..3af716d36 100644 --- a/frontend/src/components/Contributions/ContributionForm.spec.js +++ b/frontend/src/components/Contributions/ContributionForm.spec.js @@ -198,6 +198,75 @@ describe('ContributionForm', () => { }) }) }) + + describe('date with the 31st day of the month', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2022-10-31T00:00:00.000Z'), + form: { date: new Date('2022-10-31T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2022-09-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2022-09-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) + + describe('date with the 28th day of the month', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2023-02-28T00:00:00.000Z'), + form: { date: new Date('2023-02-28T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2023-01-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2023-01-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) + + describe('date with 29.02.2024 leap year', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2024-02-29T00:00:00.000Z'), + form: { date: new Date('2024-02-29T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2024-01-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2024-01-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) }) describe('set contrubtion', () => { diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index efe80f494..3884fd5b4 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -88,6 +88,8 @@