diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 345a6ad425c198121e1c540c3e6bcd6d7709bca6..4508148b7324ad44d70b35331bf426f9003961d1 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -60,8 +60,8 @@ export class AuthService { * @param id - ID of the user to be deleted. * @returns Resolves when deletion is successful. */ - public async delete(id: number): Promise<void> { - return await this.users.delete(id); + public async delete(id: number): Promise<number> { + return this.users.delete(id); } /** diff --git a/src/user/mock/user.repository.mock.ts b/src/user/mock/user.repository.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..80772ae744c8cce55f2ab02c9ff49a86e561f459 --- /dev/null +++ b/src/user/mock/user.repository.mock.ts @@ -0,0 +1,39 @@ +import { User } from '../user.entity'; + +export class MockUserRepository { + constructor(private readonly testUser: User) {} + + public async findOne({ id, email }): Promise<User | null> { + if (id === 1 || email === 'test@example.com') { + return Promise.resolve(this.testUser); + } + return Promise.resolve(null); + } + + public async findAll(): Promise<User[]> { + return Promise.resolve([ + new User('Test User 1', 'test1@example.com', 'Test Org', 'password'), + new User('Test User 2', 'test2@example.com', 'Test Org', 'password'), + ]); + } + + public async find(ids: number[]): Promise<User[]> { + const result: User[] = []; + if (ids.includes(1)) { + result.push(this.testUser); + } + if (ids.includes(2)) { + result.push( + new User('Test User 2', 'test2@example.com', 'Test Org', 'password'), + ); + } + return Promise.resolve(result); + } + + public async nativeDelete({ id }: { id: number }): Promise<number> { + if (id === 1) { + return Promise.resolve(1); + } + return Promise.resolve(0); + } +} diff --git a/src/user/mock/user.service.mock.ts b/src/user/mock/user.service.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..37a8fe100ab5e5b2a75dd2fd1d9f138a32d77102 --- /dev/null +++ b/src/user/mock/user.service.mock.ts @@ -0,0 +1,42 @@ +import { User } from '../user.entity'; +import { EditUser } from '../user.controller'; + +export class MockUserService { + constructor(private readonly testUser: User) {} + + public async findAll(): Promise<User[]> { + return Promise.resolve([ + new User('Test User 1', 'test1@example.com', 'Test Org', 'password'), + new User('Test User 2', 'test2@example.com', 'Test Org', 'password'), + ]); + } + + public async changeRole(id: number): Promise<User> { + if (id === 1) { + this.testUser.role = this.testUser.role === 'user' ? 'admin' : 'user'; + return Promise.resolve(this.testUser); + } + throw new Error('User not found'); + } + + public async changeUserData(data: EditUser): Promise<boolean> { + if (data.id === 1) { + this.testUser.name = data.name; + this.testUser.email = data.email; + this.testUser.organization = data.organization; + return Promise.resolve(true); + } + throw new Error('User not found'); + } + + public async delete(id: number): Promise<number> { + if (id === 1) { + return Promise.resolve(1); + } + throw new Error('User not found'); + } + + public async changePassword(): Promise<void> { + // + } +} diff --git a/src/user/user.controller.spec.ts b/src/user/user.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..73cd119871a756882d82a54a33726829d70acbae --- /dev/null +++ b/src/user/user.controller.spec.ts @@ -0,0 +1,188 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; +import { AuthService } from '../auth/auth.service'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from '../auth/auth.guard'; +import { isGuarded } from '../../test/utils'; +import { AdminGuard } from '../common/guards/admin.guard'; +import { User } from './user.entity'; +import * as bcrypt from 'bcrypt'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { MockUserService } from './mock/user.service.mock'; + +const TEST_USER = { + id: 1, + name: 'Test User', + email: 'test@example.com', + organization: 'Test Organization', + password: bcrypt.hashSync('password', UserService.SALT_OR_ROUNDS), + createdAt: new Date(), + updatedAt: new Date(), + role: 'user', +} as unknown as User; + +/** + * Test suite to verify the behavior of the UserController. + */ +describe('UserController', () => { + let controller: UserController; + + /** + * Before each test case, create a testing module with UserController. + * Compile the module and obtain an instance of UserController. + * This ensures a fresh instance is available for each test case. + */ + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [ + { + provide: UserService, + useValue: new MockUserService(TEST_USER), + }, + { + provide: AuthService, + useValue: { + user: () => Promise.resolve(TEST_USER), + }, + }, + { + provide: JwtService, + useValue: {}, + }, + ], + }).compile(); + + controller = module.get<UserController>(UserController); + }); + + /** + * Test case: Ensure that UserController is defined. + * This test validates that the controller instance was created successfully. + */ + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('should return an array of users', async () => { + const users = await controller.findAll(); + expect(users).toHaveLength(2); + }); + + it('should be protected with AuthGuard', () => { + expect(isGuarded(controller.findAll, AuthGuard)).toBe(true); + }); + + it('should be protected with AdminGuard', () => { + expect(isGuarded(controller.findAll, AdminGuard)).toBe(true); + }); + }); + + describe('changeRole', () => { + it('should change the role of the user', async () => { + let user = await controller.changeRole({ id: 1 }); + expect(user.role).toBe('admin'); + + user = await controller.changeRole({ id: 1 }); + expect(user.role).toBe('user'); + }); + + it('should throw an error if the user is not found', async () => { + await expect(controller.changeRole({ id: 2 })).rejects.toThrow( + 'User not found', + ); + }); + + it('should be protected with AuthGuard', () => { + expect(isGuarded(controller.changeRole, AuthGuard)).toBe(true); + }); + + it('should be protected with AdminGuard', () => { + expect(isGuarded(controller.changeRole, AdminGuard)).toBe(true); + }); + }); + + describe('changeUserData', () => { + it('should change the data of the user', async () => { + const data = { + id: 1, + name: 'New Name', + email: 'newmail@example.com', + organization: 'New Organization', + }; + const result = await controller.changeUserData(data); + + expect(result).toBe(true); + expect(TEST_USER.name).toBe(data.name); + expect(TEST_USER.email).toBe(data.email); + expect(TEST_USER.organization).toBe(data.organization); + }); + + it('should throw an error if the user is not found', async () => { + const data = { + id: 2, + name: 'New Name', + email: 'newmail@example.com', + organization: 'New Organization', + }; + + await expect(controller.changeUserData(data)).rejects.toThrow( + 'User not found', + ); + }); + + it('should be protected with AuthGuard', () => { + expect(isGuarded(controller.changeUserData, AuthGuard)).toBe(true); + }); + }); + + describe('delete', () => { + it('should delete the user', async () => { + const result = await controller.delete(1); + expect(result).toEqual({ message: 'success' }); + }); + + it('should throw an error if the user is not found', async () => { + await expect(controller.delete(2)).rejects.toThrow('User not found'); + }); + + it('should be protected with AuthGuard', () => { + expect(isGuarded(controller.delete, AuthGuard)).toBe(true); + }); + + it('should be protected with AdminGuard', () => { + expect(isGuarded(controller.delete, AdminGuard)).toBe(true); + }); + }); + + describe('changePassword', () => { + it('should change the password of the user', async () => { + const data = { + currentPassword: 'password', + newPassword: 'newpassword', + confirmNewPassword: 'newpassword', + }; + const result = await controller.changePassword(data); + + expect(result).toBe(true); + }); + + it('should throw an error if the current password is incorrect', async () => { + const data = { + currentPassword: 'wrongpassword', + newPassword: 'newpassword', + confirmNewPassword: 'newpassword', + }; + + await expect(controller.changePassword(data)).rejects.toThrow( + UnprocessableEntityException, + ); + }); + + it('should be protected with AuthGuard', () => { + expect(isGuarded(controller.changePassword, AuthGuard)).toBe(true); + }); + }); +}); diff --git a/src/user/user.entity.spec.ts b/src/user/user.entity.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c18e4a4dfafc901db53a0b292608fd2afac7d1bc --- /dev/null +++ b/src/user/user.entity.spec.ts @@ -0,0 +1,43 @@ +import { User } from './user.entity'; + +describe('User Entity', () => { + describe('constructor', () => { + it('should create a user with correct properties', () => { + const name = 'Test User'; + const email = 'test@example.com'; + const organization = 'Test Org'; + const password = 'password'; + + const user = new User(name, email, organization, password); + + expect(user.name).toBe(name); + expect(user.email).toBe(email); + expect(user.organization).toBe(organization); + expect(user.password).toBe(password); + }); + }); + + it('should set role to user by default', () => { + const user = new User( + 'Test User', + 'test@example.com', + 'Test Org', + 'password', + ); + + expect(user.role).toBe('user'); // Default value + }); + + it('should set default dates on creation', () => { + const user = new User( + 'Test User', + 'test@example.com', + 'Test Org', + 'password', + ); + const currentDate = new Date(); + + expect(user.createdAt.getTime()).toBeLessThanOrEqual(currentDate.getTime()); + expect(user.updatedAt.getTime()).toBeLessThanOrEqual(currentDate.getTime()); + }); +}); diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb76c6b65771c2e9449afd427fb51b6eaf55947e --- /dev/null +++ b/src/user/user.service.spec.ts @@ -0,0 +1,207 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserService } from './user.service'; +import { EntityManager } from '@mikro-orm/core'; +import { getRepositoryToken } from '@mikro-orm/nestjs'; +import { User } from './user.entity'; +import * as bcrypt from 'bcrypt'; +import { MockUserRepository } from './mock/user.repository.mock'; + +const TEST_USER = { + id: 1, + name: 'Test User', + email: 'test@example.com', + organization: 'Test Organization', + password: bcrypt.hashSync('password', UserService.SALT_OR_ROUNDS), + createdAt: new Date(), + updatedAt: new Date(), + role: 'user', +} as unknown as User; + +/** + * Test suite for UserService. + */ +describe('UserService', () => { + let service: UserService; + let entityManager: EntityManager; + + /** + * Before each test case, create a testing module and compile it. + * Obtain an instance of UserService for testing. + */ + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: EntityManager, + useValue: { + persistAndFlush: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: new MockUserRepository(TEST_USER), + }, + ], + }).compile(); + + service = module.get<UserService>(UserService); + entityManager = module.get<EntityManager>(EntityManager); + }); + + /** + * Test case to verify that UserService is defined. + */ + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('should return a user by id', async () => { + const user = await service.findOne(1); + expect(user).toEqual(TEST_USER); + }); + + it('should return null if no user is found', async () => { + const user = await service.findOne(2); + expect(user).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('should return a user by email', async () => { + const user = await service.findByEmail('test@example.com'); + expect(user).toEqual(TEST_USER); + }); + + it('should return null if no user is found', async () => { + const user = await service.findByEmail('wrongemail@example.com'); + expect(user).toBeNull(); + }); + }); + + describe('findAll', () => { + it('should return all users', async () => { + const users = await service.findAll(); + expect(users).toHaveLength(2); + }); + }); + + describe('find', () => { + it('should return users by ids', async () => { + const users = await service.find([1, 2]); + expect(users).toHaveLength(2); + }); + + it('should return only users with matching ids', async () => { + const users = await service.find([1, 3]); + expect(users).toHaveLength(1); + }); + }); + + describe('create', () => { + it('should create a user', async () => { + const createUser = { + name: 'New Test User', + email: 'newtest@example.com', + organization: 'Test Org', + password: 'password', + }; + const user = await service.create(createUser); + + expect(user.name).toEqual(createUser.name); + expect(user.email).toEqual(createUser.email); + expect(user.organization).toEqual(createUser.organization); + expect(entityManager.persistAndFlush).toHaveBeenCalled(); + }); + + it('should not store the password in plain text', async () => { + const createUser = { + name: 'New Test User', + email: 'newtest@example.com', + organization: 'Test Org', + password: 'password', + }; + + const user = await service.create(createUser); + expect(user.password).not.toEqual(createUser.password); + }); + }); + + describe('changePassword', () => { + it('should change the password', async () => { + const prevPassword = TEST_USER.password; + await service.changePassword(TEST_USER, 'newpassword'); + + expect(TEST_USER.password).not.toEqual(prevPassword); + expect(entityManager.persistAndFlush).toHaveBeenCalled(); + }); + + it('should not store the password in plain text', async () => { + await service.changePassword(TEST_USER, 'newpassword'); + expect(TEST_USER.password).not.toEqual('newpassword'); + }); + }); + + describe('delete', () => { + it('should delete a user', async () => { + const result = await service.delete(1); + expect(result).toEqual(1); + }); + + it('should throw an error if the user does not exist', async () => { + await expect(service.delete(2)).rejects.toThrowError('User not found'); + }); + }); + + describe('changeUserData', () => { + it('should change the user data', async () => { + const changeUserData = { + id: 1, + name: 'Updated Test User', + email: 'updatedmail@example.com', + organization: 'Updated Test Org', + }; + const result = await service.changeUserData(changeUserData); + + expect(TEST_USER.name).toEqual(changeUserData.name); + expect(TEST_USER.email).toEqual(changeUserData.email); + expect(TEST_USER.organization).toEqual(changeUserData.organization); + expect(entityManager.persistAndFlush).toHaveBeenCalled(); + expect(result).toBeTruthy(); + }); + + /** + * Wird aktuell noch nicht abgefangen. + */ + it('should throw an error if the user does not exist', async () => { + const changeUserData = { + id: 2, + name: 'Updated Test User', + email: 'updatedmail@example.com', + organization: 'Updated Test Org', + }; + await expect(service.changeUserData(changeUserData)).rejects.toThrowError( + 'User not found', + ); + }); + }); + + describe('changeRole', () => { + it('should toggle the role', async () => { + let user = await service.changeRole(1); + expect(user.role).toEqual('admin'); + expect(entityManager.persistAndFlush).toHaveBeenCalledTimes(1); + + user = await service.changeRole(1); + expect(user.role).toEqual('user'); + expect(entityManager.persistAndFlush).toHaveBeenCalledTimes(2); + }); + + it('should throw an error if the user does not exist', async () => { + await expect(service.changeRole(2)).rejects.toThrowError( + 'User not found', + ); + }); + }); +}); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 1d7068c3e1ec0e655e9bf3e59efe610b643bdb9c..b1dda94e67de07fc0d20336cacda7fe0861db537 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -26,7 +26,7 @@ export class UserService { * @returns A Promise that resolves to the found user or null if not found. */ public async findOne(id: number): Promise<User | null> { - return this.repository.findOne(id); + return this.repository.findOne({ id }); } /** @@ -86,14 +86,16 @@ export class UserService { /** * Delete a user by ID. * @param id - The user's ID. + * @returns A Promise that resolves to the number of deleted users. */ - public async delete(id: number): Promise<void> { + public async delete(id: number): Promise<number> { const user = await this.repository.findOne({ id }); if (!user) { throw new Error('User not found'); } - await this.repository.nativeDelete({ id }); + + return this.repository.nativeDelete({ id }); } /** @@ -103,6 +105,11 @@ export class UserService { */ public async changeUserData(data: EditUser): Promise<boolean> { const user = await this.findOne(data.id); + + if (!user) { + throw new Error('User not found'); + } + user.name = data.name; user.email = data.email; user.organization = data.organization; diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4970c57abc22e050f5c7083c10c1678c67fb757b --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,36 @@ +import { CanActivate } from '@nestjs/common'; + +/** + * Checks whether a route or a Controller is protected with the specified Guard. + * @param route is the route or Controller to be checked for the Guard. + * @param guardType is the type of the Guard, e.g. JwtAuthGuard. + * @returns true if the specified Guard is applied. + */ +export function isGuarded( + route: ((...args: any[]) => any) | (new (...args: any[]) => unknown), + guardType: new (...args: any[]) => CanActivate, +) { + const guards: any[] = Reflect.getMetadata('__guards__', route); + + if (!guards) { + throw Error( + `Expected: ${route.name} to be protected with ${guardType.name}\nReceived: No guard`, + ); + } + + let foundGuard = false; + const guardList: string[] = []; + guards.forEach((guard) => { + guardList.push(guard.name); + if (guard.name === guardType.name) { + foundGuard = true; + } + }); + + if (!foundGuard) { + throw Error( + `Expected: ${route.name} to be protected with ${guardType.name}\nReceived: only ${guardList}`, + ); + } + return true; +}