From 37b1b3464fcb44c817c6343a33ba49108ad5b173 Mon Sep 17 00:00:00 2001 From: Milos Date: Mon, 14 Oct 2024 14:14:59 +0200 Subject: [PATCH] test/remove-users: added unit tests --- backend/src/auth/facade/auth.facade.spec.ts | 98 ++++++++++++++++++- .../facade/constitution.facade.spec.ts | 6 ++ .../facade/governance.facade.spec.ts | 23 ++++- .../services/governance.service.spec.ts | 41 ++++++-- backend/src/s3/service/s3.service.spec.ts | 10 +- backend/src/users/facade/users.facade.spec.ts | 69 +++++++++++-- .../src/users/services/users.service.spec.ts | 32 ++++++ 7 files changed, 254 insertions(+), 25 deletions(-) diff --git a/backend/src/auth/facade/auth.facade.spec.ts b/backend/src/auth/facade/auth.facade.spec.ts index 02b82d86..5e27b48c 100644 --- a/backend/src/auth/facade/auth.facade.spec.ts +++ b/backend/src/auth/facade/auth.facade.spec.ts @@ -9,6 +9,7 @@ import { CreateUserDto } from 'src/users/dto/create-user.dto'; import { BadRequestException, ConflictException, + ForbiddenException, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -18,6 +19,7 @@ import { Role } from 'src/users/entities/role.entity'; import { TokenResponse } from '../api/response/token.response'; import { EmailService } from '../../email/service/email.service'; import { UserStatusEnum } from '../../users/enums/user-status.enum'; +import { PermissionEnum } from 'src/users/enums/permission.enum'; describe('AuthFacade', () => { let facade: AuthFacade; @@ -135,6 +137,20 @@ describe('AuthFacade', () => { createdAt: null, updatedAt: null, }, + { + id: '3', + name: 'Test User 3', + email: 'test@test.com', + description: 'Lorem ipsum dolor sit amet 3', + profilePhotoUrl: 'https://example3.com/profile.jpg', + status: UserStatusEnum.PENDING, + hotAddresses: ['addr1', 'addr2'], + role: mockRoles[2].code, + permissions: [], + deactivatedAt: null, + createdAt: null, + updatedAt: null, + }, ]; const mockTokenResponse: TokenResponse = { @@ -233,6 +249,7 @@ describe('AuthFacade', () => { } return user; }), + checkRoleManagedByPermission: jest.fn(), }; const mockAuthService = { @@ -408,7 +425,7 @@ describe('AuthFacade', () => { }); describe('Validate User', () => { - it('should validate user by email', async () => { + it('should find user by email', async () => { const email = 'sofija@example.com'; const userDto: UserDto = { id: '1', @@ -425,7 +442,7 @@ describe('AuthFacade', () => { updatedAt: null, }; - const result = await facade.validateUser(email); + const result = await facade.findUserByEmail(email); expect(result).toEqual(userDto); expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); @@ -435,7 +452,7 @@ describe('AuthFacade', () => { const email = 'non_existing@example.com'; try { - await facade.validateUser(email); + await facade.findUserByEmail(email); } catch (e) { expect(e).toBeInstanceOf(NotFoundException); expect(e.message).toBe(`User with this email address not found`); @@ -534,4 +551,79 @@ describe('AuthFacade', () => { ); }); }); + + describe('Check Ability Resend Register Invite', () => { + it('should call checkRoleManagedByPermission if user status is PENDING', async () => { + const mockUser = mockUsers[2]; + jest.spyOn(facade, 'findUserByEmail').mockResolvedValue(mockUser); + const permissions = [PermissionEnum.MANAGE_CC_MEMBERS]; + + await facade.checkAbilityResendRegisterInvite( + mockUser.email, + permissions, + ); + + expect(facade.findUserByEmail).toHaveBeenCalledWith(mockUser.email); + expect(mockUserService.checkRoleManagedByPermission).toHaveBeenCalledWith( + mockUser.role, + permissions, + ); + }); + + it(`should throw error if user's status is not PENDING`, async () => { + const mockUser = mockUsers[1]; + jest.spyOn(facade, 'findUserByEmail').mockResolvedValue(mockUser); + const permissions = [PermissionEnum.MANAGE_CC_MEMBERS]; + + try { + await facade.checkAbilityResendRegisterInvite( + mockUser.email, + permissions, + ); + } catch (e) { + expect(e).toBeInstanceOf(ConflictException); + expect(e.status).toEqual(409); + expect(e.message).toEqual(`Unable to resend register invite`); + } + }); + + it('should throw NotFoundException if user with given email is not found', async () => { + const mockUser = mockUsers[2]; + mockUser.email = 'not-existing-email@test.com'; + jest + .spyOn(facade, 'findUserByEmail') + .mockRejectedValue( + new NotFoundException(`User with this email address not found`), + ); + const permissions = [PermissionEnum.MANAGE_CC_MEMBERS]; + try { + await facade.checkAbilityResendRegisterInvite( + mockUser.email, + permissions, + ); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.status).toEqual(404); + expect(e.message).toEqual(`User with this email address not found`); + } + }); + + it('should call checkRoleManagedByPermission if user status is PENDING', async () => { + const mockUser = mockUsers[2]; + mockUser.role = RoleEnum.ADMIN; + jest.spyOn(facade, 'findUserByEmail').mockResolvedValue(mockUser); + const permissions = [PermissionEnum.ADD_CONSTITUTION]; + + try { + await facade.checkAbilityResendRegisterInvite( + mockUser.email, + permissions, + ); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + expect(e.status).toEqual(403); + expect(e.message).toEqual(`You have no permission for this action`); + } + }); + }); }); diff --git a/backend/src/constitution/facade/constitution.facade.spec.ts b/backend/src/constitution/facade/constitution.facade.spec.ts index 172f2e55..d4cdc6e8 100644 --- a/backend/src/constitution/facade/constitution.facade.spec.ts +++ b/backend/src/constitution/facade/constitution.facade.spec.ts @@ -59,12 +59,16 @@ describe('ConstitutionFacade', () => { const mockFirstConstitutionMetadataResponse: ConstitutionMetadataResponse = { title: 'Revision 1', cid: 'bafkreibxlpnlpsg6ewqzxhslwyhzl4p4vc6bifj3nb4k2lxhbnfaojbmwy', + blake2b: 'f6f811fbde53b09c1b653766f27578cc867e9b634b9142800f56e282b041de00', + url: 'https://ipfs.io/ipfs/bafkreibxlpnlpsg6ewqzxhslwyhzl4p4vc6bifj3nb4k2lxhbnfaojbmwy', version: '1713769514', createdDate: '2024-04-21 11:21:59.334', }; const mockFirstConstitutionResponse: ConstitutionResponse = { cid: 'bafkreibxlpnlpsg6ewqzxhslwyhzl4p4vc6bifj3nb4k2lxhbnfaojbmwy', + blake2b: 'f6f811fbde53b09c1b653766f27578cc867e9b634b9142800f56e282b041de00', + url: 'https://ipfs.io/ipfs/bafkreibxlpnlpsg6ewqzxhslwyhzl4p4vc6bifj3nb4k2lxhbnfaojbmwy', version: '1713769514', contents: 'The morning sun cast a golden glow over the tranquil village, painting the cobblestone streets with warmth. Birds chirped melodiously, adding to the serene ambiance that enveloped the small community. Life moved at a leisurely pace here, far removed from the hustle and bustle of the city. Neighbors greeted each other with smiles and friendly nods as they went about their daily routines, weaving a tight-knit tapestry of camaraderie.\n', @@ -93,6 +97,8 @@ describe('ConstitutionFacade', () => { const mockSecondConstitutionMetadataResponse: ConstitutionMetadataResponse = { title: 'Revision 2', cid: 'bafkreich5c3rbz4amwevqy676czysmr27ctby46zdhja7gnpzriyqwdv4i', + blake2b: 'f6f811fbde53b09c1b653766f27578cc867e9b634b9142800f56e282b041de00', + url: 'https://ipfs.io/ipfs/bafkreich5c3rbz4amwevqy676czysmr27ctby46zdhja7gnpzriyqwdv4i', version: '1713769479', createdDate: '2024-04-22 11:21:59.334', }; diff --git a/backend/src/governance/facade/governance.facade.spec.ts b/backend/src/governance/facade/governance.facade.spec.ts index 3cd0ddaf..5dea1014 100644 --- a/backend/src/governance/facade/governance.facade.spec.ts +++ b/backend/src/governance/facade/governance.facade.spec.ts @@ -36,6 +36,8 @@ describe('GovernanceFacade', () => { status: UserStatusEnum.ACTIVE, role: null, permissions: [], + rationales: null, + votes: null, createdAt: null, updatedAt: null, deactivatedAt: null, @@ -50,6 +52,8 @@ describe('GovernanceFacade', () => { status: UserStatusEnum.ACTIVE, role: null, permissions: [], + rationales: null, + votes: null, createdAt: null, updatedAt: null, deactivatedAt: null, @@ -69,6 +73,8 @@ describe('GovernanceFacade', () => { hasRationale: null, submitTime: null, endTime: null, + votedBy: null, + rationaleBy: null, }, { id: '2', @@ -82,6 +88,8 @@ describe('GovernanceFacade', () => { hasRationale: null, submitTime: null, endTime: null, + votedBy: null, + rationaleBy: null, }, ]; @@ -568,7 +576,7 @@ describe('GovernanceFacade', () => { expect( mockGovernanceService.searchGovActionProposals, - ).toHaveBeenCalledWith(query, userId); + ).toHaveBeenCalledWith(query); }); it('should return an empty array - not found by userId', async () => { @@ -578,13 +586,22 @@ describe('GovernanceFacade', () => { limit: 10, path: 'randomPath', }; + mockGovernanceService.searchGovActionProposals.mockReturnValueOnce({ + items: [], + itemCount: 0, + pageOptions: { + page: query.page, + perPage: query.limit, + skip: 10, + }, + } as PaginatedDto); const result = await facade.searchGovActionProposals(query, userId); expect(result.data).toEqual([]); expect( mockGovernanceService.searchGovActionProposals, - ).toHaveBeenCalledWith(query, userId); + ).toHaveBeenCalledWith(query); }); it('should return an empty array - not found by search parameter', async () => { @@ -601,7 +618,7 @@ describe('GovernanceFacade', () => { expect(result.data).toEqual([]); expect( mockGovernanceService.searchGovActionProposals, - ).toHaveBeenCalledWith(query, userId); + ).toHaveBeenCalledWith(query); }); }); }); diff --git a/backend/src/governance/services/governance.service.spec.ts b/backend/src/governance/services/governance.service.spec.ts index 68fc8ac3..24374563 100644 --- a/backend/src/governance/services/governance.service.spec.ts +++ b/backend/src/governance/services/governance.service.spec.ts @@ -13,6 +13,9 @@ import { PaginatedDto } from 'src/util/pagination/dto/paginated.dto'; import { VoteDto } from '../dto/vote.dto'; import { VoteStatus } from '../enums/vote-status.enum'; import { VoteValue } from '../enums/vote-value.enum'; +import { User } from 'src/users/entities/user.entity'; +import { UserStatusEnum } from 'src/users/enums/user-status.enum'; +import { RoleEnum } from 'src/users/enums/role.enum'; describe('IpfsService', () => { let service: GovernanceService; @@ -30,6 +33,8 @@ describe('IpfsService', () => { hasRationale: null, submitTime: null, endTime: null, + votedBy: null, + rationaleBy: null, }, { id: '2', @@ -43,6 +48,8 @@ describe('IpfsService', () => { hasRationale: null, submitTime: null, endTime: null, + votedBy: null, + rationaleBy: null, }, ]; @@ -92,9 +99,34 @@ describe('IpfsService', () => { }, ]; + const mockUser: User = { + id: 'userId', + name: 'John Doe', + email: 'mockedEmail', + description: 'mockedDescription', + profilePhotoUrl: 'mockedProfilePhoto', + status: UserStatusEnum.ACTIVE, + role: { + id: 'roleId3', + code: RoleEnum.USER, + users: [], + permissions: [], + createdAt: null, + updatedAt: null, + }, + permissions: null, + hotAddresses: null, + rationales: null, + votes: null, + deactivatedAt: null, + createdAt: null, + updatedAt: null, + }; + const vote: Vote = { id: '1', userId: 'userId', + user: mockUser, hotAddress: 'hotAddress_1', govActionProposal: mockGovActionProposals[0], vote: VoteValue.Yes, @@ -109,6 +141,7 @@ describe('IpfsService', () => { { id: 'Vote_1', userId: 'User_1', + user: mockUser, hotAddress: 'hotAddress_1', govActionProposal: mockGovActionProposals[0], vote: VoteValue.Yes, @@ -121,6 +154,7 @@ describe('IpfsService', () => { { id: 'Vote_2', userId: 'User_1', + user: mockUser, hotAddress: 'hotAddress_1', govActionProposal: mockGovActionProposals[1], vote: VoteValue.Yes, @@ -448,9 +482,6 @@ describe('IpfsService', () => { search: 'govActionProposal_Title', path: 'randomPath', }; - jest - .spyOn(service, 'returnGapQuery') - .mockResolvedValueOnce(mockGovActionProposals[0]); mockPaginator.paginate.mockResolvedValue(paginatedValueGap); const gapPaginatedDto: PaginatedDto = await service.searchGovActionProposals(query); @@ -468,7 +499,6 @@ describe('IpfsService', () => { search: 'NotExisting', path: 'randomPath', }; - jest.spyOn(service, 'returnGapQuery').mockResolvedValueOnce([]); mockPaginator.paginate.mockResolvedValueOnce(paginatedEmptyValueGap); const gapPaginatedDto: PaginatedDto = await service.searchGovActionProposals(query); @@ -502,10 +532,9 @@ describe('IpfsService', () => { limit: 10, path: 'randomPath', }; - const userId = 'user1'; mockPaginator.paginate.mockResolvedValueOnce(paginatedMultiValueGap); const gapPaginatedDto: PaginatedDto = - await service.searchGovActionProposals(query, userId); + await service.searchGovActionProposals(query); expect(gapPaginatedDto.items[0].title).toEqual( mockGovActionProposals[0].title, ); diff --git a/backend/src/s3/service/s3.service.spec.ts b/backend/src/s3/service/s3.service.spec.ts index 0e56d551..50e902d7 100644 --- a/backend/src/s3/service/s3.service.spec.ts +++ b/backend/src/s3/service/s3.service.spec.ts @@ -11,10 +11,13 @@ const mockBucket = { }; const mockConfigService = { - get: jest.fn().mockImplementation((minioBucked) => { - if (minioBucked === 'MINIO_BUCKET') { + get: jest.fn().mockImplementation((variable) => { + if (variable === 'MINIO_BUCKET') { return 'cc-portal'; } + if (variable === 'S3_BASE_URL') { + return 'https://cc-portal.s3.amazonaws.com'; + } }), }; @@ -88,13 +91,14 @@ describe('S3Service', () => { const result = await service.uploadFile(context, fileName, file); expect(result).toBe( - 'https://cc-portal.s3.amazonaws.com/profile-photo-test-upload.txt', + 'https://cc-portal.s3.amazonaws.com/cc-portal/profile-photo-test-upload.txt', ); expect(mockMinioClient.putObject).toHaveBeenCalledWith( 'cc-portal', 'profile-photo-test-upload.txt', expect.any(Buffer), 100, + { 'Content-Type': undefined }, ); }); diff --git a/backend/src/users/facade/users.facade.spec.ts b/backend/src/users/facade/users.facade.spec.ts index cd2fead5..b917d127 100644 --- a/backend/src/users/facade/users.facade.spec.ts +++ b/backend/src/users/facade/users.facade.spec.ts @@ -4,6 +4,7 @@ import { UsersService } from '../services/users.service'; import { ConflictException, ForbiddenException, + Logger, NotFoundException, } from '@nestjs/common'; import { UserStatusEnum } from '../enums/user-status.enum'; @@ -23,6 +24,7 @@ import { RoleFactory } from '../role/role.factory'; describe('UsersFacade', () => { let facade: UsersFacade; + let logger: Logger; let mockRoles: Role[] = [ { @@ -215,13 +217,6 @@ describe('UsersFacade', () => { ); } - // const sortedUsers = filteredUsers.sort((a, b) => { - // if (query.sortBy === SortOrder.ASC) { - // return a.name > b.name ? 1 : -1; - // } else if (order === SortOrder.DESC) { - // return b.name > a.name ? -1 : -1; - // } - // }); const currentPosition = query.page * query.limit; const paginatedUsers = filteredUsers.slice( currentPosition, @@ -241,6 +236,8 @@ describe('UsersFacade', () => { return usersPaginatedDto; }), updateUserStatus: jest.fn(), + removeUser: jest.fn(), + checkRoleManagedByPermission: jest.fn(), }; const mockS3Service = { @@ -264,10 +261,17 @@ describe('UsersFacade', () => { provide: RoleFactory, useValue: mockRoleFactory, }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, ], }).compile(); facade = module.get(UsersFacade); + logger = module.get(Logger); }); afterEach(() => { @@ -490,7 +494,7 @@ describe('UsersFacade', () => { }); it('should deactivate an admin', async () => { - const user = mockUsers[0]; + const user = mockUsers[1]; const request: ToggleStatusRequest = { userId: user.id, status: UserStatusEnum.INACTIVE, @@ -516,7 +520,7 @@ describe('UsersFacade', () => { }); it(`shouldn't deactivate an admin - no permission`, async () => { - const user = mockUsers[0]; + const user = mockUsers[1]; const request: ToggleStatusRequest = { userId: user.id, status: UserStatusEnum.INACTIVE, @@ -535,7 +539,7 @@ describe('UsersFacade', () => { }); it(`shouldn't deactivate a super admin - no permission`, async () => { - const user = mockUsers[0]; + const user = mockUsers[1]; const request: ToggleStatusRequest = { userId: user.id, status: UserStatusEnum.INACTIVE, @@ -574,4 +578,49 @@ describe('UsersFacade', () => { } }); }); + + describe(`Remove user`, () => { + it('should remove a user and delete their profile photo if it exists', async () => { + const mockUserDto = mockUsers[0]; + jest.spyOn(mockS3Service, 'deleteFile').mockResolvedValue('profile.jpg'); + jest.spyOn(mockUserService, 'removeUser').mockResolvedValue(undefined); + + await facade.removeUser(mockUserDto.id); + + // Assert: Ensure that the user was removed and file was deleted + expect(mockUserService.findById).toHaveBeenCalledWith(mockUserDto.id); + expect(mockUserService.removeUser).toHaveBeenCalledWith(mockUserDto.id); + expect(mockS3Service.deleteFile).toHaveBeenCalledWith('profile.jpg'); + }); + + it('should remove a user without attempting to delete the profile photo if none exists', async () => { + const mockUserDto = mockUsers[0]; + mockUserDto.profilePhotoUrl = null; + jest.spyOn(mockUserService, 'removeUser').mockResolvedValue(undefined); + + await facade.removeUser(mockUserDto.id); + + // Assert: Ensure the profile photo was not deleted + expect(mockUserService.findById).toHaveBeenCalledWith(mockUserDto.id); + expect(mockUserService.removeUser).toHaveBeenCalledWith(mockUserDto.id); + expect(mockS3Service.deleteFile).not.toHaveBeenCalled(); + }); + + it('should log an error if deleting the profile photo fails', async () => { + const mockUserDto = mockUsers[0]; + jest + .spyOn(mockS3Service, 'deleteFile') + .mockRejectedValue(new Error('Deletion error')); + jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + + try { + await facade.removeUser(mockUserDto.id); + } catch (error) { + // Assert: Ensure the error is logged but the process doesn't crash + expect(logger.error).toHaveBeenCalledWith( + `Error when removing profile photo of the user with id ${mockUserDto.id}: Deletion error`, + ); + } + }); + }); }); diff --git a/backend/src/users/services/users.service.spec.ts b/backend/src/users/services/users.service.spec.ts index b0199aa6..521c9670 100644 --- a/backend/src/users/services/users.service.spec.ts +++ b/backend/src/users/services/users.service.spec.ts @@ -18,6 +18,7 @@ import { UserDto } from '../dto/user.dto'; import { PaginateQuery, Paginated } from 'nestjs-paginate'; import { Paginator } from 'src/util/pagination/paginator'; import { RoleEnum } from '../enums/role.enum'; +import { RoleFactory } from '../role/role.factory'; const mockS3Service = { uploadFileMinio: jest.fn().mockResolvedValue('mocked_file_name'), createBucketIfNotExists: jest.fn().mockResolvedValue('new_bucket'), @@ -41,6 +42,8 @@ const user: User = { }, permissions: null, hotAddresses: null, + rationales: null, + votes: null, deactivatedAt: null, createdAt: null, updatedAt: null, @@ -130,6 +133,8 @@ const mockUsers: User[] = [ status: UserStatusEnum.ACTIVE, role: mockRoles[2], permissions: [], + rationales: null, + votes: null, deactivatedAt: null, createdAt: null, updatedAt: null, @@ -144,6 +149,8 @@ const mockUsers: User[] = [ status: UserStatusEnum.ACTIVE, role: mockRoles[1], permissions: [mockPermissions[0], mockPermissions[1]], + rationales: null, + votes: null, deactivatedAt: null, createdAt: null, updatedAt: null, @@ -243,6 +250,7 @@ const mockUserRepository = { return mockUsers; }), count: jest.fn().mockResolvedValue(mockUsers.length), + remove: jest.fn(), }; const mockRoleRepository = { create: jest.fn().mockReturnValue({}), @@ -327,6 +335,10 @@ describe('UsersService', () => { provide: ConfigService, useValue: {}, }, + { + provide: RoleFactory, + useValue: {}, + }, ], }).compile(); @@ -709,4 +721,24 @@ describe('UsersService', () => { expect(mockPaginator.paginate).toHaveBeenCalled(); }); }); + + describe('removeUser', () => { + it('should remove a user successfully', async () => { + const mockUser = mockUsers[0]; + const mockFindEntityById = jest + .spyOn(service, 'findEntityById') + .mockResolvedValueOnce(mockUser); + await service.removeUser(mockUser.id); + expect(mockFindEntityById).toHaveBeenCalledWith(mockUser.id); + expect(mockUserRepository.remove).toHaveBeenCalledWith(mockUser); + }); + it('should throw NotFoundException if user is not found', async () => { + jest + .spyOn(service, 'findEntityById') + .mockRejectedValue(new NotFoundException()); + await expect(service.removeUser('non-existent-user')).rejects.toThrow( + NotFoundException, + ); + }); + }); });