diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a8eafe4 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,32 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.test.ts', + '**/__tests__/**/*.spec.ts', + ], + transform: { + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: 'tsconfig.json', + useESM: true, + isolatedModules: true, + }], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/index.ts', + '!src/config/**', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + testTimeout: 30000, + verbose: true, + extensionsToTreatAsEsm: ['.ts'], +}; diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 0000000..cbd7ef9 --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,168 @@ +// Test helpers and mock factories +import { jest } from '@jest/globals'; + +// Mock repository factory +export function createMockRepository() { + return { + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn((data: Partial) => data as T), + save: jest.fn((entity: T) => Promise.resolve(entity)), + update: jest.fn(), + delete: jest.fn(), + softDelete: jest.fn(), + createQueryBuilder: jest.fn(() => createMockQueryBuilder()), + count: jest.fn(), + merge: jest.fn((entity: T, ...sources: Partial[]) => Object.assign(entity, ...sources)), + }; +} + +// Mock query builder +export function createMockQueryBuilder() { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + innerJoinAndSelect: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getOne: jest.fn(), + getMany: jest.fn(), + getManyAndCount: jest.fn(), + getCount: jest.fn(), + execute: jest.fn(), + setParameter: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + }; + return qb; +} + +// Partner factory +export function createMockPartner(overrides = {}) { + return { + id: 'partner-uuid-1', + tenantId: global.testTenantId, + name: 'Test Partner', + email: 'partner@test.com', + phone: '+1234567890', + isCustomer: true, + isSupplier: false, + isActive: true, + creditLimit: 10000, + currentBalance: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Product factory +export function createMockProduct(overrides = {}) { + return { + id: 'product-uuid-1', + tenantId: global.testTenantId, + sku: 'PROD-001', + name: 'Test Product', + description: 'Test product description', + productType: 'product', + salePrice: 100, + costPrice: 50, + currency: 'MXN', + taxRate: 16, + isActive: true, + isSellable: true, + isPurchasable: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Warehouse factory +export function createMockWarehouse(overrides = {}) { + return { + id: 'warehouse-uuid-1', + tenantId: global.testTenantId, + code: 'WH-001', + name: 'Test Warehouse', + address: '123 Test St', + city: 'Test City', + state: 'Test State', + country: 'MEX', + isActive: true, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Invoice factory +export function createMockInvoice(overrides = {}) { + return { + id: 'invoice-uuid-1', + tenantId: global.testTenantId, + invoiceNumber: 'INV-001', + invoiceType: 'sale', + partnerId: 'partner-uuid-1', + status: 'draft', + subtotal: 1000, + taxAmount: 160, + total: 1160, + currency: 'MXN', + invoiceDate: new Date(), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Employee factory +export function createMockEmployee(overrides = {}) { + return { + id: 'employee-uuid-1', + tenantId: global.testTenantId, + employeeNumber: 'EMP-001', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@test.com', + departmentId: 'dept-uuid-1', + position: 'Developer', + hireDate: new Date(), + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Account factory +export function createMockAccount(overrides = {}) { + return { + id: 'account-uuid-1', + tenantId: global.testTenantId, + code: '1000', + name: 'Cash', + accountType: 'asset', + isActive: true, + balance: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..7e91526 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,32 @@ +// Test setup file for Jest +import { jest } from '@jest/globals'; + +// Mock AppDataSource +jest.mock('../config/typeorm.js', () => ({ + AppDataSource: { + getRepository: jest.fn(), + isInitialized: true, + initialize: jest.fn(() => Promise.resolve()), + destroy: jest.fn(() => Promise.resolve()), + }, +})); + +// Global test utilities +global.testTenantId = 'test-tenant-uuid'; +global.testUserId = 'test-user-uuid'; + +// Extend global types for tests +declare global { + var testTenantId: string; + var testUserId: string; +} + +// Clean up mocks after each test +afterEach(() => { + jest.clearAllMocks(); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason); +}); diff --git a/src/modules/hr/__tests__/employees.service.test.ts b/src/modules/hr/__tests__/employees.service.test.ts new file mode 100644 index 0000000..67bbc28 --- /dev/null +++ b/src/modules/hr/__tests__/employees.service.test.ts @@ -0,0 +1,333 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockEmployee } from '../../../__tests__/helpers.js'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), +})); + +// Import after mocking +import { employeesService } from '../employees.service.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js'; + +describe('EmployeesService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return employees with pagination', async () => { + const mockEmployees = [ + createMockEmployee({ id: '1', firstName: 'John' }), + createMockEmployee({ id: '2', firstName: 'Jane' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockEmployees); + + const result = await employeesService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by company_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await employeesService.findAll(tenantId, { company_id: 'company-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('e.company_id = $'), + expect.arrayContaining([tenantId, 'company-uuid']) + ); + }); + + it('should filter by department_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await employeesService.findAll(tenantId, { department_id: 'dept-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('e.department_id = $'), + expect.arrayContaining([tenantId, 'dept-uuid']) + ); + }); + + it('should filter by status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await employeesService.findAll(tenantId, { status: 'active' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('e.status = $'), + expect.arrayContaining([tenantId, 'active']) + ); + }); + + it('should filter by search term', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await employeesService.findAll(tenantId, { search: 'John' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('e.first_name ILIKE'), + expect.arrayContaining([tenantId, '%John%']) + ); + }); + + it('should apply pagination correctly', async () => { + mockQueryOne.mockResolvedValue({ count: '50' }); + mockQuery.mockResolvedValue([]); + + await employeesService.findAll(tenantId, { page: 3, limit: 10 }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT'), + expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3) + ); + }); + }); + + describe('findById', () => { + it('should return employee when found', async () => { + const mockEmployee = createMockEmployee(); + mockQueryOne.mockResolvedValue(mockEmployee); + + const result = await employeesService.findById('employee-uuid-1', tenantId); + + expect(result).toEqual(mockEmployee); + }); + + it('should throw NotFoundError when employee not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + employeesService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + const createDto = { + company_id: 'company-uuid', + employee_number: 'EMP-001', + first_name: 'John', + last_name: 'Doe', + hire_date: '2024-01-15', + }; + + it('should create employee successfully', async () => { + // No existing employee with same number + mockQueryOne.mockResolvedValueOnce(null); + // INSERT returns new employee + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid', ...createDto }); + // findById for return value + mockQueryOne.mockResolvedValueOnce(createMockEmployee({ ...createDto })); + + const result = await employeesService.create(createDto, tenantId, userId); + + expect(result.first_name).toBe('John'); + }); + + it('should throw ConflictError when employee number already exists', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + employeesService.create(createDto, tenantId, userId) + ).rejects.toThrow(ConflictError); + }); + }); + + describe('update', () => { + it('should update employee successfully', async () => { + const existingEmployee = createMockEmployee(); + mockQueryOne.mockResolvedValue(existingEmployee); + mockQuery.mockResolvedValue([]); + + const result = await employeesService.update( + 'employee-uuid-1', + { first_name: 'Updated' }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.employees SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when employee not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + employeesService.update('nonexistent-id', { first_name: 'Test' }, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + + it('should return unchanged employee when no fields to update', async () => { + const existingEmployee = createMockEmployee(); + mockQueryOne.mockResolvedValue(existingEmployee); + + const result = await employeesService.update( + 'employee-uuid-1', + {}, + tenantId, + userId + ); + + expect(result).toEqual(existingEmployee); + }); + }); + + describe('terminate', () => { + it('should terminate active employee', async () => { + const activeEmployee = createMockEmployee({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeEmployee); + mockQuery.mockResolvedValue([]); + + await employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'terminated'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when employee already terminated', async () => { + const terminatedEmployee = createMockEmployee({ status: 'terminated' }); + mockQueryOne.mockResolvedValue(terminatedEmployee); + + await expect( + employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should also terminate active contracts', async () => { + const activeEmployee = createMockEmployee({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeEmployee); + mockQuery.mockResolvedValue([]); + + await employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.contracts SET'), + expect.any(Array) + ); + }); + }); + + describe('reactivate', () => { + it('should reactivate terminated employee', async () => { + const terminatedEmployee = createMockEmployee({ status: 'terminated' }); + mockQueryOne.mockResolvedValue(terminatedEmployee); + mockQuery.mockResolvedValue([]); + + await employeesService.reactivate('employee-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'active'"), + expect.any(Array) + ); + }); + + it('should reactivate inactive employee', async () => { + const inactiveEmployee = createMockEmployee({ status: 'inactive' }); + mockQueryOne.mockResolvedValue(inactiveEmployee); + mockQuery.mockResolvedValue([]); + + await employeesService.reactivate('employee-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'active'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when employee is already active', async () => { + const activeEmployee = createMockEmployee({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeEmployee); + + await expect( + employeesService.reactivate('employee-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('delete', () => { + it('should delete employee without contracts or subordinates', async () => { + const employee = createMockEmployee(); + mockQueryOne + .mockResolvedValueOnce(employee) // findById + .mockResolvedValueOnce({ count: '0' }) // hasContracts + .mockResolvedValueOnce({ count: '0' }); // isManager + mockQuery.mockResolvedValue([]); + + await employeesService.delete('employee-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.employees'), + expect.any(Array) + ); + }); + + it('should throw ConflictError when employee has contracts', async () => { + const employee = createMockEmployee(); + mockQueryOne + .mockResolvedValueOnce(employee) + .mockResolvedValueOnce({ count: '5' }); // hasContracts + + await expect( + employeesService.delete('employee-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError when employee is a manager', async () => { + const employee = createMockEmployee(); + mockQueryOne + .mockResolvedValueOnce(employee) + .mockResolvedValueOnce({ count: '0' }) // hasContracts + .mockResolvedValueOnce({ count: '3' }); // isManager + + await expect( + employeesService.delete('employee-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + }); + + describe('getSubordinates', () => { + it('should return subordinates of a manager', async () => { + const manager = createMockEmployee(); + const subordinates = [ + createMockEmployee({ id: 'sub-1', firstName: 'Sub 1' }), + createMockEmployee({ id: 'sub-2', firstName: 'Sub 2' }), + ]; + + mockQueryOne.mockResolvedValue(manager); + mockQuery.mockResolvedValue(subordinates); + + const result = await employeesService.getSubordinates('manager-uuid', tenantId); + + expect(result).toHaveLength(2); + }); + + it('should throw NotFoundError when manager not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + employeesService.getSubordinates('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/modules/invoices/__tests__/invoices.service.test.ts b/src/modules/invoices/__tests__/invoices.service.test.ts new file mode 100644 index 0000000..caf5755 --- /dev/null +++ b/src/modules/invoices/__tests__/invoices.service.test.ts @@ -0,0 +1,346 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockInvoice } from '../../../__tests__/helpers.js'; + +// Mock dependencies before importing service +const mockInvoiceRepository = createMockRepository(); +const mockPaymentRepository = createMockRepository(); + +jest.mock('../../../config/typeorm.js', () => ({ + AppDataSource: { + getRepository: jest.fn((entity: any) => { + if (entity.name === 'Invoice') return mockInvoiceRepository; + if (entity.name === 'Payment') return mockPaymentRepository; + return mockInvoiceRepository; + }), + }, +})); + +// Import after mocking +import { invoicesService } from '../services/index.js'; + +describe('InvoicesService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAllInvoices', () => { + it('should return invoices with pagination', async () => { + const mockInvoices = [ + createMockInvoice({ id: '1', invoiceNumber: 'FAC-000001' }), + createMockInvoice({ id: '2', invoiceNumber: 'FAC-000002' }), + ]; + + mockInvoiceRepository.findAndCount.mockResolvedValue([mockInvoices, 2]); + + const result = await invoicesService.findAllInvoices({ tenantId, limit: 50, offset: 0 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by invoice type', async () => { + mockInvoiceRepository.findAndCount.mockResolvedValue([[], 0]); + + await invoicesService.findAllInvoices({ tenantId, invoiceType: 'sale' }); + + expect(mockInvoiceRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + invoiceType: 'sale', + }), + }) + ); + }); + + it('should filter by partner', async () => { + mockInvoiceRepository.findAndCount.mockResolvedValue([[], 0]); + + await invoicesService.findAllInvoices({ tenantId, partnerId: 'partner-uuid' }); + + expect(mockInvoiceRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + partnerId: 'partner-uuid', + }), + }) + ); + }); + + it('should filter by status', async () => { + mockInvoiceRepository.findAndCount.mockResolvedValue([[], 0]); + + await invoicesService.findAllInvoices({ tenantId, status: 'validated' }); + + expect(mockInvoiceRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'validated', + }), + }) + ); + }); + }); + + describe('findInvoice', () => { + it('should return invoice when found', async () => { + const mockInvoice = createMockInvoice(); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + const result = await invoicesService.findInvoice('invoice-uuid-1', tenantId); + + expect(result).toEqual(mockInvoice); + }); + + it('should return null when invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + const result = await invoicesService.findInvoice('nonexistent-id', tenantId); + + expect(result).toBeNull(); + }); + }); + + describe('createInvoice', () => { + const createDto = { + invoiceType: 'sale' as const, + partnerId: 'partner-uuid', + subtotal: 1000, + taxAmount: 160, + totalAmount: 1160, + }; + + it('should create invoice with auto-generated number', async () => { + mockInvoiceRepository.count.mockResolvedValue(5); + const savedInvoice = createMockInvoice({ ...createDto, invoiceNumber: 'FAC-000006' }); + mockInvoiceRepository.create.mockReturnValue(savedInvoice); + mockInvoiceRepository.save.mockResolvedValue(savedInvoice); + + const result = await invoicesService.createInvoice(tenantId, createDto, userId); + + expect(result.invoiceNumber).toBe('FAC-000006'); + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: 'FAC-000006', + status: 'draft', + }) + ); + }); + + it('should use correct prefix for purchase invoices', async () => { + mockInvoiceRepository.count.mockResolvedValue(3); + const purchaseDto = { ...createDto, invoiceType: 'purchase' as const }; + mockInvoiceRepository.create.mockReturnValue(createMockInvoice({ invoiceNumber: 'FP-000004' })); + mockInvoiceRepository.save.mockResolvedValue(createMockInvoice({ invoiceNumber: 'FP-000004' })); + + await invoicesService.createInvoice(tenantId, purchaseDto, userId); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: 'FP-000004', + }) + ); + }); + + it('should use correct prefix for credit notes', async () => { + mockInvoiceRepository.count.mockResolvedValue(1); + const creditDto = { ...createDto, invoiceType: 'credit_note' as const }; + mockInvoiceRepository.create.mockReturnValue(createMockInvoice({ invoiceNumber: 'NC-000002' })); + mockInvoiceRepository.save.mockResolvedValue(createMockInvoice({ invoiceNumber: 'NC-000002' })); + + await invoicesService.createInvoice(tenantId, creditDto, userId); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: 'NC-000002', + }) + ); + }); + + it('should set createdBy field', async () => { + mockInvoiceRepository.count.mockResolvedValue(0); + mockInvoiceRepository.create.mockReturnValue(createMockInvoice()); + mockInvoiceRepository.save.mockResolvedValue(createMockInvoice()); + + await invoicesService.createInvoice(tenantId, createDto, userId); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: userId, + }) + ); + }); + }); + + describe('updateInvoice', () => { + it('should update invoice successfully', async () => { + const existingInvoice = createMockInvoice(); + mockInvoiceRepository.findOne.mockResolvedValue(existingInvoice); + mockInvoiceRepository.save.mockResolvedValue({ ...existingInvoice, notes: 'Updated notes' }); + + const result = await invoicesService.updateInvoice( + 'invoice-uuid-1', + tenantId, + { notes: 'Updated notes' }, + userId + ); + + expect(result?.notes).toBe('Updated notes'); + }); + + it('should return null when invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + const result = await invoicesService.updateInvoice( + 'nonexistent-id', + tenantId, + { notes: 'Test' }, + userId + ); + + expect(result).toBeNull(); + }); + }); + + describe('deleteInvoice', () => { + it('should soft delete invoice', async () => { + mockInvoiceRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await invoicesService.deleteInvoice('invoice-uuid-1', tenantId); + + expect(result).toBe(true); + }); + + it('should return false when invoice not found', async () => { + mockInvoiceRepository.softDelete.mockResolvedValue({ affected: 0 }); + + const result = await invoicesService.deleteInvoice('nonexistent-id', tenantId); + + expect(result).toBe(false); + }); + }); + + describe('validateInvoice', () => { + it('should validate draft invoice', async () => { + const draftInvoice = createMockInvoice({ status: 'draft' }); + mockInvoiceRepository.findOne.mockResolvedValue(draftInvoice); + mockInvoiceRepository.save.mockResolvedValue({ ...draftInvoice, status: 'validated' }); + + const result = await invoicesService.validateInvoice('invoice-uuid-1', tenantId, userId); + + expect(result?.status).toBe('validated'); + }); + + it('should return null when invoice not in draft status', async () => { + const validatedInvoice = createMockInvoice({ status: 'validated' }); + mockInvoiceRepository.findOne.mockResolvedValue(validatedInvoice); + + const result = await invoicesService.validateInvoice('invoice-uuid-1', tenantId, userId); + + expect(result).toBeNull(); + }); + + it('should return null when invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + const result = await invoicesService.validateInvoice('nonexistent-id', tenantId, userId); + + expect(result).toBeNull(); + }); + }); + + describe('cancelInvoice', () => { + it('should cancel invoice', async () => { + const invoice = createMockInvoice({ status: 'validated' }); + mockInvoiceRepository.findOne.mockResolvedValue(invoice); + mockInvoiceRepository.save.mockResolvedValue({ ...invoice, status: 'cancelled' }); + + const result = await invoicesService.cancelInvoice('invoice-uuid-1', tenantId, userId); + + expect(result?.status).toBe('cancelled'); + }); + + it('should return null when invoice already cancelled', async () => { + const cancelledInvoice = createMockInvoice({ status: 'cancelled' }); + mockInvoiceRepository.findOne.mockResolvedValue(cancelledInvoice); + + const result = await invoicesService.cancelInvoice('invoice-uuid-1', tenantId, userId); + + expect(result).toBeNull(); + }); + }); + + describe('Payment operations', () => { + const createPaymentDto = { + partnerId: 'partner-uuid', + paymentType: 'received' as const, + paymentMethod: 'transfer', + amount: 1000, + }; + + it('should find all payments', async () => { + const mockPayments = [{ id: '1', amount: 1000 }]; + mockPaymentRepository.findAndCount.mockResolvedValue([mockPayments, 1]); + + const result = await invoicesService.findAllPayments(tenantId); + + expect(result.data).toHaveLength(1); + }); + + it('should find all payments filtered by partner', async () => { + mockPaymentRepository.findAndCount.mockResolvedValue([[], 0]); + + await invoicesService.findAllPayments(tenantId, 'partner-uuid'); + + expect(mockPaymentRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + partnerId: 'partner-uuid', + }), + }) + ); + }); + + it('should create payment with auto-generated number', async () => { + mockPaymentRepository.count.mockResolvedValue(10); + const savedPayment = { id: '1', paymentNumber: 'PAG-000011', ...createPaymentDto }; + mockPaymentRepository.create.mockReturnValue(savedPayment); + mockPaymentRepository.save.mockResolvedValue(savedPayment); + + const result = await invoicesService.createPayment(tenantId, createPaymentDto, userId); + + expect(result.paymentNumber).toBe('PAG-000011'); + }); + + it('should confirm payment', async () => { + const draftPayment = { id: '1', status: 'draft' }; + mockPaymentRepository.findOne.mockResolvedValue(draftPayment); + mockPaymentRepository.save.mockResolvedValue({ ...draftPayment, status: 'confirmed' }); + + const result = await invoicesService.confirmPayment('payment-uuid', tenantId, userId); + + expect(result?.status).toBe('confirmed'); + }); + + it('should not confirm already confirmed payment', async () => { + const confirmedPayment = { id: '1', status: 'confirmed' }; + mockPaymentRepository.findOne.mockResolvedValue(confirmedPayment); + + const result = await invoicesService.confirmPayment('payment-uuid', tenantId, userId); + + expect(result).toBeNull(); + }); + + it('should cancel payment', async () => { + const payment = { id: '1', status: 'confirmed' }; + mockPaymentRepository.findOne.mockResolvedValue(payment); + mockPaymentRepository.save.mockResolvedValue({ ...payment, status: 'cancelled' }); + + const result = await invoicesService.cancelPayment('payment-uuid', tenantId, userId); + + expect(result?.status).toBe('cancelled'); + }); + }); +}); diff --git a/src/modules/partners/__tests__/partners.service.test.ts b/src/modules/partners/__tests__/partners.service.test.ts new file mode 100644 index 0000000..e10f470 --- /dev/null +++ b/src/modules/partners/__tests__/partners.service.test.ts @@ -0,0 +1,325 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockQueryBuilder, createMockPartner } from '../../../__tests__/helpers.js'; + +// Mock dependencies before importing service +const mockRepository = createMockRepository(); +const mockQueryBuilder = createMockQueryBuilder(); + +jest.mock('../../../config/typeorm.js', () => ({ + AppDataSource: { + getRepository: jest.fn(() => mockRepository), + }, +})); + +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { partnersService } from '../partners.service.js'; +import { NotFoundError, ValidationError } from '../../../shared/types/index.js'; + +describe('PartnersService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + describe('findAll', () => { + it('should return partners with pagination', async () => { + const mockPartners = [ + createMockPartner({ id: '1', displayName: 'Partner A' }), + createMockPartner({ id: '2', displayName: 'Partner B' }), + ]; + + mockQueryBuilder.getCount.mockResolvedValue(2); + mockQueryBuilder.getMany.mockResolvedValue(mockPartners); + + const result = await partnersService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'partner.tenantId = :tenantId', + { tenantId } + ); + }); + + it('should filter by search term', async () => { + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]); + + await partnersService.findAll(tenantId, { search: 'test' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('partner.displayName ILIKE :search'), + { search: '%test%' } + ); + }); + + it('should filter by partner type', async () => { + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]); + + await partnersService.findAll(tenantId, { partnerType: 'customer' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'partner.partnerType = :partnerType', + { partnerType: 'customer' } + ); + }); + + it('should filter by active status', async () => { + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]); + + await partnersService.findAll(tenantId, { isActive: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'partner.isActive = :isActive', + { isActive: true } + ); + }); + + it('should apply pagination correctly', async () => { + mockQueryBuilder.getCount.mockResolvedValue(50); + mockQueryBuilder.getMany.mockResolvedValue([]); + + await partnersService.findAll(tenantId, { page: 3, limit: 10 }); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + }); + }); + + describe('findById', () => { + it('should return partner when found', async () => { + const mockPartner = createMockPartner(); + mockRepository.findOne.mockResolvedValue(mockPartner); + + const result = await partnersService.findById('partner-uuid-1', tenantId); + + expect(result).toEqual(mockPartner); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { + id: 'partner-uuid-1', + tenantId, + deletedAt: expect.anything(), + }, + }); + }); + + it('should throw NotFoundError when partner not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + partnersService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should enforce tenant isolation', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + partnersService.findById('partner-uuid-1', 'different-tenant') + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + const createDto = { + code: 'PART-001', + displayName: 'New Partner', + email: 'new@partner.com', + partnerType: 'customer' as const, + }; + + it('should create partner successfully', async () => { + mockRepository.findOne.mockResolvedValue(null); // No existing partner + const savedPartner = createMockPartner({ ...createDto }); + mockRepository.create.mockReturnValue(savedPartner); + mockRepository.save.mockResolvedValue(savedPartner); + + const result = await partnersService.create(createDto, tenantId, userId); + + expect(result).toEqual(savedPartner); + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + code: createDto.code, + displayName: createDto.displayName, + createdBy: userId, + }) + ); + }); + + it('should throw ValidationError when code already exists', async () => { + mockRepository.findOne.mockResolvedValue(createMockPartner()); + + await expect( + partnersService.create(createDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should normalize email to lowercase', async () => { + mockRepository.findOne.mockResolvedValue(null); + const savedPartner = createMockPartner(); + mockRepository.create.mockReturnValue(savedPartner); + mockRepository.save.mockResolvedValue(savedPartner); + + await partnersService.create( + { ...createDto, email: 'TEST@PARTNER.COM' }, + tenantId, + userId + ); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@partner.com', + }) + ); + }); + + it('should set default values correctly', async () => { + mockRepository.findOne.mockResolvedValue(null); + const savedPartner = createMockPartner(); + mockRepository.create.mockReturnValue(savedPartner); + mockRepository.save.mockResolvedValue(savedPartner); + + await partnersService.create( + { code: 'PART-001', displayName: 'Partner' }, + tenantId, + userId + ); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + partnerType: 'customer', + paymentTermDays: 0, + creditLimit: 0, + discountPercent: 0, + isActive: true, + isVerified: false, + }) + ); + }); + }); + + describe('update', () => { + it('should update partner successfully', async () => { + const existingPartner = createMockPartner(); + mockRepository.findOne.mockResolvedValue(existingPartner); + mockRepository.save.mockResolvedValue({ ...existingPartner, displayName: 'Updated Name' }); + + const result = await partnersService.update( + 'partner-uuid-1', + { displayName: 'Updated Name' }, + tenantId, + userId + ); + + expect(mockRepository.save).toHaveBeenCalled(); + expect(result.displayName).toBe('Updated Name'); + }); + + it('should throw NotFoundError when partner not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + partnersService.update('nonexistent-id', { displayName: 'Test' }, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + + it('should update credit limit', async () => { + const existingPartner = createMockPartner({ creditLimit: 1000 }); + mockRepository.findOne.mockResolvedValue(existingPartner); + mockRepository.save.mockResolvedValue({ ...existingPartner, creditLimit: 5000 }); + + await partnersService.update( + 'partner-uuid-1', + { creditLimit: 5000 }, + tenantId, + userId + ); + + expect(existingPartner.creditLimit).toBe(5000); + }); + + it('should set updatedBy field', async () => { + const existingPartner = createMockPartner(); + mockRepository.findOne.mockResolvedValue(existingPartner); + mockRepository.save.mockResolvedValue(existingPartner); + + await partnersService.update( + 'partner-uuid-1', + { displayName: 'Updated' }, + tenantId, + userId + ); + + expect(existingPartner.updatedBy).toBe(userId); + }); + }); + + describe('delete', () => { + it('should soft delete partner', async () => { + const existingPartner = createMockPartner(); + mockRepository.findOne.mockResolvedValue(existingPartner); + mockRepository.save.mockResolvedValue(existingPartner); + + await partnersService.delete('partner-uuid-1', tenantId, userId); + + expect(existingPartner.deletedAt).toBeInstanceOf(Date); + expect(existingPartner.isActive).toBe(false); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundError when partner not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + partnersService.delete('nonexistent-id', tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('findCustomers', () => { + it('should return only customers', async () => { + const mockCustomers = [createMockPartner({ partnerType: 'customer' })]; + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue(mockCustomers); + + const result = await partnersService.findCustomers(tenantId, {}); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'partner.partnerType = :partnerType', + { partnerType: 'customer' } + ); + expect(result.data).toHaveLength(1); + }); + }); + + describe('findSuppliers', () => { + it('should return only suppliers', async () => { + const mockSuppliers = [createMockPartner({ partnerType: 'supplier' })]; + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue(mockSuppliers); + + const result = await partnersService.findSuppliers(tenantId, {}); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'partner.partnerType = :partnerType', + { partnerType: 'supplier' } + ); + expect(result.data).toHaveLength(1); + }); + }); +}); diff --git a/src/modules/products/__tests__/products.service.test.ts b/src/modules/products/__tests__/products.service.test.ts new file mode 100644 index 0000000..173ea22 --- /dev/null +++ b/src/modules/products/__tests__/products.service.test.ts @@ -0,0 +1,302 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockProduct } from '../../../__tests__/helpers.js'; + +// Mock dependencies before importing service +const mockProductRepository = createMockRepository(); +const mockCategoryRepository = createMockRepository(); + +jest.mock('../../../config/typeorm.js', () => ({ + AppDataSource: { + getRepository: jest.fn((entity: any) => { + if (entity.name === 'Product') return mockProductRepository; + if (entity.name === 'ProductCategory') return mockCategoryRepository; + return mockProductRepository; + }), + }, +})); + +// Import after mocking +import { productsService } from '../products.service.js'; + +describe('ProductsService', () => { + const tenantId = 'test-tenant-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return products with pagination', async () => { + const mockProducts = [ + createMockProduct({ id: '1', name: 'Product A' }), + createMockProduct({ id: '2', name: 'Product B' }), + ]; + + mockProductRepository.findAndCount.mockResolvedValue([mockProducts, 2]); + + const result = await productsService.findAll({ tenantId, limit: 50, offset: 0 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by search term', async () => { + const mockProducts = [createMockProduct({ name: 'Test Product' })]; + mockProductRepository.findAndCount.mockResolvedValue([mockProducts, 1]); + + const result = await productsService.findAll({ tenantId, search: 'Test' }); + + expect(result.data).toHaveLength(1); + expect(mockProductRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by category', async () => { + mockProductRepository.findAndCount.mockResolvedValue([[], 0]); + + await productsService.findAll({ tenantId, categoryId: 'cat-uuid' }); + + expect(mockProductRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by product type', async () => { + mockProductRepository.findAndCount.mockResolvedValue([[], 0]); + + await productsService.findAll({ tenantId, productType: 'service' }); + + expect(mockProductRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by sellable status', async () => { + mockProductRepository.findAndCount.mockResolvedValue([[], 0]); + + await productsService.findAll({ tenantId, isSellable: true }); + + expect(mockProductRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter by purchasable status', async () => { + mockProductRepository.findAndCount.mockResolvedValue([[], 0]); + + await productsService.findAll({ tenantId, isPurchasable: true }); + + expect(mockProductRepository.findAndCount).toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return product when found', async () => { + const mockProduct = createMockProduct(); + mockProductRepository.findOne.mockResolvedValue(mockProduct); + + const result = await productsService.findOne('product-uuid-1', tenantId); + + expect(result).toEqual(mockProduct); + expect(mockProductRepository.findOne).toHaveBeenCalledWith({ + where: { + id: 'product-uuid-1', + tenantId, + deletedAt: expect.anything(), + }, + relations: ['category'], + }); + }); + + it('should return null when product not found', async () => { + mockProductRepository.findOne.mockResolvedValue(null); + + const result = await productsService.findOne('nonexistent-id', tenantId); + + expect(result).toBeNull(); + }); + }); + + describe('findBySku', () => { + it('should return product by SKU', async () => { + const mockProduct = createMockProduct({ sku: 'PROD-001' }); + mockProductRepository.findOne.mockResolvedValue(mockProduct); + + const result = await productsService.findBySku('PROD-001', tenantId); + + expect(result).toEqual(mockProduct); + expect(mockProductRepository.findOne).toHaveBeenCalledWith({ + where: { + sku: 'PROD-001', + tenantId, + deletedAt: expect.anything(), + }, + relations: ['category'], + }); + }); + }); + + describe('findByBarcode', () => { + it('should return product by barcode', async () => { + const mockProduct = createMockProduct({ barcode: '1234567890123' }); + mockProductRepository.findOne.mockResolvedValue(mockProduct); + + const result = await productsService.findByBarcode('1234567890123', tenantId); + + expect(result).toEqual(mockProduct); + }); + }); + + describe('create', () => { + const createDto = { + sku: 'PROD-001', + name: 'New Product', + salePrice: 100, + costPrice: 50, + }; + + it('should create product successfully', async () => { + const savedProduct = createMockProduct({ ...createDto }); + mockProductRepository.create.mockReturnValue(savedProduct); + mockProductRepository.save.mockResolvedValue(savedProduct); + + const result = await productsService.create(tenantId, createDto, 'user-uuid'); + + expect(result).toEqual(savedProduct); + expect(mockProductRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + sku: createDto.sku, + name: createDto.name, + }) + ); + }); + + it('should set createdBy field', async () => { + const savedProduct = createMockProduct(); + mockProductRepository.create.mockReturnValue(savedProduct); + mockProductRepository.save.mockResolvedValue(savedProduct); + + await productsService.create(tenantId, createDto, 'user-uuid'); + + expect(mockProductRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: 'user-uuid', + }) + ); + }); + }); + + describe('update', () => { + it('should update product successfully', async () => { + const existingProduct = createMockProduct(); + const updatedProduct = { ...existingProduct, name: 'Updated Name' }; + mockProductRepository.findOne.mockResolvedValue(existingProduct); + mockProductRepository.save.mockResolvedValue(updatedProduct); + + const result = await productsService.update( + 'product-uuid-1', + tenantId, + { name: 'Updated Name' }, + 'user-uuid' + ); + + expect(result?.name).toBe('Updated Name'); + }); + + it('should return null when product not found', async () => { + mockProductRepository.findOne.mockResolvedValue(null); + + const result = await productsService.update( + 'nonexistent-id', + tenantId, + { name: 'Test' }, + 'user-uuid' + ); + + expect(result).toBeNull(); + }); + + it('should update prices', async () => { + const existingProduct = createMockProduct({ salePrice: 100 }); + mockProductRepository.findOne.mockResolvedValue(existingProduct); + mockProductRepository.save.mockResolvedValue({ ...existingProduct, salePrice: 150 }); + + await productsService.update( + 'product-uuid-1', + tenantId, + { salePrice: 150 }, + 'user-uuid' + ); + + expect(mockProductRepository.save).toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should soft delete product', async () => { + mockProductRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await productsService.delete('product-uuid-1', tenantId); + + expect(result).toBe(true); + expect(mockProductRepository.softDelete).toHaveBeenCalledWith({ + id: 'product-uuid-1', + tenantId, + }); + }); + + it('should return false when product not found', async () => { + mockProductRepository.softDelete.mockResolvedValue({ affected: 0 }); + + const result = await productsService.delete('nonexistent-id', tenantId); + + expect(result).toBe(false); + }); + }); + + describe('getSellableProducts', () => { + it('should return only sellable products', async () => { + const mockProducts = [createMockProduct({ isSellable: true })]; + mockProductRepository.findAndCount.mockResolvedValue([mockProducts, 1]); + + const result = await productsService.getSellableProducts(tenantId); + + expect(result.data).toHaveLength(1); + }); + }); + + describe('getPurchasableProducts', () => { + it('should return only purchasable products', async () => { + const mockProducts = [createMockProduct({ isPurchasable: true })]; + mockProductRepository.findAndCount.mockResolvedValue([mockProducts, 1]); + + const result = await productsService.getPurchasableProducts(tenantId); + + expect(result.data).toHaveLength(1); + }); + }); + + describe('Category operations', () => { + it('should find all categories', async () => { + const mockCategories = [{ id: '1', name: 'Category A' }]; + mockCategoryRepository.findAndCount.mockResolvedValue([mockCategories, 1]); + + const result = await productsService.findAllCategories({ tenantId }); + + expect(result.data).toHaveLength(1); + }); + + it('should create category', async () => { + const categoryDto = { code: 'CAT-001', name: 'New Category' }; + const savedCategory = { id: '1', ...categoryDto }; + mockCategoryRepository.create.mockReturnValue(savedCategory); + mockCategoryRepository.save.mockResolvedValue(savedCategory); + + const result = await productsService.createCategory(tenantId, categoryDto); + + expect(result).toEqual(savedCategory); + }); + + it('should delete category', async () => { + mockCategoryRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await productsService.deleteCategory('cat-uuid', tenantId); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/modules/warehouses/__tests__/warehouses.service.test.ts b/src/modules/warehouses/__tests__/warehouses.service.test.ts new file mode 100644 index 0000000..1daa93d --- /dev/null +++ b/src/modules/warehouses/__tests__/warehouses.service.test.ts @@ -0,0 +1,345 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockQueryBuilder, createMockWarehouse } from '../../../__tests__/helpers.js'; + +// Mock dependencies before importing service +const mockWarehouseRepository = createMockRepository(); +const mockLocationRepository = createMockRepository(); +const mockQueryBuilder = createMockQueryBuilder(); + +jest.mock('../../../config/typeorm.js', () => ({ + AppDataSource: { + getRepository: jest.fn((entity: any) => { + if (entity.name === 'Warehouse') return mockWarehouseRepository; + if (entity.name === 'WarehouseLocation') return mockLocationRepository; + return mockWarehouseRepository; + }), + }, +})); + +// Import after mocking +import { warehousesService } from '../warehouses.service.js'; + +describe('WarehousesService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + mockLocationRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + describe('findAll', () => { + it('should return warehouses with pagination', async () => { + const mockWarehouses = [ + createMockWarehouse({ id: '1', name: 'Warehouse A' }), + createMockWarehouse({ id: '2', name: 'Warehouse B' }), + ]; + + mockWarehouseRepository.findAndCount.mockResolvedValue([mockWarehouses, 2]); + + const result = await warehousesService.findAll({ tenantId, limit: 50, offset: 0 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by search term', async () => { + const mockWarehouses = [createMockWarehouse({ name: 'Main Warehouse' })]; + mockWarehouseRepository.findAndCount.mockResolvedValue([mockWarehouses, 1]); + + const result = await warehousesService.findAll({ tenantId, search: 'Main' }); + + expect(result.data).toHaveLength(1); + }); + + it('should filter by active status', async () => { + mockWarehouseRepository.findAndCount.mockResolvedValue([[], 0]); + + await warehousesService.findAll({ tenantId, isActive: true }); + + expect(mockWarehouseRepository.findAndCount).toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return warehouse when found', async () => { + const mockWarehouse = createMockWarehouse(); + mockWarehouseRepository.findOne.mockResolvedValue(mockWarehouse); + + const result = await warehousesService.findOne('warehouse-uuid-1', tenantId); + + expect(result).toEqual(mockWarehouse); + expect(mockWarehouseRepository.findOne).toHaveBeenCalledWith({ + where: { + id: 'warehouse-uuid-1', + tenantId, + deletedAt: expect.anything(), + }, + }); + }); + + it('should return null when warehouse not found', async () => { + mockWarehouseRepository.findOne.mockResolvedValue(null); + + const result = await warehousesService.findOne('nonexistent-id', tenantId); + + expect(result).toBeNull(); + }); + }); + + describe('findByCode', () => { + it('should return warehouse by code', async () => { + const mockWarehouse = createMockWarehouse({ code: 'WH-001' }); + mockWarehouseRepository.findOne.mockResolvedValue(mockWarehouse); + + const result = await warehousesService.findByCode('WH-001', tenantId); + + expect(result).toEqual(mockWarehouse); + expect(mockWarehouseRepository.findOne).toHaveBeenCalledWith({ + where: { + code: 'WH-001', + tenantId, + deletedAt: expect.anything(), + }, + }); + }); + }); + + describe('getDefault', () => { + it('should return default warehouse', async () => { + const mockWarehouse = createMockWarehouse({ isDefault: true }); + mockWarehouseRepository.findOne.mockResolvedValue(mockWarehouse); + + const result = await warehousesService.getDefault(tenantId); + + expect(result?.isDefault).toBe(true); + }); + + it('should return null when no default warehouse exists', async () => { + mockWarehouseRepository.findOne.mockResolvedValue(null); + + const result = await warehousesService.getDefault(tenantId); + + expect(result).toBeNull(); + }); + }); + + describe('getActive', () => { + it('should return only active warehouses', async () => { + const mockWarehouses = [ + createMockWarehouse({ isActive: true }), + createMockWarehouse({ isActive: true }), + ]; + mockWarehouseRepository.find.mockResolvedValue(mockWarehouses); + + const result = await warehousesService.getActive(tenantId); + + expect(result).toHaveLength(2); + }); + }); + + describe('create', () => { + const createDto = { + code: 'WH-001', + name: 'New Warehouse', + address: '123 Main St', + city: 'Test City', + }; + + it('should create warehouse successfully', async () => { + const savedWarehouse = createMockWarehouse({ ...createDto }); + mockWarehouseRepository.create.mockReturnValue(savedWarehouse); + mockWarehouseRepository.save.mockResolvedValue(savedWarehouse); + + const result = await warehousesService.create(tenantId, createDto, userId); + + expect(result).toEqual(savedWarehouse); + expect(mockWarehouseRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + code: createDto.code, + name: createDto.name, + }) + ); + }); + + it('should unset other defaults when creating default warehouse', async () => { + const createDtoWithDefault = { ...createDto, isDefault: true }; + mockWarehouseRepository.update.mockResolvedValue({ affected: 1 }); + mockWarehouseRepository.create.mockReturnValue(createMockWarehouse(createDtoWithDefault)); + mockWarehouseRepository.save.mockResolvedValue(createMockWarehouse(createDtoWithDefault)); + + await warehousesService.create(tenantId, createDtoWithDefault, userId); + + expect(mockWarehouseRepository.update).toHaveBeenCalledWith( + { tenantId, isDefault: true }, + { isDefault: false } + ); + }); + }); + + describe('update', () => { + it('should update warehouse successfully', async () => { + const existingWarehouse = createMockWarehouse(); + mockWarehouseRepository.findOne.mockResolvedValue(existingWarehouse); + mockWarehouseRepository.save.mockResolvedValue({ ...existingWarehouse, name: 'Updated Name' }); + + const result = await warehousesService.update( + 'warehouse-uuid-1', + tenantId, + { name: 'Updated Name' }, + userId + ); + + expect(result?.name).toBe('Updated Name'); + }); + + it('should return null when warehouse not found', async () => { + mockWarehouseRepository.findOne.mockResolvedValue(null); + + const result = await warehousesService.update( + 'nonexistent-id', + tenantId, + { name: 'Test' }, + userId + ); + + expect(result).toBeNull(); + }); + + it('should unset other defaults when setting as default', async () => { + const existingWarehouse = createMockWarehouse({ isDefault: false }); + mockWarehouseRepository.findOne.mockResolvedValue(existingWarehouse); + mockWarehouseRepository.update.mockResolvedValue({ affected: 1 }); + mockWarehouseRepository.save.mockResolvedValue({ ...existingWarehouse, isDefault: true }); + + await warehousesService.update( + 'warehouse-uuid-1', + tenantId, + { isDefault: true }, + userId + ); + + expect(mockWarehouseRepository.update).toHaveBeenCalledWith( + { tenantId, isDefault: true }, + { isDefault: false } + ); + }); + }); + + describe('delete', () => { + it('should soft delete warehouse', async () => { + mockWarehouseRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await warehousesService.delete('warehouse-uuid-1', tenantId); + + expect(result).toBe(true); + }); + + it('should return false when warehouse not found', async () => { + mockWarehouseRepository.softDelete.mockResolvedValue({ affected: 0 }); + + const result = await warehousesService.delete('nonexistent-id', tenantId); + + expect(result).toBe(false); + }); + }); + + describe('Location operations', () => { + const createLocationDto = { + warehouseId: 'warehouse-uuid-1', + code: 'LOC-001', + name: 'Location A', + locationType: 'shelf' as const, + }; + + beforeEach(() => { + mockQueryBuilder.getOne.mockResolvedValue(null); + mockQueryBuilder.getMany.mockResolvedValue([]); + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + }); + + it('should find all locations with tenant filtering via join', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await warehousesService.findAllLocations({ tenantId, limit: 50, offset: 0 }); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'warehouse.tenantId = :tenantId', + { tenantId } + ); + }); + + it('should filter locations by warehouse', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await warehousesService.findAllLocations({ + tenantId, + warehouseId: 'warehouse-uuid', + }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'location.warehouseId = :warehouseId', + { warehouseId: 'warehouse-uuid' } + ); + }); + + it('should filter locations by type', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await warehousesService.findAllLocations({ + tenantId, + locationType: 'shelf', + }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'location.locationType = :locationType', + { locationType: 'shelf' } + ); + }); + + it('should create location', async () => { + const savedLocation = { id: '1', ...createLocationDto }; + mockLocationRepository.create.mockReturnValue(savedLocation); + mockLocationRepository.save.mockResolvedValue(savedLocation); + + const result = await warehousesService.createLocation(tenantId, createLocationDto, userId); + + expect(result).toEqual(savedLocation); + }); + + it('should update location', async () => { + const existingLocation = { id: '1', ...createLocationDto }; + mockQueryBuilder.getOne.mockResolvedValue(existingLocation); + mockLocationRepository.save.mockResolvedValue({ ...existingLocation, name: 'Updated Location' }); + + const result = await warehousesService.updateLocation( + 'location-uuid', + tenantId, + { name: 'Updated Location' }, + userId + ); + + expect(result?.name).toBe('Updated Location'); + }); + + it('should delete location', async () => { + const existingLocation = { id: '1', ...createLocationDto }; + mockQueryBuilder.getOne.mockResolvedValue(existingLocation); + mockLocationRepository.softDelete.mockResolvedValue({ affected: 1 }); + + const result = await warehousesService.deleteLocation('location-uuid', tenantId); + + expect(result).toBe(true); + }); + + it('should get locations by warehouse', async () => { + const mockLocations = [{ id: '1', code: 'LOC-001' }]; + mockQueryBuilder.getMany.mockResolvedValue(mockLocations); + + const result = await warehousesService.getLocationsByWarehouse('warehouse-uuid', tenantId); + + expect(result).toHaveLength(1); + }); + }); +});