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 >; let employeesRepository: jest.Mocked< Pick >; 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(); }); });