diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8680c9203..bb2441701 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -528,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 64 + min_coverage: 66 token: ${{ github.token }} ########################################################################## diff --git a/admin/src/graphql/searchUsers.js b/admin/src/graphql/searchUsers.js index ddf759031..5740e24cc 100644 --- a/admin/src/graphql/searchUsers.js +++ b/admin/src/graphql/searchUsers.js @@ -5,15 +5,13 @@ export const searchUsers = gql` $searchText: String! $currentPage: Int $pageSize: Int - $filterByActivated: Boolean - $filterByDeleted: Boolean + $filters: SearchUsersFiltersInput ) { searchUsers( searchText: $searchText currentPage: $currentPage pageSize: $pageSize - filterByActivated: $filterByActivated - filterByDeleted: $filterByDeleted + filters: $filters ) { userCount userList { diff --git a/admin/src/pages/Creation.spec.js b/admin/src/pages/Creation.spec.js index 98c03d277..432cbe19b 100644 --- a/admin/src/pages/Creation.spec.js +++ b/admin/src/pages/Creation.spec.js @@ -71,8 +71,10 @@ describe('Creation', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: true, - filterByDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -271,8 +273,10 @@ describe('Creation', () => { searchText: 'XX', currentPage: 1, pageSize: 25, - filterByActivated: true, - filterByDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -288,8 +292,10 @@ describe('Creation', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: true, - filterByDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -305,8 +311,10 @@ describe('Creation', () => { searchText: '', currentPage: 2, pageSize: 25, - filterByActivated: true, - filterByDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) diff --git a/admin/src/pages/Creation.vue b/admin/src/pages/Creation.vue index 54bc0d735..17962bfff 100644 --- a/admin/src/pages/Creation.vue +++ b/admin/src/pages/Creation.vue @@ -102,8 +102,10 @@ export default { searchText: this.criteria, currentPage: this.currentPage, pageSize: this.perPage, - filterByActivated: true, - filterByDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, fetchPolicy: 'network-only', }) diff --git a/admin/src/pages/UserSearch.spec.js b/admin/src/pages/UserSearch.spec.js index 2eb24f84b..a1d809a66 100644 --- a/admin/src/pages/UserSearch.spec.js +++ b/admin/src/pages/UserSearch.spec.js @@ -7,7 +7,7 @@ const localVue = global.localVue const apolloQueryMock = jest.fn().mockResolvedValue({ data: { searchUsers: { - userCount: 1, + userCount: 4, userList: [ { userId: 1, @@ -82,8 +82,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: null, - filterByDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -101,8 +103,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: false, - filterByDeleted: null, + filters: { + filterByActivated: false, + filterByDeleted: null, + }, }, }), ) @@ -121,8 +125,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: null, - filterByDeleted: true, + filters: { + filterByActivated: null, + filterByDeleted: true, + }, }, }), ) @@ -141,8 +147,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 2, pageSize: 25, - filterByActivated: null, - filterByDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -161,8 +169,10 @@ describe('UserSearch', () => { searchText: 'search string', currentPage: 1, pageSize: 25, - filterByActivated: null, - filterByDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -178,8 +188,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - filterByActivated: null, - filterByDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) diff --git a/admin/src/pages/UserSearch.vue b/admin/src/pages/UserSearch.vue index f8ceac36c..7b638c316 100644 --- a/admin/src/pages/UserSearch.vue +++ b/admin/src/pages/UserSearch.vue @@ -97,8 +97,10 @@ export default { searchText: this.criteria, currentPage: this.currentPage, pageSize: this.perPage, - filterByActivated: this.filterByActivated, - filterByDeleted: this.filterByDeleted, + filters: { + filterByActivated: this.filterByActivated, + filterByDeleted: this.filterByDeleted, + }, }, fetchPolicy: 'no-cache', }) diff --git a/backend/src/graphql/arg/SearchUsersArgs.ts b/backend/src/graphql/arg/SearchUsersArgs.ts index b47f39d56..8db6bfc06 100644 --- a/backend/src/graphql/arg/SearchUsersArgs.ts +++ b/backend/src/graphql/arg/SearchUsersArgs.ts @@ -1,4 +1,5 @@ import { ArgsType, Field, Int } from 'type-graphql' +import SearchUsersFilters from '@arg/SearchUsersFilters' @ArgsType() export default class SearchUsersArgs { @@ -11,9 +12,6 @@ export default class SearchUsersArgs { @Field(() => Int, { nullable: true }) pageSize?: number - @Field(() => Boolean, { nullable: true }) - filterByActivated?: boolean | null - - @Field(() => Boolean, { nullable: true }) - filterByDeleted?: boolean | null + @Field(() => SearchUsersFilters, { nullable: true }) + filters: SearchUsersFilters } diff --git a/backend/src/graphql/arg/SearchUsersFilters.ts b/backend/src/graphql/arg/SearchUsersFilters.ts new file mode 100644 index 000000000..de7c7c20a --- /dev/null +++ b/backend/src/graphql/arg/SearchUsersFilters.ts @@ -0,0 +1,11 @@ +import { Field, InputType, ObjectType } from 'type-graphql' + +@ObjectType() +@InputType('SearchUsersFiltersInput') +export default class SearchUsersFilters { + @Field(() => Boolean, { nullable: true, defaultValue: null }) + filterByActivated?: boolean | null + + @Field(() => Boolean, { nullable: true, defaultValue: null }) + filterByDeleted?: boolean | null +} diff --git a/backend/src/graphql/arg/TransactionLinkFilters.ts b/backend/src/graphql/arg/TransactionLinkFilters.ts index e2f752d3f..b009a3180 100644 --- a/backend/src/graphql/arg/TransactionLinkFilters.ts +++ b/backend/src/graphql/arg/TransactionLinkFilters.ts @@ -3,11 +3,11 @@ import { ArgsType, Field } from 'type-graphql' @ArgsType() export default class TransactionLinkFilters { @Field(() => Boolean, { nullable: true, defaultValue: true }) - withDeleted?: boolean + filterByDeleted?: boolean @Field(() => Boolean, { nullable: true, defaultValue: true }) - withExpired?: boolean + filterByExpired?: boolean @Field(() => Boolean, { nullable: true, defaultValue: true }) - withRedeemed?: boolean + filterByRedeemed?: boolean } diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index ca6bf0fe7..4771232ea 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { convertObjValuesToArray } from '@/util/utilities' import { testEnvironment, resetToken, cleanDB } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' @@ -11,6 +12,7 @@ import { garrickOllivander } from '@/seeds/users/garrick-ollivander' import { deleteUser, unDeleteUser, + searchUsers, createPendingCreation, createPendingCreations, updatePendingCreation, @@ -261,6 +263,224 @@ describe('AdminResolver', () => { }) }) + describe('search users', () => { + const variablesWithoutTextAndFilters = { + searchText: '', + currentPage: 1, + pageSize: 25, + filters: null, + } + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + const allUsers = { + bibi: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + garrick: expect.objectContaining({ + email: 'garrick@ollivander.com', + }), + peter: expect.objectContaining({ + email: 'peter@lustig.de', + }), + stephen: expect.objectContaining({ + email: 'stephen@hawking.uk', + }), + } + + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(convertObjValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(convertObjValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('filter by unchecked email', () => { + it('finds only users with unchecked email', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: false, + filterByDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.garrick]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted users', () => { + it('finds only users with deleted account', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: null, + filterByDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.stephen]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted account and unchecked email', () => { + it('finds no users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: false, + filterByDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 0, + userList: [], + }, + }, + }), + ) + }) + }) + }) + }) + }) + describe('creations', () => { const variables = { email: 'bibi@bloxberg.de', diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 8da92a61c..8c3d71b73 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -52,23 +52,19 @@ export class AdminResolver { @Query(() => SearchUsersResult) async searchUsers( @Args() - { - searchText, - currentPage = 1, - pageSize = 25, - filterByActivated = null, - filterByDeleted = null, - }: SearchUsersArgs, + { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, ): Promise { const userRepository = getCustomRepository(UserRepository) const filterCriteria: ObjectLiteral[] = [] - if (filterByActivated !== null) { - filterCriteria.push({ emailChecked: filterByActivated }) - } + if (filters) { + if (filters.filterByActivated !== null) { + filterCriteria.push({ emailChecked: filters.filterByActivated }) + } - if (filterByDeleted !== null) { - filterCriteria.push({ deletedAt: filterByDeleted ? Not(IsNull()) : IsNull() }) + if (filters.filterByDeleted !== null) { + filterCriteria.push({ deletedAt: filters.filterByDeleted ? Not(IsNull()) : IsNull() }) + } } const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked', 'deletedAt'] @@ -442,11 +438,11 @@ export class AdminResolver { } = { userId, } - if (!filters.withRedeemed) where.redeemedBy = null - if (!filters.withExpired) where.validUntil = MoreThan(new Date()) + if (!filters.filterByRedeemed) where.redeemedBy = null + if (!filters.filterByExpired) where.validUntil = MoreThan(new Date()) const [transactionLinks, count] = await dbTransactionLink.findAndCount({ where, - withDeleted: filters.withDeleted, + withDeleted: filters.filterByDeleted, order: { createdAt: order, }, diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index d3026dbdd..4598cbbe2 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -107,6 +107,35 @@ export const unDeleteUser = gql` } ` +export const searchUsers = gql` + query ( + $searchText: String! + $currentPage: Int + $pageSize: Int + $filters: SearchUsersFiltersInput + ) { + searchUsers( + searchText: $searchText + currentPage: $currentPage + pageSize: $pageSize + filters: $filters + ) { + userCount + userList { + userId + firstName + lastName + email + creation + emailChecked + hasElopage + emailConfirmationSend + deletedAt + } + } + } +` + export const createPendingCreations = gql` mutation ($pendingCreations: [CreatePendingCreationArgs!]!) { createPendingCreations(pendingCreations: $pendingCreations) { diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index 21539e1ba..710f255ee 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -29,7 +29,7 @@ const context = { } export const cleanDB = async () => { - // this only works as lond we do not have foreign key constraints + // this only works as long we do not have foreign key constraints for (let i = 0; i < entities.length; i++) { await resetEntity(entities[i]) } diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts new file mode 100644 index 000000000..f77ad05ec --- /dev/null +++ b/backend/src/util/utilities.ts @@ -0,0 +1,5 @@ +export const convertObjValuesToArray = (obj: { [x: string]: string }): Array => { + return Object.keys(obj).map(function (key) { + return obj[key] + }) +}