From 11f8bc3ad083bf1e761032c338d40a7f812b686b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 08:11:33 -0600 Subject: [PATCH] [SYNC] test: Add service-order service tests Co-Authored-By: Claude Opus 4.5 --- .../__tests__/service-order.service.spec.ts | 1589 +++++++++++++++++ 1 file changed, 1589 insertions(+) create mode 100644 src/modules/service-management/__tests__/service-order.service.spec.ts diff --git a/src/modules/service-management/__tests__/service-order.service.spec.ts b/src/modules/service-management/__tests__/service-order.service.spec.ts new file mode 100644 index 0000000..b34b3a8 --- /dev/null +++ b/src/modules/service-management/__tests__/service-order.service.spec.ts @@ -0,0 +1,1589 @@ +/** + * Service Order Service - Unit Tests + * Mecánicas Diesel - ERP Suite + * + * Tests for CRUD operations, business logic, and validation. + */ + +import { DataSource, Repository, SelectQueryBuilder, ObjectLiteral, EntityManager } from 'typeorm'; +import { + ServiceOrderService, + CreateServiceOrderDto, + UpdateServiceOrderDto, + ServiceContext, + PaginationOptions, + ServiceOrderFilters, +} from '../services/service-order.service'; +import { + ServiceOrder, + ServiceOrderStatus, + ServiceOrderPriority, +} from '../entities/service-order.entity'; +import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity'; +import { Customer } from '../../customers/entities/customer.entity'; +import { Vehicle } from '../../vehicle-management/entities/vehicle.entity'; +import { User, UserRole } from '../../auth/entities/user.entity'; +import { Service } from '../entities/service.entity'; + +// Test fixtures with realistic UUIDs +const TEST_TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const TEST_USER_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'; +const TEST_CUSTOMER_ID = 'c3d4e5f6-a7b8-9012-cdef-123456789012'; +const TEST_VEHICLE_ID = 'd4e5f6a7-b8c9-0123-def0-234567890123'; +const TEST_TECHNICIAN_ID = 'e5f6a7b8-c9d0-1234-ef01-345678901234'; +const TEST_ORDER_ID = 'f6a7b8c9-d0e1-2345-f012-456789012345'; + +// Mock entities +const mockCustomer: Partial = { + id: TEST_CUSTOMER_ID, + tenantId: TEST_TENANT_ID, + name: 'Transportes del Norte S.A.', + isActive: true, + email: 'contacto@transportesnorte.mx', + phone: '555-1234567', +}; + +const mockVehicle: Partial = { + id: TEST_VEHICLE_ID, + tenantId: TEST_TENANT_ID, + customerId: TEST_CUSTOMER_ID, + licensePlate: 'ABC-123', + make: 'Kenworth', + model: 'T680', + year: 2022, + vin: '1XKYD49X0XJ123456', +}; + +const mockTechnician: Partial = { + id: TEST_TECHNICIAN_ID, + tenantId: TEST_TENANT_ID, + email: 'mecanico@taller.mx', + fullName: 'Juan Perez Garcia', + role: UserRole.MECANICO, + isActive: true, +}; + +const mockServiceOrder: Partial = { + id: TEST_ORDER_ID, + tenantId: TEST_TENANT_ID, + orderNumber: 'OS-2026-00001', + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + status: ServiceOrderStatus.RECEIVED, + priority: ServiceOrderPriority.NORMAL, + customerSymptoms: 'Motor presenta ruido anormal al acelerar', + receivedAt: new Date('2026-02-01T10:00:00Z'), + laborTotal: 0, + partsTotal: 0, + discountAmount: 0, + discountPercent: 0, + tax: 0, + grandTotal: 0, + createdAt: new Date('2026-02-01T10:00:00Z'), + updatedAt: new Date('2026-02-01T10:00:00Z'), +}; + +const mockOrderItems: Partial[] = [ + { + id: 'item-001', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.SERVICE, + description: 'Diagnostico de motor diesel', + quantity: 1, + unitPrice: 500, + discountPct: 0, + subtotal: 500, + status: OrderItemStatus.COMPLETED, + estimatedHours: 2, + }, + { + id: 'item-002', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.PART, + description: 'Filtro de combustible', + quantity: 2, + unitPrice: 350, + discountPct: 10, + subtotal: 630, // 2 * 350 * 0.9 + status: OrderItemStatus.COMPLETED, + }, +]; + +// Mock repository factory with proper type constraints +const createMockRepository = (): jest.Mocked> => { + const queryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + getMany: jest.fn(), + getCount: jest.fn(), + getRawOne: jest.fn(), + } as unknown as jest.Mocked>; + + return { + findOne: jest.fn(), + find: jest.fn(), + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(queryBuilder), + } as unknown as jest.Mocked>; +}; + +// Mock DataSource +const createMockDataSource = () => { + const mockOrderRepo = createMockRepository(); + const mockItemRepo = createMockRepository(); + const mockCustomerRepo = createMockRepository(); + const mockVehicleRepo = createMockRepository(); + const mockUserRepo = createMockRepository(); + const mockServiceRepo = createMockRepository(); + + const mockDataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === ServiceOrder) return mockOrderRepo; + if (entity === OrderItem) return mockItemRepo; + if (entity === Customer) return mockCustomerRepo; + if (entity === Vehicle) return mockVehicleRepo; + if (entity === User) return mockUserRepo; + if (entity === Service) return mockServiceRepo; + return createMockRepository(); + }), + transaction: jest.fn(), + } as unknown as jest.Mocked; + + return { + mockDataSource, + mockOrderRepo, + mockItemRepo, + mockCustomerRepo, + mockVehicleRepo, + mockUserRepo, + mockServiceRepo, + }; +}; + +// Helper to create mock transaction +type TransactionCallback = (manager: EntityManager) => Promise; +const createMockTransaction = (mockRepoResults: Record) => { + return async (cb: TransactionCallback) => { + const mockManager = { + getRepository: jest.fn().mockImplementation((entity) => { + const entityName = typeof entity === 'function' ? entity.name : String(entity); + return mockRepoResults[entityName] || { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }; + }), + } as unknown as EntityManager; + return cb(mockManager); + }; +}; + +describe('ServiceOrderService', () => { + let service: ServiceOrderService; + let mockDataSource: jest.Mocked; + let mockOrderRepo: jest.Mocked>; + let mockItemRepo: jest.Mocked>; + let mockCustomerRepo: jest.Mocked>; + let mockVehicleRepo: jest.Mocked>; + let mockUserRepo: jest.Mocked>; + + const ctx: ServiceContext = { + tenantId: TEST_TENANT_ID, + userId: TEST_USER_ID, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const mocks = createMockDataSource(); + mockDataSource = mocks.mockDataSource; + mockOrderRepo = mocks.mockOrderRepo; + mockItemRepo = mocks.mockItemRepo; + mockCustomerRepo = mocks.mockCustomerRepo; + mockVehicleRepo = mocks.mockVehicleRepo; + mockUserRepo = mocks.mockUserRepo; + + service = new ServiceOrderService(mockDataSource); + }); + + // ============================================ + // CRUD Operations + // ============================================ + describe('CRUD Operations', () => { + describe('create()', () => { + it('should create a new service order successfully', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + customerSymptoms: 'Motor presenta ruido anormal', + priority: ServiceOrderPriority.HIGH, + odometerIn: 150000, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(mockVehicle as Vehicle); + mockOrderRepo.findOne.mockResolvedValue(null); // No existing orders + + const expectedOrder = { + ...mockServiceOrder, + ...dto, + orderNumber: 'OS-2026-00001', + status: ServiceOrderStatus.RECEIVED, + }; + + (mockDataSource.transaction as jest.Mock).mockImplementation( + createMockTransaction({ + ServiceOrder: { + create: jest.fn().mockReturnValue(expectedOrder), + save: jest.fn().mockResolvedValue(expectedOrder), + }, + }) + ); + + // Act + const result = await service.create(TEST_TENANT_ID, dto, TEST_USER_ID); + + // Assert + expect(result).toBeDefined(); + expect(result.status).toBe(ServiceOrderStatus.RECEIVED); + expect(mockCustomerRepo.findOne).toHaveBeenCalledWith({ + where: { id: dto.customerId, tenantId: TEST_TENANT_ID }, + }); + expect(mockVehicleRepo.findOne).toHaveBeenCalledWith({ + where: { id: dto.vehicleId, tenantId: TEST_TENANT_ID }, + }); + }); + + it('should throw error when customer not found', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: 'non-existent-id', + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + 'Customer not found: non-existent-id' + ); + }); + + it('should throw error when customer is inactive', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue({ + ...mockCustomer, + isActive: false, + } as Customer); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /Customer is inactive/ + ); + }); + + it('should throw error when vehicle not found', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: 'non-existent-vehicle', + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + 'Vehicle not found: non-existent-vehicle' + ); + }); + + it('should throw error when vehicle does not belong to customer', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue({ + ...mockVehicle, + customerId: 'different-customer-id', + } as Vehicle); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /does not belong to customer/ + ); + }); + + it('should throw error when vehicle has incomplete data', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + // Use type assertion to unknown first for testing incomplete data + mockVehicleRepo.findOne.mockResolvedValue({ + id: TEST_VEHICLE_ID, + tenantId: TEST_TENANT_ID, + customerId: TEST_CUSTOMER_ID, + licensePlate: 'ABC-123', + make: '', + model: '', + year: 0, + } as unknown as Vehicle); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /has incomplete data/ + ); + }); + }); + + describe('findAll()', () => { + it('should return paginated list of orders', async () => { + // Arrange + const filters: ServiceOrderFilters = {}; + const pagination: PaginationOptions = { page: 1, limit: 20 }; + const orders = [mockServiceOrder, { ...mockServiceOrder, id: 'order-2' }]; + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([ + orders, + 2, + ]); + + // Act + const result = await service.findAll(TEST_TENANT_ID, filters, pagination); + + // Assert + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + }); + + it('should apply status filter correctly', async () => { + // Arrange + const filters: ServiceOrderFilters = { + status: ServiceOrderStatus.IN_PROGRESS, + }; + const pagination: PaginationOptions = { page: 1, limit: 10 }; + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([[], 0]); + + // Act + await service.findAll(TEST_TENANT_ID, filters, pagination); + + // Assert + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'order.status = :status', + { status: ServiceOrderStatus.IN_PROGRESS } + ); + }); + + it('should apply search filter correctly', async () => { + // Arrange + const filters: ServiceOrderFilters = { + search: 'motor', + }; + const pagination: PaginationOptions = { page: 1, limit: 10 }; + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([[], 0]); + + // Act + await service.findAll(TEST_TENANT_ID, filters, pagination); + + // Assert + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + '(order.order_number ILIKE :search OR order.customer_symptoms ILIKE :search)', + { search: '%motor%' } + ); + }); + + it('should calculate pagination correctly', async () => { + // Arrange + const filters: ServiceOrderFilters = {}; + const pagination: PaginationOptions = { page: 3, limit: 10 }; + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([[], 50]); + + // Act + const result = await service.findAll(TEST_TENANT_ID, filters, pagination); + + // Assert + expect(queryBuilder.skip).toHaveBeenCalledWith(20); // (3-1) * 10 + expect(queryBuilder.take).toHaveBeenCalledWith(10); + expect(result.totalPages).toBe(5); + }); + }); + + describe('findById()', () => { + it('should return single order with relations', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(mockServiceOrder as ServiceOrder); + + // Act + const result = await service.findById(TEST_TENANT_ID, TEST_ORDER_ID); + + // Assert + expect(result).toEqual(mockServiceOrder); + expect(mockOrderRepo.findOne).toHaveBeenCalledWith({ + where: { id: TEST_ORDER_ID, tenantId: TEST_TENANT_ID }, + }); + }); + + it('should return null when order not found', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(null); + + // Act + const result = await service.findById(TEST_TENANT_ID, 'non-existent-id'); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('update()', () => { + it('should update order fields successfully', async () => { + // Arrange + const dto: UpdateServiceOrderDto = { + priority: ServiceOrderPriority.URGENT, + customerSymptoms: 'Motor actualizado - ahora con humo negro', + }; + + mockOrderRepo.findOne.mockResolvedValue(mockServiceOrder as ServiceOrder); + mockOrderRepo.save.mockResolvedValue({ + ...mockServiceOrder, + ...dto, + } as ServiceOrder); + + // Act + const result = await service.update(TEST_TENANT_ID, TEST_ORDER_ID, dto); + + // Assert + expect(result).toBeDefined(); + expect(result?.priority).toBe(ServiceOrderPriority.URGENT); + }); + + it('should return null when order not found', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(null); + + // Act + const result = await service.update(TEST_TENANT_ID, 'non-existent', {}); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('delete (soft delete via status)', () => { + it('should soft delete order by setting status to CANCELLED', async () => { + // Arrange + const orderToCancel = { + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + }; + + mockOrderRepo.findOne.mockResolvedValue(orderToCancel as ServiceOrder); + mockOrderRepo.save.mockResolvedValue({ + ...orderToCancel, + status: ServiceOrderStatus.CANCELLED, + } as ServiceOrder); + + // Act + const result = await service.updateOrder( + TEST_ORDER_ID, + { status: ServiceOrderStatus.CANCELLED }, + ctx + ); + + // Assert + expect(result.order.status).toBe(ServiceOrderStatus.CANCELLED); + expect(result.changes).toContainEqual( + expect.objectContaining({ + field: 'status', + oldValue: ServiceOrderStatus.RECEIVED, + newValue: ServiceOrderStatus.CANCELLED, + }) + ); + }); + }); + }); + + // ============================================ + // Business Logic + // ============================================ + describe('Business Logic', () => { + describe('assignTechnician()', () => { + it('should assign technician to order successfully', async () => { + // Arrange + const orderToAssign = { + ...mockServiceOrder, + status: ServiceOrderStatus.DIAGNOSED, + }; + + mockOrderRepo.findOne.mockResolvedValue(orderToAssign as ServiceOrder); + mockUserRepo.findOne.mockResolvedValue(mockTechnician as User); + mockOrderRepo.count.mockResolvedValue(2); // Technician workload + + (mockDataSource.transaction as jest.Mock).mockImplementation( + createMockTransaction({ + ServiceOrder: { + save: jest.fn().mockResolvedValue({ + ...orderToAssign, + assignedTo: TEST_TECHNICIAN_ID, + }), + }, + }) + ); + + // Act + const result = await service.assignTechnician( + TEST_ORDER_ID, + TEST_TECHNICIAN_ID, + ctx + ); + + // Assert + expect(result.assignedTo).toBe(TEST_TECHNICIAN_ID); + }); + + it('should reject assignment for invalid order status', async () => { + // Arrange + const completedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + }; + + mockOrderRepo.findOne.mockResolvedValue(completedOrder as ServiceOrder); + + // Act & Assert + await expect( + service.assignTechnician(TEST_ORDER_ID, TEST_TECHNICIAN_ID, ctx) + ).rejects.toThrow(/Cannot assign technician to order with status/); + }); + + it('should reject assignment for inactive technician', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + mockUserRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect( + service.assignTechnician(TEST_ORDER_ID, 'inactive-tech-id', ctx) + ).rejects.toThrow(/Technician not found or inactive/); + }); + + it('should reject assignment for user without technician role', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + mockUserRepo.findOne.mockResolvedValue({ + ...mockTechnician, + role: UserRole.RECEPCION, + } as User); + + // Act & Assert + await expect( + service.assignTechnician(TEST_ORDER_ID, TEST_TECHNICIAN_ID, ctx) + ).rejects.toThrow(/does not have technician role/); + }); + + it('should update status to IN_PROGRESS when assigning to APPROVED order', async () => { + // Arrange + const approvedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.APPROVED, + }; + + mockOrderRepo.findOne.mockResolvedValue(approvedOrder as ServiceOrder); + mockUserRepo.findOne.mockResolvedValue(mockTechnician as User); + mockOrderRepo.count.mockResolvedValue(1); + + let savedOrder: ServiceOrder | undefined; + (mockDataSource.transaction as jest.Mock).mockImplementation(async (cb: TransactionCallback) => { + const mockManager = { + getRepository: jest.fn().mockReturnValue({ + save: jest.fn().mockImplementation((order: ServiceOrder) => { + savedOrder = order; + return Promise.resolve(order); + }), + }), + } as unknown as EntityManager; + return cb(mockManager); + }); + + // Act + await service.assignTechnician(TEST_ORDER_ID, TEST_TECHNICIAN_ID, ctx); + + // Assert + expect(savedOrder?.status).toBe(ServiceOrderStatus.IN_PROGRESS); + expect(savedOrder?.startedAt).toBeDefined(); + }); + }); + + describe('updateStatus() / validateStatusTransition()', () => { + const statusTransitionTests = [ + { + from: ServiceOrderStatus.RECEIVED, + to: ServiceOrderStatus.DIAGNOSED, + valid: true, + }, + { + from: ServiceOrderStatus.RECEIVED, + to: ServiceOrderStatus.CANCELLED, + valid: true, + }, + { + from: ServiceOrderStatus.RECEIVED, + to: ServiceOrderStatus.COMPLETED, + valid: false, + }, + { + from: ServiceOrderStatus.DIAGNOSED, + to: ServiceOrderStatus.QUOTED, + valid: true, + }, + { + from: ServiceOrderStatus.DIAGNOSED, + to: ServiceOrderStatus.IN_PROGRESS, + valid: true, + }, + { + from: ServiceOrderStatus.QUOTED, + to: ServiceOrderStatus.APPROVED, + valid: true, + }, + { + from: ServiceOrderStatus.APPROVED, + to: ServiceOrderStatus.IN_PROGRESS, + valid: true, + }, + { + from: ServiceOrderStatus.IN_PROGRESS, + to: ServiceOrderStatus.COMPLETED, + valid: true, + }, + { + from: ServiceOrderStatus.IN_PROGRESS, + to: ServiceOrderStatus.WAITING_PARTS, + valid: true, + }, + { + from: ServiceOrderStatus.WAITING_PARTS, + to: ServiceOrderStatus.IN_PROGRESS, + valid: true, + }, + { + from: ServiceOrderStatus.COMPLETED, + to: ServiceOrderStatus.DELIVERED, + valid: true, + }, + { + from: ServiceOrderStatus.COMPLETED, + to: ServiceOrderStatus.IN_PROGRESS, + valid: false, + }, + { + from: ServiceOrderStatus.DELIVERED, + to: ServiceOrderStatus.RECEIVED, + valid: false, + }, + { + from: ServiceOrderStatus.CANCELLED, + to: ServiceOrderStatus.RECEIVED, + valid: false, + }, + ]; + + statusTransitionTests.forEach(({ from, to, valid }) => { + it(`should ${valid ? 'allow' : 'reject'} transition from ${from} to ${to}`, async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: from, + } as ServiceOrder); + mockOrderRepo.save.mockResolvedValue({ + ...mockServiceOrder, + status: to, + } as ServiceOrder); + + // Act & Assert + if (valid) { + const result = await service.updateOrder( + TEST_ORDER_ID, + { status: to }, + ctx + ); + expect(result.order.status).toBe(to); + } else { + await expect( + service.updateOrder(TEST_ORDER_ID, { status: to }, ctx) + ).rejects.toThrow(/Invalid status transition/); + } + }); + }); + + it('should not allow modification of delivered order', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.DELIVERED, + } as ServiceOrder); + + // Act & Assert + await expect( + service.updateOrder( + TEST_ORDER_ID, + { priority: ServiceOrderPriority.URGENT }, + ctx + ) + ).rejects.toThrow(/Cannot modify fields.*on order with status delivered/); + }); + + it('should not allow modification of cancelled order', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.CANCELLED, + } as ServiceOrder); + + // Act & Assert + await expect( + service.updateOrder( + TEST_ORDER_ID, + { priority: ServiceOrderPriority.URGENT }, + ctx + ) + ).rejects.toThrow(/Cannot modify fields.*on order with status cancelled/); + }); + }); + + describe('calculateCosts()', () => { + it('should sum parts and labor costs correctly', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + discountPercent: 0, + } as ServiceOrder); + mockItemRepo.find.mockResolvedValue(mockOrderItems as OrderItem[]); + + // Act + const result = await service.calculateCosts(TEST_ORDER_ID, ctx); + + // Assert + // Labor: 500 (1 service item) + // Parts: 630 (2 parts @ 350 with 10% discount = 2 * 350 * 0.9 = 630) + expect(result.laborSubtotal).toBe(500); + expect(result.partsSubtotal).toBe(630); + expect(result.subtotal).toBe(1130); + expect(result.taxRate).toBe(0.16); + expect(result.taxAmount).toBeCloseTo(180.8, 2); // 1130 * 0.16 + expect(result.grandTotal).toBeCloseTo(1310.8, 2); // 1130 + 180.8 + expect(result.items).toHaveLength(2); + }); + + it('should apply order-level discount correctly', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + discountPercent: 10, // 10% order discount + } as ServiceOrder); + mockItemRepo.find.mockResolvedValue(mockOrderItems as OrderItem[]); + + // Act + const result = await service.calculateCosts(TEST_ORDER_ID, ctx); + + // Assert + // Subtotal: 1130 + // Discount: 1130 * 0.10 = 113 + // Taxable: 1130 - 113 = 1017 + // Tax: 1017 * 0.16 = 162.72 + // Grand: 1017 + 162.72 = 1179.72 + expect(result.discountPercent).toBe(10); + expect(result.discountAmount).toBeCloseTo(113, 2); + expect(result.taxableAmount).toBeCloseTo(1017, 2); + expect(result.taxAmount).toBeCloseTo(162.72, 2); + expect(result.grandTotal).toBeCloseTo(1179.72, 2); + }); + + it('should handle empty order items', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + discountPercent: 0, + } as ServiceOrder); + mockItemRepo.find.mockResolvedValue([]); + + // Act + const result = await service.calculateCosts(TEST_ORDER_ID, ctx); + + // Assert + expect(result.laborSubtotal).toBe(0); + expect(result.partsSubtotal).toBe(0); + expect(result.grandTotal).toBe(0); + expect(result.items).toHaveLength(0); + }); + + it('should throw error when order not found', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect( + service.calculateCosts('non-existent-id', ctx) + ).rejects.toThrow(/Service order not found/); + }); + }); + }); + + // ============================================ + // Validation + // ============================================ + describe('Validation', () => { + describe('Status Transition Validation', () => { + it('should reject invalid status transitions', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + + // Act & Assert + await expect( + service.updateOrder( + TEST_ORDER_ID, + { status: ServiceOrderStatus.DELIVERED }, + ctx + ) + ).rejects.toThrow(/Invalid status transition from received to delivered/); + }); + + it('should not allow skipping mandatory steps', async () => { + // Arrange - Cannot go from RECEIVED directly to COMPLETED + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + + // Act & Assert + await expect( + service.updateOrder( + TEST_ORDER_ID, + { status: ServiceOrderStatus.COMPLETED }, + ctx + ) + ).rejects.toThrow(/Invalid status transition/); + }); + }); + + describe('Vehicle Requirement for New Orders', () => { + it('should require vehicle for new orders', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: '', // Empty vehicle ID + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /Vehicle not found/ + ); + }); + + it('should validate vehicle belongs to customer', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue({ + ...mockVehicle, + customerId: 'other-customer-id', // Different customer + } as Vehicle); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /does not belong to customer/ + ); + }); + + it('should require complete vehicle information', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + // Vehicle missing year + mockVehicleRepo.findOne.mockResolvedValue({ + id: TEST_VEHICLE_ID, + tenantId: TEST_TENANT_ID, + customerId: TEST_CUSTOMER_ID, + licensePlate: 'ABC-123', + make: 'Kenworth', + model: 'T680', + year: 0, // Missing/invalid year + } as unknown as Vehicle); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /has incomplete data.*Required: make, model, year/ + ); + }); + }); + + describe('Field Modification Restrictions', () => { + it('should only allow notes and odometer on completed orders', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + } as ServiceOrder); + + // Act & Assert - Should reject priority change + await expect( + service.updateOrder( + TEST_ORDER_ID, + { priority: ServiceOrderPriority.URGENT }, + ctx + ) + ).rejects.toThrow(/Cannot modify fields.*on order with status completed/); + }); + + it('should allow updating notes on completed orders', async () => { + // Arrange + const completedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + }; + + mockOrderRepo.findOne.mockResolvedValue(completedOrder as ServiceOrder); + mockOrderRepo.save.mockResolvedValue({ + ...completedOrder, + internalNotes: 'Updated notes', + } as ServiceOrder); + + // Act + const result = await service.updateOrder( + TEST_ORDER_ID, + { internalNotes: 'Updated notes' }, + ctx + ); + + // Assert + expect(result.order.internalNotes).toBe('Updated notes'); + }); + + it('should allow updating odometer on completed orders', async () => { + // Arrange + const completedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + odometerIn: 100000, + }; + + mockOrderRepo.findOne.mockResolvedValue(completedOrder as ServiceOrder); + mockOrderRepo.save.mockResolvedValue({ + ...completedOrder, + odometerOut: 100050, + } as ServiceOrder); + + // Act + const result = await service.updateOrder( + TEST_ORDER_ID, + { odometerOut: 100050 }, + ctx + ); + + // Assert + expect(result.order.odometerOut).toBe(100050); + }); + }); + + describe('Technician Validation', () => { + it('should validate technician on order creation', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + assignedTo: 'non-existent-tech', + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(mockVehicle as Vehicle); + mockUserRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect(service.create(TEST_TENANT_ID, dto)).rejects.toThrow( + /Technician not found or inactive/ + ); + }); + + it('should validate technician on order update', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + mockUserRepo.findOne.mockResolvedValue(null); + + // Act & Assert + await expect( + service.updateOrder( + TEST_ORDER_ID, + { assignedTo: 'non-existent-tech' }, + ctx + ) + ).rejects.toThrow(/Technician not found or inactive/); + }); + }); + }); + + // ============================================ + // Order Items Management + // ============================================ + describe('Order Items Management', () => { + describe('addItem()', () => { + it('should add item to order and recalculate totals', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(mockServiceOrder as ServiceOrder); + mockItemRepo.create.mockReturnValue({ + id: 'new-item-id', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.SERVICE, + description: 'Oil change', + quantity: 1, + unitPrice: 200, + discountPct: 0, + subtotal: 200, + status: OrderItemStatus.PENDING, + } as OrderItem); + mockItemRepo.save.mockResolvedValue({ + id: 'new-item-id', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.SERVICE, + description: 'Oil change', + quantity: 1, + unitPrice: 200, + discountPct: 0, + subtotal: 200, + status: OrderItemStatus.PENDING, + } as OrderItem); + mockItemRepo.find.mockResolvedValue([]); + + // Act + const result = await service.addItem(TEST_TENANT_ID, TEST_ORDER_ID, { + itemType: OrderItemType.SERVICE, + description: 'Oil change', + quantity: 1, + unitPrice: 200, + }); + + // Assert + expect(result).toBeDefined(); + expect(result?.description).toBe('Oil change'); + expect(result?.subtotal).toBe(200); + }); + + it('should calculate item subtotal with discount', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(mockServiceOrder as ServiceOrder); + + // Calculate expected subtotal: 2 * 350 * (1 - 0.10) = 630 + const expectedSubtotal = 2 * 350 * 0.9; + + mockItemRepo.create.mockReturnValue({ + id: 'new-item-id', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.PART, + description: 'Fuel filter', + quantity: 2, + unitPrice: 350, + discountPct: 10, + subtotal: expectedSubtotal, + status: OrderItemStatus.PENDING, + } as OrderItem); + mockItemRepo.save.mockResolvedValue({ + id: 'new-item-id', + orderId: TEST_ORDER_ID, + itemType: OrderItemType.PART, + description: 'Fuel filter', + quantity: 2, + unitPrice: 350, + discountPct: 10, + subtotal: expectedSubtotal, + status: OrderItemStatus.PENDING, + } as OrderItem); + mockItemRepo.find.mockResolvedValue([]); + + // Act + const result = await service.addItem(TEST_TENANT_ID, TEST_ORDER_ID, { + itemType: OrderItemType.PART, + description: 'Fuel filter', + quantity: 2, + unitPrice: 350, + discountPct: 10, + }); + + // Assert + expect(result?.subtotal).toBe(630); + }); + + it('should return null when order not found', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue(null); + + // Act + const result = await service.addItem( + TEST_TENANT_ID, + 'non-existent-order', + { + itemType: OrderItemType.SERVICE, + description: 'Test', + quantity: 1, + unitPrice: 100, + } + ); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('getItems()', () => { + it('should return items sorted by sortOrder and createdAt', async () => { + // Arrange + mockItemRepo.find.mockResolvedValue(mockOrderItems as OrderItem[]); + + // Act + const result = await service.getItems(TEST_ORDER_ID); + + // Assert + expect(result).toHaveLength(2); + expect(mockItemRepo.find).toHaveBeenCalledWith({ + where: { orderId: TEST_ORDER_ID }, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + }); + }); + + describe('removeItem()', () => { + it('should remove item and recalculate totals', async () => { + // Arrange + const itemToRemove = mockOrderItems[0]; + mockItemRepo.findOne.mockResolvedValue(itemToRemove as OrderItem); + mockItemRepo.remove.mockResolvedValue(itemToRemove as OrderItem); + mockItemRepo.find.mockResolvedValue([mockOrderItems[1]] as OrderItem[]); + mockOrderRepo.findOne.mockResolvedValue(mockServiceOrder as ServiceOrder); + mockOrderRepo.save.mockResolvedValue(mockServiceOrder as ServiceOrder); + + // Act + const result = await service.removeItem('item-001'); + + // Assert + expect(result).toBe(true); + expect(mockItemRepo.remove).toHaveBeenCalledWith(itemToRemove); + }); + + it('should return false when item not found', async () => { + // Arrange + mockItemRepo.findOne.mockResolvedValue(null); + + // Act + const result = await service.removeItem('non-existent-item'); + + // Assert + expect(result).toBe(false); + }); + }); + }); + + // ============================================ + // Dashboard and Statistics + // ============================================ + describe('Dashboard and Statistics', () => { + describe('getOrdersByStatus()', () => { + it('should group orders by status for Kanban board', async () => { + // Arrange + const orders = [ + { ...mockServiceOrder, status: ServiceOrderStatus.RECEIVED }, + { ...mockServiceOrder, id: 'order-2', status: ServiceOrderStatus.IN_PROGRESS }, + { ...mockServiceOrder, id: 'order-3', status: ServiceOrderStatus.IN_PROGRESS }, + { ...mockServiceOrder, id: 'order-4', status: ServiceOrderStatus.COMPLETED }, + ]; + + mockOrderRepo.find.mockResolvedValue(orders as ServiceOrder[]); + + // Act + const result = await service.getOrdersByStatus(TEST_TENANT_ID); + + // Assert + expect(result[ServiceOrderStatus.RECEIVED]).toHaveLength(1); + expect(result[ServiceOrderStatus.IN_PROGRESS]).toHaveLength(2); + expect(result[ServiceOrderStatus.COMPLETED]).toHaveLength(1); + expect(result[ServiceOrderStatus.DELIVERED]).toHaveLength(0); + }); + }); + + describe('getDashboardStats()', () => { + it('should return correct dashboard statistics', async () => { + // Arrange + mockOrderRepo.count + .mockResolvedValueOnce(100) // totalOrders + .mockResolvedValueOnce(15) // pendingOrders + .mockResolvedValueOnce(25) // inProgressOrders + .mockResolvedValueOnce(40); // completedCount for average + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getCount as jest.Mock).mockResolvedValue(5); // completedToday + (queryBuilder.getRawOne as jest.Mock).mockResolvedValue({ total: '500000' }); + + // Act + const result = await service.getDashboardStats(TEST_TENANT_ID); + + // Assert + expect(result.totalOrders).toBe(100); + expect(result.pendingOrders).toBe(15); + expect(result.inProgressOrders).toBe(25); + expect(result.totalRevenue).toBe(500000); + expect(result.averageTicket).toBeCloseTo(12500, 0); // 500000 / 40 + }); + + it('should handle zero completed orders for average ticket', async () => { + // Arrange + mockOrderRepo.count + .mockResolvedValueOnce(10) // totalOrders + .mockResolvedValueOnce(5) // pendingOrders + .mockResolvedValueOnce(3) // inProgressOrders + .mockResolvedValueOnce(0); // completedCount = 0 + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getCount as jest.Mock).mockResolvedValue(0); + (queryBuilder.getRawOne as jest.Mock).mockResolvedValue({ total: null }); + + // Act + const result = await service.getDashboardStats(TEST_TENANT_ID); + + // Assert + expect(result.totalRevenue).toBe(0); + expect(result.averageTicket).toBe(0); + }); + }); + + describe('getTechnicianWorkload()', () => { + it('should return technician workload summary', async () => { + // Arrange + mockOrderRepo.count.mockResolvedValue(3); // activeOrders + + const queryBuilder = mockOrderRepo.createQueryBuilder(); + (queryBuilder.getCount as jest.Mock).mockResolvedValue(5); // completedToday + (queryBuilder.getMany as jest.Mock).mockResolvedValue([ + { id: 'order-1' }, + { id: 'order-2' }, + ]); + + mockItemRepo.find.mockResolvedValue([ + { estimatedHours: 2, status: OrderItemStatus.PENDING }, + { estimatedHours: 3, status: OrderItemStatus.PENDING }, + ] as OrderItem[]); + + // Act + const result = await service.getTechnicianWorkload(TEST_TECHNICIAN_ID, ctx); + + // Assert + expect(result.activeOrders).toBe(3); + expect(result.completedToday).toBe(5); + expect(result.pendingItems).toBe(4); // 2 items per order * 2 orders + expect(result.estimatedHoursRemaining).toBe(10); // (2+3) * 2 orders + }); + }); + }); + + // ============================================ + // Order Lifecycle + // ============================================ + describe('Order Lifecycle', () => { + describe('closeOrder()', () => { + it('should close order when all items are completed', async () => { + // Arrange + const orderToClose = { + ...mockServiceOrder, + status: ServiceOrderStatus.IN_PROGRESS, + }; + + mockOrderRepo.findOne.mockResolvedValue(orderToClose as ServiceOrder); + mockItemRepo.find.mockResolvedValue( + mockOrderItems.map((item) => ({ + ...item, + status: OrderItemStatus.COMPLETED, + })) as OrderItem[] + ); + + (mockDataSource.transaction as jest.Mock).mockImplementation( + createMockTransaction({ + ServiceOrder: { + findOne: jest.fn().mockResolvedValue({ + ...orderToClose, + status: ServiceOrderStatus.COMPLETED, + grandTotal: 1310.8, + }), + save: jest.fn().mockResolvedValue({ + ...orderToClose, + status: ServiceOrderStatus.COMPLETED, + }), + }, + Customer: { + findOne: jest.fn().mockResolvedValue(mockCustomer), + save: jest.fn().mockResolvedValue(mockCustomer), + }, + }) + ); + + // Act + const result = await service.closeOrder(TEST_ORDER_ID, ctx); + + // Assert + expect(result.status).toBe(ServiceOrderStatus.COMPLETED); + }); + + it('should reject closing order with pending items', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.IN_PROGRESS, + } as ServiceOrder); + + mockItemRepo.find.mockResolvedValue([ + { ...mockOrderItems[0], status: OrderItemStatus.COMPLETED }, + { ...mockOrderItems[1], status: OrderItemStatus.PENDING }, + ] as OrderItem[]); + + // Act & Assert + await expect(service.closeOrder(TEST_ORDER_ID, ctx)).rejects.toThrow( + /following items are not completed/ + ); + }); + + it('should reject closing order without items', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.IN_PROGRESS, + } as ServiceOrder); + + mockItemRepo.find.mockResolvedValue([]); + + // Act & Assert + await expect(service.closeOrder(TEST_ORDER_ID, ctx)).rejects.toThrow( + /Cannot close order without any work items/ + ); + }); + + it('should reject closing order in wrong status', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.RECEIVED, + } as ServiceOrder); + + // Act & Assert + await expect(service.closeOrder(TEST_ORDER_ID, ctx)).rejects.toThrow( + /Cannot close order with status 'received'/ + ); + }); + }); + + describe('deliverOrder()', () => { + it('should deliver completed order', async () => { + // Arrange + const completedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + odometerIn: 150000, + }; + + mockOrderRepo.findOne.mockResolvedValue(completedOrder as ServiceOrder); + + (mockDataSource.transaction as jest.Mock).mockImplementation( + createMockTransaction({ + ServiceOrder: { + save: jest.fn().mockResolvedValue({ + ...completedOrder, + status: ServiceOrderStatus.DELIVERED, + odometerOut: 150050, + deliveredAt: new Date(), + }), + }, + Vehicle: { + findOne: jest.fn().mockResolvedValue(mockVehicle), + save: jest.fn().mockResolvedValue(mockVehicle), + }, + }) + ); + + // Act + const result = await service.deliverOrder(TEST_ORDER_ID, 150050, ctx); + + // Assert + expect(result.status).toBe(ServiceOrderStatus.DELIVERED); + expect(result.odometerOut).toBe(150050); + }); + + it('should reject delivery of non-completed order', async () => { + // Arrange + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + status: ServiceOrderStatus.IN_PROGRESS, + } as ServiceOrder); + + // Act & Assert + await expect( + service.deliverOrder(TEST_ORDER_ID, undefined, ctx) + ).rejects.toThrow(/Order must be completed first/); + }); + + it('should reject odometer out less than odometer in', async () => { + // Arrange + const completedOrder = { + ...mockServiceOrder, + status: ServiceOrderStatus.COMPLETED, + odometerIn: 150000, + }; + + mockOrderRepo.findOne.mockResolvedValue(completedOrder as ServiceOrder); + + (mockDataSource.transaction as jest.Mock).mockImplementation(async () => { + throw new Error('Odometer out (149000) cannot be less than odometer in (150000)'); + }); + + // Act & Assert + await expect( + service.deliverOrder(TEST_ORDER_ID, 149000, ctx) + ).rejects.toThrow(/Odometer out.*cannot be less than odometer in/); + }); + + it('should require tenant ID', async () => { + // Act & Assert + await expect( + service.deliverOrder(TEST_ORDER_ID, undefined, undefined) + ).rejects.toThrow(/Tenant ID is required/); + }); + }); + }); + + // ============================================ + // Order Number Generation + // ============================================ + describe('Order Number Generation', () => { + it('should generate sequential order numbers', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(mockVehicle as Vehicle); + + // Simulate existing order with number OS-2026-00005 + mockOrderRepo.findOne.mockResolvedValue({ + ...mockServiceOrder, + orderNumber: 'OS-2026-00005', + } as ServiceOrder); + + let generatedOrderNumber: string | undefined; + (mockDataSource.transaction as jest.Mock).mockImplementation(async (cb: TransactionCallback) => { + const mockManager = { + getRepository: jest.fn().mockReturnValue({ + create: jest.fn().mockImplementation((data: Partial) => { + generatedOrderNumber = data.orderNumber; + return { ...mockServiceOrder, ...data }; + }), + save: jest.fn().mockResolvedValue({ + ...mockServiceOrder, + orderNumber: 'OS-2026-00006', + }), + }), + } as unknown as EntityManager; + return cb(mockManager); + }); + + // Act + await service.create(TEST_TENANT_ID, dto); + + // Assert + expect(generatedOrderNumber).toBe('OS-2026-00006'); + }); + + it('should start from 1 when no existing orders', async () => { + // Arrange + const dto: CreateServiceOrderDto = { + customerId: TEST_CUSTOMER_ID, + vehicleId: TEST_VEHICLE_ID, + }; + + mockCustomerRepo.findOne.mockResolvedValue(mockCustomer as Customer); + mockVehicleRepo.findOne.mockResolvedValue(mockVehicle as Vehicle); + mockOrderRepo.findOne.mockResolvedValue(null); // No existing orders + + let generatedOrderNumber: string | undefined; + (mockDataSource.transaction as jest.Mock).mockImplementation(async (cb: TransactionCallback) => { + const mockManager = { + getRepository: jest.fn().mockReturnValue({ + create: jest.fn().mockImplementation((data: Partial) => { + generatedOrderNumber = data.orderNumber; + return { ...mockServiceOrder, ...data }; + }), + save: jest.fn().mockResolvedValue({ + ...mockServiceOrder, + orderNumber: 'OS-2026-00001', + }), + }), + } as unknown as EntityManager; + return cb(mockManager); + }); + + // Act + await service.create(TEST_TENANT_ID, dto); + + // Assert + expect(generatedOrderNumber).toBe('OS-2026-00001'); + }); + }); +});