- Introduced `EmployeeUserWrite` type to manage user details associated with employees. - Updated `EmployeesService` and `EmployeesRepository` to support user assignment and retrieval by user ID. - Enhanced DTOs to include user information for employee creation and updates. - Implemented validation to ensure proper handling of user data during employee operations. - Added unit and e2e tests to validate the new functionality and ensure data integrity in user-employee relationships. - Modified existing controllers to accommodate the new user linkage features in employee management.
364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
|
import { PhoneNumber } from '../../common/value-objects/phone-number/phone-number';
|
|
import { Status } from '../../common/value-objects/status/status';
|
|
import type { Employee } from '../configuration/employees/employee';
|
|
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
|
import { PrivilegesService } from '../privileges/privileges.service';
|
|
import type { User } from './user';
|
|
import { UsersRepository } from './users.repository';
|
|
import { UsersService } from './users.service';
|
|
|
|
describe('UsersService', () => {
|
|
let service: UsersService;
|
|
let repository: jest.Mocked<
|
|
Pick<
|
|
UsersRepository,
|
|
| 'findById'
|
|
| 'findByUsername'
|
|
| 'create'
|
|
| 'update'
|
|
| 'updatePrivilegeId'
|
|
| 'updateStatus'
|
|
| 'bulkUpdateStatus'
|
|
| 'delete'
|
|
| 'bulkDelete'
|
|
| 'list'
|
|
>
|
|
>;
|
|
let privilegesService: jest.Mocked<
|
|
Pick<PrivilegesService, 'findPrivilegeSummary'>
|
|
>;
|
|
let employeesRepository: jest.Mocked<
|
|
Pick<EmployeesRepository, 'findById' | 'findByUserId' | 'update'>
|
|
>;
|
|
|
|
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
|
const sampleUser: User = {
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
passwordHash: 'hashed',
|
|
privilegeId: null,
|
|
isSuperadmin: false,
|
|
status: Status.create('draft'),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
createdBy: 'user-1',
|
|
updatedBy: 'user-1',
|
|
privilege: null,
|
|
employee: null,
|
|
createdByUser: { id: 'user-1', username: 'alice' },
|
|
updatedByUser: { id: 'user-1', username: 'alice' },
|
|
};
|
|
|
|
const sampleEmployee: Employee = {
|
|
id: 'emp-1',
|
|
code: 'EMP_01',
|
|
name: 'Ada Lovelace',
|
|
phone: PhoneNumber.create('+6281234567890'),
|
|
position: 'sales',
|
|
status: Status.create('draft'),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
createdBy: 'actor-1',
|
|
updatedBy: 'actor-1',
|
|
userId: null,
|
|
user: null,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
repository = {
|
|
findById: jest.fn(),
|
|
findByUsername: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
updatePrivilegeId: jest.fn(),
|
|
updateStatus: jest.fn(),
|
|
bulkUpdateStatus: jest.fn(),
|
|
delete: jest.fn(),
|
|
bulkDelete: jest.fn(),
|
|
list: jest.fn(),
|
|
};
|
|
privilegesService = {
|
|
findPrivilegeSummary: jest.fn(),
|
|
};
|
|
employeesRepository = {
|
|
findById: jest.fn(),
|
|
findByUserId: jest.fn(),
|
|
update: jest.fn(),
|
|
};
|
|
|
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
UsersService,
|
|
{ provide: UsersRepository, useValue: repository },
|
|
{ provide: PrivilegesService, useValue: privilegesService },
|
|
{ provide: EmployeesRepository, useValue: employeesRepository },
|
|
{
|
|
provide: ConfigService,
|
|
useValue: { getOrThrow: () => 4 },
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = moduleRef.get(UsersService);
|
|
});
|
|
|
|
it('findById delegates to repository', async () => {
|
|
repository.findById.mockResolvedValue(sampleUser);
|
|
await expect(service.findById('user-1')).resolves.toEqual(sampleUser);
|
|
});
|
|
|
|
it('create stores lowercase username', async () => {
|
|
repository.findByUsername.mockResolvedValue(null);
|
|
repository.create.mockResolvedValue(sampleUser);
|
|
|
|
await service.create('Alice', 'hashed');
|
|
|
|
expect(repository.findByUsername).toHaveBeenCalledWith('alice');
|
|
expect(repository.create).toHaveBeenCalledWith({
|
|
username: 'alice',
|
|
passwordHash: 'hashed',
|
|
});
|
|
});
|
|
|
|
it('create throws ConflictException when username exists', async () => {
|
|
repository.findByUsername.mockResolvedValue(sampleUser);
|
|
|
|
await expect(service.create('alice', 'hashed')).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
expect(repository.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('findByUsername delegates to repository', async () => {
|
|
repository.findByUsername.mockResolvedValue(sampleUser);
|
|
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
|
|
});
|
|
|
|
it('assignPrivilege validates privilege exists', async () => {
|
|
repository.findById.mockResolvedValue(sampleUser);
|
|
privilegesService.findPrivilegeSummary.mockResolvedValue({
|
|
id: 'priv-1',
|
|
name: 'Admin',
|
|
code: 'ADMIN',
|
|
status: 'active',
|
|
});
|
|
repository.updatePrivilegeId.mockResolvedValue({
|
|
...sampleUser,
|
|
privilegeId: 'priv-1',
|
|
privilege: { id: 'priv-1', code: 'ADMIN', name: 'Admin' },
|
|
});
|
|
|
|
const result = await service.assignPrivilege('user-1', 'priv-1');
|
|
expect(result.privilege).toEqual({
|
|
id: 'priv-1',
|
|
code: 'ADMIN',
|
|
name: 'Admin',
|
|
});
|
|
expect(result).not.toHaveProperty('passwordHash');
|
|
});
|
|
|
|
it('list maps nested relations and omits password', async () => {
|
|
repository.list.mockResolvedValue({ data: [sampleUser], total: 1 });
|
|
const result = await service.list({ page: 1, limit: 10 });
|
|
expect(result.total).toBe(1);
|
|
expect(result.data[0]).toMatchObject({
|
|
id: 'user-1',
|
|
username: 'alice',
|
|
status: 'draft',
|
|
privilege: null,
|
|
employee: null,
|
|
createdBy: { id: 'user-1', username: 'alice' },
|
|
});
|
|
expect(result.data[0]).not.toHaveProperty('passwordHash');
|
|
});
|
|
|
|
it('update rejects status field', async () => {
|
|
await expect(
|
|
service.update('user-1', { status: 'active', actorUserId: 'user-1' }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('assertCanAuthenticate allows active users without employee', () => {
|
|
expect(() =>
|
|
service.assertCanAuthenticate({
|
|
...sampleUser,
|
|
status: Status.create('active'),
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('assertCanAuthenticate rejects draft users and inactive employees', () => {
|
|
expect(() => service.assertCanAuthenticate(sampleUser)).toThrow(
|
|
UnauthorizedException,
|
|
);
|
|
expect(() =>
|
|
service.assertCanAuthenticate({
|
|
...sampleUser,
|
|
status: Status.create('active'),
|
|
employee: {
|
|
id: 'emp-1',
|
|
code: 'EMP_01',
|
|
name: 'Ada',
|
|
status: Status.create('draft'),
|
|
},
|
|
}),
|
|
).toThrow(UnauthorizedException);
|
|
});
|
|
|
|
it('importCsv requires username and password headers', async () => {
|
|
await expect(
|
|
service.importCsv('name\nalice', 'user-1'),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('createManaged links an existing employee by id', async () => {
|
|
repository.create.mockResolvedValue(sampleUser);
|
|
employeesRepository.findById.mockResolvedValue(sampleEmployee);
|
|
employeesRepository.update.mockResolvedValue({
|
|
...sampleEmployee,
|
|
userId: 'user-1',
|
|
});
|
|
repository.findById.mockResolvedValue({
|
|
...sampleUser,
|
|
employee: {
|
|
id: 'emp-1',
|
|
code: 'EMP_01',
|
|
name: 'Ada Lovelace',
|
|
status: Status.create('draft'),
|
|
},
|
|
});
|
|
|
|
const result = await service.createManaged({
|
|
username: 'alice',
|
|
password: 'password123',
|
|
actorUserId: 'actor-1',
|
|
employeeId: 'emp-1',
|
|
});
|
|
|
|
expect(employeesRepository.update).toHaveBeenCalledWith(
|
|
'emp-1',
|
|
expect.objectContaining({
|
|
userId: 'actor-1',
|
|
assignedUserId: 'user-1',
|
|
}),
|
|
);
|
|
expect(result.employee).toEqual({
|
|
id: 'emp-1',
|
|
code: 'EMP_01',
|
|
name: 'Ada Lovelace',
|
|
});
|
|
});
|
|
|
|
it('createManaged rejects linking an employee assigned to another user', async () => {
|
|
repository.create.mockResolvedValue(sampleUser);
|
|
employeesRepository.findById.mockResolvedValue({
|
|
...sampleEmployee,
|
|
userId: 'other-user',
|
|
});
|
|
repository.delete.mockResolvedValue(undefined);
|
|
|
|
await expect(
|
|
service.createManaged({
|
|
username: 'alice',
|
|
password: 'password123',
|
|
actorUserId: 'actor-1',
|
|
employeeId: 'emp-1',
|
|
}),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
expect(repository.delete).toHaveBeenCalledWith('user-1');
|
|
});
|
|
|
|
it('update with employeeId null unlinks the current employee', async () => {
|
|
repository.update.mockResolvedValue({
|
|
...sampleUser,
|
|
employee: {
|
|
id: 'emp-1',
|
|
code: 'EMP_01',
|
|
name: 'Ada Lovelace',
|
|
status: Status.create('draft'),
|
|
},
|
|
});
|
|
employeesRepository.findByUserId.mockResolvedValue({
|
|
...sampleEmployee,
|
|
userId: 'user-1',
|
|
});
|
|
employeesRepository.update.mockResolvedValue({
|
|
...sampleEmployee,
|
|
userId: null,
|
|
});
|
|
repository.findById.mockResolvedValue({ ...sampleUser, employee: null });
|
|
|
|
const result = await service.update('user-1', {
|
|
actorUserId: 'actor-1',
|
|
employeeId: null,
|
|
});
|
|
|
|
expect(employeesRepository.update).toHaveBeenCalledWith(
|
|
'emp-1',
|
|
expect.objectContaining({ assignedUserId: null, userId: 'actor-1' }),
|
|
);
|
|
expect(result.employee).toBeNull();
|
|
});
|
|
|
|
it('update with employeeId reassigns after unlinking the previous employee', async () => {
|
|
repository.update.mockResolvedValue(sampleUser);
|
|
employeesRepository.findById.mockResolvedValue({
|
|
...sampleEmployee,
|
|
id: 'emp-2',
|
|
code: 'EMP_02',
|
|
});
|
|
employeesRepository.findByUserId.mockResolvedValue({
|
|
...sampleEmployee,
|
|
userId: 'user-1',
|
|
});
|
|
employeesRepository.update.mockResolvedValue(sampleEmployee);
|
|
repository.findById.mockResolvedValue({
|
|
...sampleUser,
|
|
employee: {
|
|
id: 'emp-2',
|
|
code: 'EMP_02',
|
|
name: 'Ada Lovelace',
|
|
status: Status.create('draft'),
|
|
},
|
|
});
|
|
|
|
await service.update('user-1', {
|
|
actorUserId: 'actor-1',
|
|
employeeId: 'emp-2',
|
|
});
|
|
|
|
expect(employeesRepository.update).toHaveBeenNthCalledWith(
|
|
1,
|
|
'emp-1',
|
|
expect.objectContaining({ assignedUserId: null }),
|
|
);
|
|
expect(employeesRepository.update).toHaveBeenNthCalledWith(
|
|
2,
|
|
'emp-2',
|
|
expect.objectContaining({ assignedUserId: 'user-1' }),
|
|
);
|
|
});
|
|
|
|
it('update with unknown employeeId throws NotFoundException', async () => {
|
|
repository.findById.mockResolvedValue(sampleUser);
|
|
employeesRepository.findById.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.update('user-1', {
|
|
actorUserId: 'actor-1',
|
|
employeeId: 'missing',
|
|
}),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
expect(repository.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|