diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c3966f5b..5dbbf685f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -520,7 +520,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 41 + min_coverage: 45 token: ${{ github.token }} ############################################################################## diff --git a/admin/src/graphql/searchUsers.js b/admin/src/graphql/searchUsers.js index cf6225338..2bd6d2004 100644 --- a/admin/src/graphql/searchUsers.js +++ b/admin/src/graphql/searchUsers.js @@ -1,14 +1,22 @@ import gql from 'graphql-tag' export const searchUsers = gql` - query ($searchText: String!) { - searchUsers(searchText: $searchText) { - userId - firstName - lastName - email - creation - emailChecked + query ($searchText: String!, $currentPage: Int, $pageSize: Int, $notActivated: Boolean) { + searchUsers( + searchText: $searchText + currentPage: $currentPage + pageSize: $pageSize + notActivated: $notActivated + ) { + userCount + userList { + userId + firstName + lastName + email + creation + emailChecked + } } } ` diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 58f7b6714..a9a2bc382 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -1,4 +1,5 @@ { + "all_emails": "Alle Nutzer", "bookmark": "bookmark", "confirmed": "bestätigt", "creation_form": { @@ -53,7 +54,7 @@ "transactionlist": { "title": "Alle geschöpften Transaktionen für den Nutzer" }, - "unregistered_emails": "Unregistrierte E-Mails", + "unregistered_emails": "Nur unregistrierte Nutzer", "unregister_mail": { "button": "Registrierungs-Email bestätigen, jetzt senden", "error": "Fehler beim Senden des Bestätigungs-Links an den Benutzer: {message}", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index a02782267..ee2b9b36d 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -1,4 +1,5 @@ { + "all_emails": "All users", "bookmark": "Remember", "confirmed": "confirmed", "creation_form": { @@ -53,7 +54,7 @@ "transactionlist": { "title": "All creation-transactions for the user" }, - "unregistered_emails": "Unregistered e-mails", + "unregistered_emails": "Only unregistered users", "unregister_mail": { "button": "Confirm registration email, send now", "error": "Error sending the confirmation link to the user: {message}", diff --git a/admin/src/pages/Creation.spec.js b/admin/src/pages/Creation.spec.js index fc8cdbaf0..01a884f54 100644 --- a/admin/src/pages/Creation.spec.js +++ b/admin/src/pages/Creation.spec.js @@ -6,22 +6,25 @@ const localVue = global.localVue const apolloQueryMock = jest.fn().mockResolvedValue({ data: { - searchUsers: [ - { - userId: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - }, - { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - }, - ], + searchUsers: { + userCount: 2, + userList: [ + { + userId: 1, + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + creation: [200, 400, 600], + }, + { + userId: 2, + firstName: 'Benjamin', + lastName: 'Blümchen', + email: 'benjamin@bluemchen.de', + creation: [800, 600, 400], + }, + ], + }, }, }) @@ -227,6 +230,22 @@ describe('Creation', () => { }) }) + describe('watchers', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('calls API when criteria changes', async () => { + await wrapper.setData({ criteria: 'XX' }) + expect(apolloQueryMock).toBeCalled() + }) + + it('calls API when currentPage changes', async () => { + await wrapper.setData({ currentPage: 2 }) + expect(apolloQueryMock).toBeCalled() + }) + }) + describe('apollo returns error', () => { beforeEach(() => { apolloQueryMock.mockRejectedValue({ diff --git a/admin/src/pages/Creation.vue b/admin/src/pages/Creation.vue index 65e0b2f2f..0de804288 100644 --- a/admin/src/pages/Creation.vue +++ b/admin/src/pages/Creation.vue @@ -18,6 +18,13 @@ :creation="creation" @update-item="updateItem" /> + { - this.itemsList = result.data.searchUsers.map((user) => { + this.rows = result.data.searchUsers.userCount + this.itemsList = result.data.searchUsers.userList.map((user) => { return { ...user, showDetails: false, @@ -153,5 +166,13 @@ export default { this.itemsMassCreation = [] }, }, + watch: { + currentPage() { + this.getUsers() + }, + criteria() { + this.getUsers() + }, + }, } diff --git a/admin/src/pages/UserSearch.spec.js b/admin/src/pages/UserSearch.spec.js index b6d01b254..4beda0c49 100644 --- a/admin/src/pages/UserSearch.spec.js +++ b/admin/src/pages/UserSearch.spec.js @@ -5,15 +5,18 @@ const localVue = global.localVue const apolloQueryMock = jest.fn().mockResolvedValue({ data: { - searchUsers: [ - { - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - emailChecked: false, - }, - ], + searchUsers: { + userCount: 1, + userList: [ + { + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + creation: [200, 400, 600], + emailChecked: false, + }, + ], + }, }, }) diff --git a/admin/src/pages/UserSearch.vue b/admin/src/pages/UserSearch.vue index a5d4c05cf..4b9bcae8a 100644 --- a/admin/src/pages/UserSearch.vue +++ b/admin/src/pages/UserSearch.vue @@ -3,7 +3,7 @@
- {{ $t('unregistered_emails') }} + {{ filterCheckedEmails ? $t('all_emails') : $t('unregistered_emails') }}
@@ -21,6 +21,14 @@ :fieldsTable="fields" :criteria="criteria" /> +
@@ -67,14 +75,16 @@ export default { beforeLastMonth: { short: this.$moment().subtract(2, 'month').format('MMMM'), }, + filterCheckedEmails: false, + rows: 0, + currentPage: 1, + perPage: 25, } }, - methods: { unconfirmedRegisterMails() { - this.searchResult = this.searchResult.filter((user) => { - return !user.emailChecked - }) + this.filterCheckedEmails = !this.filterCheckedEmails + this.getUsers() }, getUsers() { this.$apollo @@ -82,16 +92,25 @@ export default { query: searchUsers, variables: { searchText: this.criteria, + currentPage: this.currentPage, + pageSize: this.perPage, + notActivated: this.filterCheckedEmails, }, }) .then((result) => { - this.searchResult = result.data.searchUsers + this.rows = result.data.searchUsers.userCount + this.searchResult = result.data.searchUsers.userList }) .catch((error) => { this.$toasted.error(error.message) }) }, }, + watch: { + currentPage() { + this.getUsers() + }, + }, created() { this.getUsers() }, diff --git a/backend/jest.config.js b/backend/jest.config.js index 9d99c68f6..981475807 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,15 +1,18 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -module.exports = { - verbose: true, - preset: 'ts-jest', - collectCoverage: true, - collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], - moduleNameMapper: { - '@entity/(.*)': '/../database/build/entity/$1', - // This is hack to fix a problem with the library `ts-mysql-migrate` which does differentiate between its ts/js state - '@dbTools/(.*)': - process.env.NODE_ENV === 'development' - ? '/../database/src/$1' - : '/../database/build/src/$1', - }, +module.exports = async () => { + process.env.TZ = 'UTC' + return { + verbose: true, + preset: 'ts-jest', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], + moduleNameMapper: { + '@entity/(.*)': '/../database/build/entity/$1', + // This is hack to fix a problem with the library `ts-mysql-migrate` which does differentiate between its ts/js state + '@dbTools/(.*)': + process.env.NODE_ENV === 'development' + ? '/../database/src/$1' + : '/../database/build/src/$1', + }, + } } diff --git a/backend/src/apis/KlicktippController.ts b/backend/src/apis/KlicktippController.ts index 544b39d97..0777211ad 100644 --- a/backend/src/apis/KlicktippController.ts +++ b/backend/src/apis/KlicktippController.ts @@ -5,7 +5,7 @@ import CONFIG from '../config' const klicktippConnector = new KlicktippConnector() -export const signIn = async ( +export const klicktippSignIn = async ( email: string, language: string, firstName?: string, diff --git a/backend/src/graphql/arg/SearchUsersArgs.ts b/backend/src/graphql/arg/SearchUsersArgs.ts new file mode 100644 index 000000000..5b40fd9ca --- /dev/null +++ b/backend/src/graphql/arg/SearchUsersArgs.ts @@ -0,0 +1,16 @@ +import { ArgsType, Field, Int } from 'type-graphql' + +@ArgsType() +export default class SearchUsersArgs { + @Field(() => String) + searchText: string + + @Field(() => Int, { nullable: true }) + currentPage?: number + + @Field(() => Int, { nullable: true }) + pageSize?: number + + @Field(() => Boolean, { nullable: true }) + notActivated?: boolean +} diff --git a/backend/src/graphql/model/UserAdmin.ts b/backend/src/graphql/model/UserAdmin.ts index befc203a5..9e28a33d6 100644 --- a/backend/src/graphql/model/UserAdmin.ts +++ b/backend/src/graphql/model/UserAdmin.ts @@ -1,4 +1,4 @@ -import { ObjectType, Field } from 'type-graphql' +import { ObjectType, Field, Int } from 'type-graphql' @ObjectType() export class UserAdmin { @@ -20,3 +20,12 @@ export class UserAdmin { @Field(() => Boolean) emailChecked: boolean } + +@ObjectType() +export class SearchUsersResult { + @Field(() => Int) + userCount: number + + @Field(() => [UserAdmin]) + userList: UserAdmin[] +} diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 11d8c99cd..8a7e034a5 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -3,7 +3,7 @@ import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx } from 'type-graphql' import { getCustomRepository, Raw } from 'typeorm' -import { UserAdmin } from '../model/UserAdmin' +import { UserAdmin, SearchUsersResult } from '../model/UserAdmin' import { PendingCreation } from '../model/PendingCreation' import { CreatePendingCreations } from '../model/CreatePendingCreations' import { UpdatePendingCreation } from '../model/UpdatePendingCreation' @@ -14,6 +14,7 @@ import { LoginPendingTasksAdminRepository } from '../../typeorm/repository/Login import { UserRepository } from '../../typeorm/repository/User' import CreatePendingCreationArgs from '../arg/CreatePendingCreationArgs' import UpdatePendingCreationArgs from '../arg/UpdatePendingCreationArgs' +import SearchUsersArgs from '../arg/SearchUsersArgs' import moment from 'moment' import { Transaction } from '@entity/Transaction' import { TransactionCreation } from '@entity/TransactionCreation' @@ -26,11 +27,13 @@ import { LoginUserRepository } from '../../typeorm/repository/LoginUser' @Resolver() export class AdminResolver { @Authorized([RIGHTS.SEARCH_USERS]) - @Query(() => [UserAdmin]) - async searchUsers(@Arg('searchText') searchText: string): Promise { + @Query(() => SearchUsersResult) + async searchUsers( + @Args() { searchText, currentPage = 1, pageSize = 25, notActivated = false }: SearchUsersArgs, + ): Promise { const userRepository = getCustomRepository(UserRepository) const users = await userRepository.findBySearchCriteria(searchText) - const adminUsers = await Promise.all( + let adminUsers = await Promise.all( users.map(async (user) => { const adminUser = new UserAdmin() adminUser.userId = user.id @@ -42,7 +45,12 @@ export class AdminResolver { return adminUser }), ) - return adminUsers + if (notActivated) adminUsers = adminUsers.filter((u) => !u.emailChecked) + const first = (currentPage - 1) * pageSize + return { + userCount: adminUsers.length, + userList: adminUsers.slice(first, first + pageSize), + } } @Authorized([RIGHTS.CREATE_PENDING_CREATION]) diff --git a/backend/src/graphql/resolver/KlicktippResolver.ts b/backend/src/graphql/resolver/KlicktippResolver.ts index fdffb940a..0ba2387e3 100644 --- a/backend/src/graphql/resolver/KlicktippResolver.ts +++ b/backend/src/graphql/resolver/KlicktippResolver.ts @@ -6,7 +6,7 @@ import { getKlickTippUser, getKlicktippTagMap, unsubscribe, - signIn, + klicktippSignIn, } from '../../apis/KlicktippController' import { RIGHTS } from '../../auth/RIGHTS' import SubscribeNewsletterArgs from '../arg/SubscribeNewsletterArgs' @@ -36,6 +36,6 @@ export class KlicktippResolver { async subscribeNewsletter( @Args() { email, language }: SubscribeNewsletterArgs, ): Promise { - return await signIn(email, language) + return await klicktippSignIn(email, language) } } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 8aaf3b69e..02e490edd 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -13,6 +13,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import CONFIG from '../../config' import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail' +import { klicktippSignIn } from '../../apis/KlicktippController' jest.mock('../../mailer/sendAccountActivationEmail', () => { return { @@ -21,6 +22,13 @@ jest.mock('../../mailer/sendAccountActivationEmail', () => { } }) +jest.mock('../../apis/KlicktippController', () => { + return { + __esModule: true, + klicktippSignIn: jest.fn(), + } +}) + let mutate: any let con: any @@ -220,6 +228,157 @@ describe('UserResolver', () => { }) }) }) + + describe('setPassword', () => { + const createUserMutation = gql` + mutation ( + $email: String! + $firstName: String! + $lastName: String! + $language: String! + $publisherId: Int + ) { + createUser( + email: $email + firstName: $firstName + lastName: $lastName + language: $language + publisherId: $publisherId + ) + } + ` + + const createUserVariables = { + email: 'peter@lustig.de', + firstName: 'Peter', + lastName: 'Lustig', + language: 'de', + publisherId: 1234, + } + + const setPasswordMutation = gql` + mutation ($code: String!, $password: String!) { + setPassword(code: $code, password: $password) + } + ` + let result: any + let emailOptIn: string + + describe('valid optin code and valid password', () => { + let loginUser: any + let newLoginUser: any + let newUser: any + + beforeAll(async () => { + await mutate({ mutation: createUserMutation, variables: createUserVariables }) + const loginEmailOptIn = await getRepository(LoginEmailOptIn) + .createQueryBuilder('login_email_optin') + .getMany() + loginUser = await getRepository(LoginUser).createQueryBuilder('login_user').getMany() + emailOptIn = loginEmailOptIn[0].verificationCode.toString() + result = await mutate({ + mutation: setPasswordMutation, + variables: { code: emailOptIn, password: 'Aa12345_' }, + }) + newLoginUser = await getRepository(LoginUser).createQueryBuilder('login_user').getMany() + newUser = await getRepository(User).createQueryBuilder('state_user').getMany() + }) + + afterAll(async () => { + await resetDB() + }) + + it('sets email checked to true', () => { + expect(newLoginUser[0].emailChecked).toBeTruthy() + }) + + it('updates the password', () => { + expect(newLoginUser[0].password).toEqual('3917921995996627700') + }) + + it('updates the public Key on both user tables', () => { + expect(newLoginUser[0].pubKey).toEqual(expect.any(Buffer)) + expect(newLoginUser[0].pubKey).not.toEqual(loginUser[0].pubKey) + expect(newLoginUser[0].pubKey).toEqual(newUser[0].pubkey) + }) + + it('updates the private Key', () => { + expect(newLoginUser[0].privKey).toEqual(expect.any(Buffer)) + expect(newLoginUser[0].privKey).not.toEqual(loginUser[0].privKey) + }) + + it('removes the optin', async () => { + await expect( + getRepository(LoginEmailOptIn).createQueryBuilder('login_email_optin').getMany(), + ).resolves.toHaveLength(0) + }) + + it('calls the klicktipp API', () => { + expect(klicktippSignIn).toBeCalledWith( + loginUser[0].email, + loginUser[0].language, + loginUser[0].firstName, + loginUser[0].lastName, + ) + }) + + it('returns true', () => { + expect(result).toBeTruthy() + }) + }) + + describe('no valid password', () => { + beforeAll(async () => { + await mutate({ mutation: createUserMutation, variables: createUserVariables }) + const loginEmailOptIn = await getRepository(LoginEmailOptIn) + .createQueryBuilder('login_email_optin') + .getMany() + emailOptIn = loginEmailOptIn[0].verificationCode.toString() + result = await mutate({ + mutation: setPasswordMutation, + variables: { code: emailOptIn, password: 'not-valid' }, + }) + }) + + afterAll(async () => { + await resetDB() + }) + + it('throws an error', () => { + expect(result).toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', + ), + ], + }), + ) + }) + }) + + describe('no valid optin code', () => { + beforeAll(async () => { + await mutate({ mutation: createUserMutation, variables: createUserVariables }) + result = await mutate({ + mutation: setPasswordMutation, + variables: { code: 'not valid', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await resetDB() + }) + + it('throws an error', () => { + expect(result).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Could not login with emailVerificationCode')], + }), + ) + }) + }) + }) }) afterAll(async () => { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 83e915bc7..bf4ef2a68 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -23,7 +23,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { sendResetPasswordEmail } from '../../mailer/sendResetPasswordEmail' import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail' import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys' -import { signIn } from '../../apis/KlicktippController' +import { klicktippSignIn } from '../../apis/KlicktippController' import { RIGHTS } from '../../auth/RIGHTS' import { ServerUserRepository } from '../../typeorm/repository/ServerUser' import { ROLE_ADMIN } from '../../auth/ROLES' @@ -641,7 +641,12 @@ export class UserResolver { // TODO do we always signUp the user? How to handle things with old users? if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) { try { - await signIn(loginUser.email, loginUser.language, loginUser.firstName, loginUser.lastName) + await klicktippSignIn( + loginUser.email, + loginUser.language, + loginUser.firstName, + loginUser.lastName, + ) } catch { // TODO is this a problem? // eslint-disable-next-line no-console diff --git a/backend/src/middleware/klicktippMiddleware.ts b/backend/src/middleware/klicktippMiddleware.ts index 69a74480d..d0fde8195 100644 --- a/backend/src/middleware/klicktippMiddleware.ts +++ b/backend/src/middleware/klicktippMiddleware.ts @@ -1,5 +1,5 @@ import { MiddlewareFn } from 'type-graphql' -import { /* signIn, */ getKlickTippUser } from '../apis/KlicktippController' +import { /* klicktippSignIn, */ getKlickTippUser } from '../apis/KlicktippController' import { KlickTipp } from '../graphql/model/KlickTipp' import CONFIG from '../config/index' @@ -12,7 +12,7 @@ import CONFIG from '../config/index' // // Do Something here before resolver is called // const result = await next() // // Do Something here after resolver is completed -// await signIn(result.email, result.language, result.firstName, result.lastName) +// await klicktippSignIn(result.email, result.language, result.firstName, result.lastName) // return result // } diff --git a/database/src/index.ts b/database/src/index.ts index 033a36d8b..fefdea217 100644 --- a/database/src/index.ts +++ b/database/src/index.ts @@ -7,6 +7,7 @@ import { CreateBibiBloxbergSeed } from './seeds/users/bibi-bloxberg.seed' import { CreateRaeuberHotzenplotzSeed } from './seeds/users/raeuber-hotzenplotz.seed' import { CreateBobBaumeisterSeed } from './seeds/users/bob-baumeister.seed' import { CreateGarrickOllivanderSeed } from './seeds/users/garrick-ollivander.seed' +import { CreateUserSeed } from './seeds/create-user.seed' import { DecayStartBlockSeed } from './seeds/decay-start-block.seed' import { resetDB, pool, migration } from './helpers' @@ -45,6 +46,10 @@ const run = async (command: string) => { await runSeeder(CreateBibiBloxbergSeed) await runSeeder(CreateRaeuberHotzenplotzSeed) await runSeeder(CreateBobBaumeisterSeed) + // eslint-disable-next-line prefer-spread + Array.apply(null, Array(96)).forEach(async () => { + await runSeeder(CreateUserSeed) + }) await runSeeder(CreateGarrickOllivanderSeed) break default: diff --git a/database/src/seeds/create-user.seed.ts b/database/src/seeds/create-user.seed.ts index ca3a182c4..69488a790 100644 --- a/database/src/seeds/create-user.seed.ts +++ b/database/src/seeds/create-user.seed.ts @@ -1,11 +1,8 @@ import { Factory, Seeder } from 'typeorm-seeding' -import { User } from '../../entity/User' -// import { LoginUser } from '../../entity/LoginUser' +import { userSeeder } from './helpers/user-helpers' export class CreateUserSeed implements Seeder { public async run(factory: Factory): Promise { - // const loginUser = await factory(LoginUser)().make() - // console.log(loginUser.email) - await factory(User)().create() + await userSeeder(factory, {}) } } diff --git a/database/src/seeds/helpers/user-helpers.ts b/database/src/seeds/helpers/user-helpers.ts index bd46ecdee..e1e33a94b 100644 --- a/database/src/seeds/helpers/user-helpers.ts +++ b/database/src/seeds/helpers/user-helpers.ts @@ -27,6 +27,7 @@ import { Factory } from 'typeorm-seeding' export const userSeeder = async (factory: Factory, userData: UserInterface): Promise => { const user = await factory(User)(createUserContext(userData)).create() + if (!userData.email) userData.email = user.email const loginUser = await factory(LoginUser)(createLoginUserContext(userData)).create() await factory(LoginUserBackup)(createLoginUserBackupContext(userData, loginUser)).create()