diff --git a/tests/unit/services/adminTicketService.test.js b/tests/unit/services/adminTicketService.test.js new file mode 100644 index 0000000..496bc46 --- /dev/null +++ b/tests/unit/services/adminTicketService.test.js @@ -0,0 +1,484 @@ +/** + * Admin Ticket Service Unit Tests + * + * Tests the AdminTicketService in isolation with all dependencies mocked. + * Covers two types of admin ticket creation: + * 1. Internal tickets (is_admin_created=true) - Hidden from department users + * 2. Department tickets (is_admin_created=false) - Visible to department users + */ + +const adminTicketService = require('../../../services/adminTicketService'); +const Ticket = require('../../../models/Ticket'); +const User = require('../../../models/User'); +const AuditLog = require('../../../models/AuditLog'); + +// Mock dependencies +jest.mock('../../../models/Ticket'); +jest.mock('../../../models/User'); +jest.mock('../../../models/AuditLog'); +jest.mock('../../../utils/logger'); + +describe('Admin Ticket Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createAdminTicket', () => { + it('should create admin ticket with is_admin_created=true', async () => { + // Arrange + const adminUserId = 1; + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Internal ticket', + description: 'Admin-only ticket', + reporter_department: 'Internal', + priority: 'medium', + status: 'open', + }; + const mockTicket = { id: 1, ...ticketData, is_admin_created: true, reporter_id: adminUserId }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await adminTicketService.createAdminTicket( + adminUserId, + ticketData, + '127.0.0.1' + ); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ is_admin_created: true }) + ); + expect(result.is_admin_created).toBe(true); + }); + + it('should set reporter_id to admin user ID', async () => { + // Arrange + const adminUserId = 42; + const adminUser = { id: 42, username: 'admin', role: 'admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1, reporter_id: 42 }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createAdminTicket(adminUserId, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ reporter_id: adminUserId }) + ); + }); + + it('should auto-populate reporter_name with admin username', async () => { + // Arrange + const adminUser = { id: 1, username: 'john.admin', role: 'super_admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1, reporter_name: 'john.admin' }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ reporter_name: 'john.admin' }) + ); + }); + + it('should allow admin user to create admin ticket', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act & Assert + await expect( + adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1') + ).resolves.toBeDefined(); + }); + + it('should allow super_admin user to create admin ticket', async () => { + // Arrange + const superAdmin = { id: 1, username: 'superadmin', role: 'super_admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(superAdmin); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act & Assert + await expect( + adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1') + ).resolves.toBeDefined(); + }); + + it('should reject non-admin user (department role)', async () => { + // Arrange + const deptUser = { id: 1, username: 'deptuser', role: 'department' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + + User.findById.mockResolvedValue(deptUser); + + // Act & Assert + await expect( + adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1') + ).rejects.toThrow('Only admins can create admin tickets'); + + expect(Ticket.create).not.toHaveBeenCalled(); + }); + + it('should throw error when user not found', async () => { + // Arrange + User.findById.mockResolvedValue(null); + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + + // Act & Assert + await expect( + adminTicketService.createAdminTicket(999, ticketData, '127.0.0.1') + ).rejects.toThrow('Admin user not found'); + }); + + it('should use default priority unset when not provided', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1, priority: 'unset' }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 'unset' })); + }); + + it('should use default status open when not provided', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { title: 'Test', description: 'Test', reporter_department: 'Internal' }; + const mockTicket = { id: 1, status: 'open' }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createAdminTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'open' })); + }); + + it('should create audit log with CREATE_ADMIN_TICKET action', async () => { + // Arrange + const adminUserId = 1; + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test Ticket', + description: 'Test', + reporter_department: 'Internal', + priority: 'high', + status: 'open', + }; + const mockTicket = { id: 10, ...ticketData }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createAdminTicket(adminUserId, ticketData, '192.168.1.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: adminUserId, + action: 'CREATE_ADMIN_TICKET', + targetType: 'ticket', + targetId: 10, + details: expect.objectContaining({ + title: 'Test Ticket', + priority: 'high', + status: 'open', + department: 'Internal', + }), + ipAddress: '192.168.1.1', + }); + }); + }); + + describe('createDepartmentTicket', () => { + it('should create department ticket with is_admin_created=false', async () => { + // Arrange + const adminUserId = 1; + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Dept ticket', + description: 'Created on behalf of dept', + reporter_name: 'Department Contact', + reporter_department: 'Emergency Department', + priority: 'medium', + }; + const mockTicket = { id: 1, ...ticketData, is_admin_created: false }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await adminTicketService.createDepartmentTicket( + adminUserId, + ticketData, + '127.0.0.1' + ); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ is_admin_created: false }) + ); + expect(result.is_admin_created).toBe(false); + }); + + it('should set reporter_id to NULL for anonymous ticket', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'John Doe', + reporter_department: 'Cardiology', + }; + const mockTicket = { id: 1, reporter_id: null }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ reporter_id: null })); + }); + + it('should use reporter_name from form input (not auto-populated)', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Dr. Sarah Johnson', + reporter_department: 'Radiology', + }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ reporter_name: 'Dr. Sarah Johnson' }) + ); + }); + + it('should force status to open', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Pharmacy', + status: 'closed', // Admin tries to set different status + }; + const mockTicket = { id: 1, status: 'open' }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'open' })); + }); + + it('should reject Internal department', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Internal', // System department not allowed + }; + + User.findById.mockResolvedValue(adminUser); + + // Act & Assert + await expect( + adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1') + ).rejects.toThrow('Cannot create department ticket for Internal department'); + + expect(Ticket.create).not.toHaveBeenCalled(); + }); + + it('should allow admin user to create department ticket', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Laboratory', + }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act & Assert + await expect( + adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1') + ).resolves.toBeDefined(); + }); + + it('should allow super_admin user to create department ticket', async () => { + // Arrange + const superAdmin = { id: 1, username: 'superadmin', role: 'super_admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Surgery', + }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(superAdmin); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act & Assert + await expect( + adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1') + ).resolves.toBeDefined(); + }); + + it('should reject non-admin user', async () => { + // Arrange + const deptUser = { id: 1, username: 'deptuser', role: 'department' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Cardiology', + }; + + User.findById.mockResolvedValue(deptUser); + + // Act & Assert + await expect( + adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1') + ).rejects.toThrow('Only admins can create department tickets'); + + expect(Ticket.create).not.toHaveBeenCalled(); + }); + + it('should throw error when user not found', async () => { + // Arrange + User.findById.mockResolvedValue(null); + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Radiology', + }; + + // Act & Assert + await expect( + adminTicketService.createDepartmentTicket(999, ticketData, '127.0.0.1') + ).rejects.toThrow('Admin user not found'); + }); + + it('should use default priority unset when not provided', async () => { + // Arrange + const adminUser = { id: 1, username: 'admin', role: 'admin' }; + const ticketData = { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Pharmacy', + }; + const mockTicket = { id: 1 }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createDepartmentTicket(1, ticketData, '127.0.0.1'); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 'unset' })); + }); + + it('should create audit log with CREATE_DEPARTMENT_TICKET action', async () => { + // Arrange + const adminUserId = 1; + const adminUser = { id: 1, username: 'admin.user', role: 'admin' }; + const ticketData = { + title: 'Patient Monitor Issue', + description: 'Monitor not working', + reporter_name: 'Dr. Smith', + reporter_department: 'Intensive Care Unit', + priority: 'high', + }; + const mockTicket = { id: 20, ...ticketData }; + + User.findById.mockResolvedValue(adminUser); + Ticket.create.mockResolvedValue(mockTicket); + AuditLog.create.mockResolvedValue({}); + + // Act + await adminTicketService.createDepartmentTicket(adminUserId, ticketData, '10.0.0.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: adminUserId, + action: 'CREATE_DEPARTMENT_TICKET', + targetType: 'ticket', + targetId: 20, + details: expect.objectContaining({ + title: 'Patient Monitor Issue', + priority: 'high', + department: 'Intensive Care Unit', + reporter_name: 'Dr. Smith', + createdBy: 'admin.user', + }), + ipAddress: '10.0.0.1', + }); + }); + }); +}); diff --git a/tests/unit/services/clientTicketService.test.js b/tests/unit/services/clientTicketService.test.js new file mode 100644 index 0000000..1c4c2da --- /dev/null +++ b/tests/unit/services/clientTicketService.test.js @@ -0,0 +1,522 @@ +/** + * Client Ticket Service Unit Tests + * + * Tests the ClientTicketService in isolation with all dependencies mocked. + * Focuses on ownership-aware business logic for department user portal. + */ + +const clientTicketService = require('../../../services/clientTicketService'); +const Ticket = require('../../../models/Ticket'); +const Comment = require('../../../models/Comment'); +const User = require('../../../models/User'); +const AuditLog = require('../../../models/AuditLog'); + +// Mock dependencies +jest.mock('../../../models/Ticket'); +jest.mock('../../../models/Comment'); +jest.mock('../../../models/User'); +jest.mock('../../../models/AuditLog'); +jest.mock('../../../utils/logger'); + +describe('Client Ticket Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + AuditLog.create.mockResolvedValue({}); + }); + + describe('createTicket', () => { + it('should create ticket with auto-populated department from user', async () => { + // Arrange + const userId = 1; + const mockUser = { + id: 1, + username: 'deptuser', + department: 'Emergency Department', + role: 'department', + }; + const ticketData = { + title: 'Test Ticket', + description: 'Test description', + reporter_phone: '+1234567890', + }; + const mockTicket = { + id: 1, + ...ticketData, + reporter_department: 'Emergency Department', + reporter_id: userId, + priority: 'unset', + status: 'waiting_on_admin', + }; + + User.findById.mockResolvedValue(mockUser); + Ticket.create.mockResolvedValue(mockTicket); + + // Act + const result = await clientTicketService.createTicket(userId, ticketData); + + // Assert + expect(result).toEqual(mockTicket); + expect(User.findById).toHaveBeenCalledWith(userId); + expect(Ticket.create).toHaveBeenCalledWith({ + title: ticketData.title, + description: ticketData.description, + reporter_name: mockUser.username, // Auto-populated + reporter_department: 'Emergency Department', // Auto-populated + reporter_phone: ticketData.reporter_phone, + reporter_id: userId, // Ownership enforcement + priority: 'unset', // Forced + status: 'waiting_on_admin', // Department-created tickets + }); + }); + + it('should force priority to unset regardless of input', async () => { + // Arrange + const userId = 1; + const mockUser = { id: 1, department: 'Cardiology', role: 'department' }; + const ticketData = { + title: 'Test', + description: 'Test', + priority: 'critical', // Department user tries to set priority + }; + const mockTicket = { id: 1, ...ticketData, priority: 'unset', reporter_id: userId }; + + User.findById.mockResolvedValue(mockUser); + Ticket.create.mockResolvedValue(mockTicket); + + // Act + await clientTicketService.createTicket(userId, ticketData); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 'unset' })); + }); + + it('should set status to waiting_on_admin for department-created tickets', async () => { + // Arrange + const userId = 1; + const mockUser = { id: 1, department: 'Pharmacy', role: 'department' }; + const ticketData = { title: 'Test', description: 'Test' }; + const mockTicket = { id: 1, status: 'waiting_on_admin' }; + + User.findById.mockResolvedValue(mockUser); + Ticket.create.mockResolvedValue(mockTicket); + + // Act + await clientTicketService.createTicket(userId, ticketData); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith( + expect.objectContaining({ status: 'waiting_on_admin' }) + ); + }); + + it('should set reporter_id to enforce ownership', async () => { + // Arrange + const userId = 42; + const mockUser = { id: 42, department: 'Laboratory', role: 'department' }; + const ticketData = { title: 'Test', description: 'Test' }; + const mockTicket = { id: 1, reporter_id: 42 }; + + User.findById.mockResolvedValue(mockUser); + Ticket.create.mockResolvedValue(mockTicket); + + // Act + await clientTicketService.createTicket(userId, ticketData); + + // Assert + expect(Ticket.create).toHaveBeenCalledWith(expect.objectContaining({ reporter_id: userId })); + }); + + it('should throw error when user not found', async () => { + // Arrange + User.findById.mockResolvedValue(null); + + // Act & Assert + await expect( + clientTicketService.createTicket(999, { title: 'Test', description: 'Test' }) + ).rejects.toThrow('User not found'); + }); + + it('should throw error when user has no department set', async () => { + // Arrange + const mockUser = { id: 1, username: 'user', department: null, role: 'department' }; + User.findById.mockResolvedValue(mockUser); + + // Act & Assert + await expect( + clientTicketService.createTicket(1, { title: 'Test', description: 'Test' }) + ).rejects.toThrow('Department not set for user'); + }); + + it('should propagate database errors', async () => { + // Arrange + const mockUser = { id: 1, department: 'Test Dept', role: 'department' }; + const dbError = new Error('Database connection failed'); + + User.findById.mockResolvedValue(mockUser); + Ticket.create.mockRejectedValue(dbError); + + // Act & Assert + await expect( + clientTicketService.createTicket(1, { title: 'Test', description: 'Test' }) + ).rejects.toThrow('Database connection failed'); + }); + }); + + describe('getDepartmentTickets', () => { + it('should fetch tickets for a department with no filters', async () => { + // Arrange + const userId = 1; + const department = 'Cardiology'; + const mockTickets = [ + { id: 1, title: 'Ticket 1', reporter_department: department }, + { id: 2, title: 'Ticket 2', reporter_department: department }, + ]; + + Ticket.findByDepartment.mockResolvedValue(mockTickets); + + // Act + const result = await clientTicketService.getDepartmentTickets(userId, department); + + // Assert + expect(result).toEqual(mockTickets); + expect(Ticket.findByDepartment).toHaveBeenCalledWith(department, { + status: undefined, + priority: undefined, + search: undefined, + }); + }); + + it('should apply status filter', async () => { + // Arrange + const userId = 1; + const department = 'Cardiology'; + const filters = { status: 'open' }; + const mockTickets = [{ id: 1, status: 'open', reporter_department: department }]; + + Ticket.findByDepartment.mockResolvedValue(mockTickets); + + // Act + await clientTicketService.getDepartmentTickets(userId, department, filters); + + // Assert + expect(Ticket.findByDepartment).toHaveBeenCalledWith(department, { + status: 'open', + priority: undefined, + search: undefined, + }); + }); + + it('should apply priority filter', async () => { + // Arrange + const userId = 1; + const department = 'Cardiology'; + const filters = { priority: 'high' }; + + Ticket.findByDepartment.mockResolvedValue([]); + + // Act + await clientTicketService.getDepartmentTickets(userId, department, filters); + + // Assert + expect(Ticket.findByDepartment).toHaveBeenCalledWith(department, { + status: undefined, + priority: 'high', + search: undefined, + }); + }); + + it('should apply search filter', async () => { + // Arrange + const userId = 1; + const department = 'Cardiology'; + const filters = { search: 'printer' }; + + Ticket.findByDepartment.mockResolvedValue([]); + + // Act + await clientTicketService.getDepartmentTickets(userId, department, filters); + + // Assert + expect(Ticket.findByDepartment).toHaveBeenCalledWith(department, { + status: undefined, + priority: undefined, + search: 'printer', + }); + }); + + it('should apply multiple filters simultaneously', async () => { + // Arrange + const userId = 1; + const department = 'Cardiology'; + const filters = { status: 'open', priority: 'high', search: 'urgent' }; + + Ticket.findByDepartment.mockResolvedValue([]); + + // Act + await clientTicketService.getDepartmentTickets(userId, department, filters); + + // Assert + expect(Ticket.findByDepartment).toHaveBeenCalledWith(department, { + status: 'open', + priority: 'high', + search: 'urgent', + }); + }); + + it('should throw error on database failure', async () => { + // Arrange + const dbError = new Error('Database error'); + Ticket.findByDepartment.mockRejectedValue(dbError); + + // Act & Assert + await expect(clientTicketService.getDepartmentTickets(1, 'Cardiology')).rejects.toThrow( + 'Database error' + ); + }); + }); + + describe('getTicketById', () => { + it('should return ticket when found', async () => { + // Arrange + const mockTicket = { id: 1, title: 'Test Ticket' }; + Ticket.findById.mockResolvedValue(mockTicket); + + // Act + const result = await clientTicketService.getTicketById(1); + + // Assert + expect(result).toEqual(mockTicket); + expect(Ticket.findById).toHaveBeenCalledWith(1); + }); + + it('should return undefined when ticket not found', async () => { + // Arrange + Ticket.findById.mockResolvedValue(undefined); + + // Act + const result = await clientTicketService.getTicketById(999); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('getVisibleComments', () => { + it('should fetch public comments for department users', async () => { + // Arrange + const ticketId = 1; + const mockComments = [ + { id: 1, content: 'Public comment', visibility_type: 'public' }, + { id: 2, content: 'Another public', visibility_type: 'public' }, + ]; + + Comment.findVisibleByTicketId.mockResolvedValue(mockComments); + + // Act + const result = await clientTicketService.getVisibleComments(ticketId); + + // Assert + expect(result).toEqual(mockComments); + expect(Comment.findVisibleByTicketId).toHaveBeenCalledWith(ticketId, 'department'); + }); + + it('should not include internal comments (filtered by model)', async () => { + // Arrange + const ticketId = 1; + const publicCommentsOnly = [{ id: 1, content: 'Public comment', visibility_type: 'public' }]; + + Comment.findVisibleByTicketId.mockResolvedValue(publicCommentsOnly); + + // Act + const result = await clientTicketService.getVisibleComments(ticketId); + + // Assert + expect(result).toEqual(publicCommentsOnly); + // Verify 'department' role passed (filters internal comments at model layer) + expect(Comment.findVisibleByTicketId).toHaveBeenCalledWith(ticketId, 'department'); + }); + + it('should throw error on database failure', async () => { + // Arrange + const dbError = new Error('Database error'); + Comment.findVisibleByTicketId.mockRejectedValue(dbError); + + // Act & Assert + await expect(clientTicketService.getVisibleComments(1)).rejects.toThrow('Database error'); + }); + }); + + describe('addComment', () => { + it('should create comment with forced public visibility', async () => { + // Arrange + const ticketId = 1; + const userId = 5; + const content = 'This is my comment'; + const mockTicket = { id: 1, status: 'open' }; + const mockComment = { + id: 1, + ticket_id: ticketId, + user_id: userId, + content, + visibility_type: 'public', + }; + + Ticket.findById.mockResolvedValue(mockTicket); + Comment.create.mockResolvedValue(mockComment); + Ticket.update.mockResolvedValue({ id: 1, status: 'waiting_on_admin' }); + + // Act + const result = await clientTicketService.addComment(ticketId, userId, content); + + // Assert + expect(result).toEqual(mockComment); + expect(Comment.create).toHaveBeenCalledWith({ + ticket_id: ticketId, + user_id: userId, + content, + visibility_type: 'public', // Forced for department users + }); + }); + + it('should auto-update ticket status to waiting_on_admin when ticket not closed', async () => { + // Arrange + const ticketId = 1; + const mockTicket = { id: 1, status: 'in_progress' }; + const mockComment = { id: 1 }; + + Ticket.findById.mockResolvedValue(mockTicket); + Comment.create.mockResolvedValue(mockComment); + Ticket.update.mockResolvedValue({ id: 1, status: 'waiting_on_admin' }); + + // Act + await clientTicketService.addComment(ticketId, 1, 'Comment'); + + // Assert + expect(Ticket.update).toHaveBeenCalledWith(ticketId, { status: 'waiting_on_admin' }); + }); + + it('should not update status when ticket is closed', async () => { + // Arrange + const ticketId = 1; + const mockTicket = { id: 1, status: 'closed' }; + const mockComment = { id: 1 }; + + Ticket.findById.mockResolvedValue(mockTicket); + Comment.create.mockResolvedValue(mockComment); + + // Act + await clientTicketService.addComment(ticketId, 1, 'Comment'); + + // Assert + expect(Ticket.update).not.toHaveBeenCalled(); + }); + + it('should throw error when ticket not found', async () => { + // Arrange + Ticket.findById.mockResolvedValue(null); + + // Act & Assert + await expect(clientTicketService.addComment(999, 1, 'Comment')).rejects.toThrow( + 'Ticket not found' + ); + }); + + it('should propagate comment creation errors', async () => { + // Arrange + const mockTicket = { id: 1, status: 'open' }; + const dbError = new Error('Comment creation failed'); + + Ticket.findById.mockResolvedValue(mockTicket); + Comment.create.mockRejectedValue(dbError); + + // Act & Assert + await expect(clientTicketService.addComment(1, 1, 'Comment')).rejects.toThrow( + 'Comment creation failed' + ); + }); + }); + + describe('updateTicketStatus', () => { + it('should allow status change to waiting_on_admin', async () => { + // Arrange + const ticketId = 1; + const newStatus = 'waiting_on_admin'; + const mockTicket = { id: 1, status: 'waiting_on_admin' }; + + Ticket.update.mockResolvedValue(mockTicket); + + // Act + const result = await clientTicketService.updateTicketStatus(ticketId, newStatus); + + // Assert + expect(result).toEqual(mockTicket); + expect(Ticket.update).toHaveBeenCalledWith(ticketId, { status: 'waiting_on_admin' }); + }); + + it('should allow status change to closed', async () => { + // Arrange + const ticketId = 1; + const newStatus = 'closed'; + const mockTicket = { id: 1, status: 'closed' }; + + Ticket.update.mockResolvedValue(mockTicket); + + // Act + const result = await clientTicketService.updateTicketStatus(ticketId, newStatus); + + // Assert + expect(result).toEqual(mockTicket); + expect(Ticket.update).toHaveBeenCalledWith(ticketId, { status: 'closed' }); + }); + + it('should reject status change to open (admin-only)', async () => { + // Arrange + const ticketId = 1; + const invalidStatus = 'open'; + + // Act & Assert + await expect(clientTicketService.updateTicketStatus(ticketId, invalidStatus)).rejects.toThrow( + 'Department users cannot set status to: open' + ); + + expect(Ticket.update).not.toHaveBeenCalled(); + }); + + it('should reject status change to in_progress (admin-only)', async () => { + // Arrange + const ticketId = 1; + const invalidStatus = 'in_progress'; + + // Act & Assert + await expect(clientTicketService.updateTicketStatus(ticketId, invalidStatus)).rejects.toThrow( + 'Department users cannot set status to: in_progress' + ); + + expect(Ticket.update).not.toHaveBeenCalled(); + }); + + it('should reject status change to waiting_on_department (admin-only)', async () => { + // Arrange + const ticketId = 1; + const invalidStatus = 'waiting_on_department'; + + // Act & Assert + await expect(clientTicketService.updateTicketStatus(ticketId, invalidStatus)).rejects.toThrow( + 'Department users cannot set status to: waiting_on_department' + ); + + expect(Ticket.update).not.toHaveBeenCalled(); + }); + + it('should throw error on database failure', async () => { + // Arrange + const dbError = new Error('Database error'); + Ticket.update.mockRejectedValue(dbError); + + // Act & Assert + await expect(clientTicketService.updateTicketStatus(1, 'closed')).rejects.toThrow( + 'Database error' + ); + }); + }); +}); diff --git a/tests/unit/services/departmentService.test.js b/tests/unit/services/departmentService.test.js new file mode 100644 index 0000000..9ea1833 --- /dev/null +++ b/tests/unit/services/departmentService.test.js @@ -0,0 +1,785 @@ +/** + * Department Service Unit Tests + * + * Tests the DepartmentService in isolation with all dependencies mocked. + * Covers extensive business logic including: + * - CRUD operations with system department protection + * - User assignment with safety checks + * - Audit logging for all operations + * - Validation and error handling + */ + +const departmentService = require('../../../services/departmentService'); +const Department = require('../../../models/Department'); +const AuditLog = require('../../../models/AuditLog'); +const User = require('../../../models/User'); + +// Mock dependencies +jest.mock('../../../models/Department'); +jest.mock('../../../models/AuditLog'); +jest.mock('../../../models/User'); +jest.mock('../../../utils/logger'); + +describe('Department Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getActiveDepartments', () => { + it('should fetch active departments without system departments', async () => { + // Arrange + const mockDepartments = [ + { id: 1, name: 'Emergency Department', active: true, is_system: false }, + { id: 2, name: 'Cardiology', active: true, is_system: false }, + ]; + Department.findAll.mockResolvedValue(mockDepartments); + + // Act + const result = await departmentService.getActiveDepartments(false); + + // Assert + expect(result).toEqual(mockDepartments); + expect(Department.findAll).toHaveBeenCalledWith(false); + }); + + it('should fetch active departments including system departments', async () => { + // Arrange + const mockDepartments = [ + { id: 1, name: 'Emergency Department', active: true, is_system: false }, + { id: 2, name: 'Internal', active: true, is_system: true }, + ]; + Department.findAll.mockResolvedValue(mockDepartments); + + // Act + const result = await departmentService.getActiveDepartments(true); + + // Assert + expect(result).toEqual(mockDepartments); + expect(Department.findAll).toHaveBeenCalledWith(true); + }); + }); + + describe('getAllDepartments', () => { + it('should fetch all departments for admin management', async () => { + // Arrange + const mockDepartments = [ + { id: 1, name: 'Active Dept', active: true, is_system: false }, + { id: 2, name: 'Inactive Dept', active: false, is_system: false }, + ]; + Department.findAllForAdmin.mockResolvedValue(mockDepartments); + + // Act + const result = await departmentService.getAllDepartments(); + + // Assert + expect(result).toEqual(mockDepartments); + expect(Department.findAllForAdmin).toHaveBeenCalled(); + }); + }); + + describe('getDepartmentById', () => { + it('should return department when found', async () => { + // Arrange + const mockDepartment = { id: 1, name: 'Emergency Department' }; + Department.findById.mockResolvedValue(mockDepartment); + + // Act + const result = await departmentService.getDepartmentById(1); + + // Assert + expect(result).toEqual(mockDepartment); + }); + + it('should throw error when department not found', async () => { + // Arrange + Department.findById.mockResolvedValue(null); + + // Act & Assert + await expect(departmentService.getDepartmentById(999)).rejects.toThrow( + 'Department not found' + ); + }); + }); + + describe('createDepartment', () => { + it('should create department with valid data', async () => { + // Arrange + const actorId = 1; + const deptData = { + name: 'New Department', + description: 'Test description', + floor: 'Ground Floor', + }; + const mockCreated = { id: 1, ...deptData, is_system: false, active: true }; + + Department.findByName.mockResolvedValue(null); // No duplicate + Department.create.mockResolvedValue(mockCreated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.createDepartment(actorId, deptData, '127.0.0.1'); + + // Assert + expect(result).toEqual(mockCreated); + expect(Department.create).toHaveBeenCalledWith({ + name: 'New Department', + description: 'Test description', + floor: 'Ground Floor', + }); + }); + + it('should trim whitespace from department name', async () => { + // Arrange + const deptData = { name: ' Radiology ', description: 'Test', floor: 'Ground Floor' }; + const mockCreated = { id: 1, name: 'Radiology', description: 'Test' }; + + Department.findByName.mockResolvedValue(null); + Department.create.mockResolvedValue(mockCreated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.createDepartment(1, deptData, '127.0.0.1'); + + // Assert + expect(Department.findByName).toHaveBeenCalledWith('Radiology'); + expect(Department.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Radiology' }) + ); + }); + + it('should trim whitespace from description', async () => { + // Arrange + const deptData = { name: 'Test', description: ' Description ', floor: 'Ground Floor' }; + const mockCreated = { id: 1, name: 'Test', description: 'Description' }; + + Department.findByName.mockResolvedValue(null); + Department.create.mockResolvedValue(mockCreated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.createDepartment(1, deptData, '127.0.0.1'); + + // Assert + expect(Department.create).toHaveBeenCalledWith( + expect.objectContaining({ description: 'Description' }) + ); + }); + + it('should reject empty department name', async () => { + // Arrange + const deptData = { name: '', description: 'Test' }; + + // Act & Assert + await expect(departmentService.createDepartment(1, deptData, '127.0.0.1')).rejects.toThrow( + 'Department name is required' + ); + + expect(Department.create).not.toHaveBeenCalled(); + }); + + it('should reject whitespace-only department name', async () => { + // Arrange + const deptData = { name: ' ', description: 'Test' }; + + // Act & Assert + await expect(departmentService.createDepartment(1, deptData, '127.0.0.1')).rejects.toThrow( + 'Department name is required' + ); + }); + + it('should reject duplicate department name', async () => { + // Arrange + const deptData = { name: 'Existing Dept', description: 'Test', floor: 'Ground Floor' }; + const existing = { id: 5, name: 'Existing Dept' }; + + Department.findByName.mockResolvedValue(existing); + + // Act & Assert + await expect(departmentService.createDepartment(1, deptData, '127.0.0.1')).rejects.toThrow( + 'Department with this name already exists' + ); + + expect(Department.create).not.toHaveBeenCalled(); + }); + + it('should create audit log with correct details', async () => { + // Arrange + const actorId = 42; + const deptData = { name: 'Surgery', description: 'Surgical services', floor: 'Ground Floor' }; + const mockCreated = { id: 10, ...deptData }; + + Department.findByName.mockResolvedValue(null); + Department.create.mockResolvedValue(mockCreated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.createDepartment(actorId, deptData, '192.168.1.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 42, + action: 'CREATE_DEPARTMENT', + targetType: 'department', + targetId: 10, + details: { name: 'Surgery', description: 'Surgical services', floor: 'Ground Floor' }, + ipAddress: '192.168.1.1', + }); + }); + }); + + describe('updateDepartment', () => { + it('should update department name', async () => { + // Arrange + const current = { id: 1, name: 'Old Name', description: 'Desc', is_system: false }; + const updated = { id: 1, name: 'New Name', description: 'Desc', is_system: false }; + + Department.findById.mockResolvedValue(current); + Department.findByName.mockResolvedValue(null); // No duplicate + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.updateDepartment( + 1, + 1, + { name: 'New Name' }, + '127.0.0.1' + ); + + // Assert + expect(result).toEqual(updated); + expect(Department.update).toHaveBeenCalledWith(1, { + name: 'New Name', + description: undefined, + active: undefined, + }); + }); + + it('should update description only', async () => { + // Arrange + const current = { id: 1, name: 'Dept', description: 'Old desc', is_system: false }; + const updated = { id: 1, name: 'Dept', description: 'New desc', is_system: false }; + + Department.findById.mockResolvedValue(current); + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.updateDepartment(1, 1, { description: 'New desc' }, '127.0.0.1'); + + // Assert + expect(Department.update).toHaveBeenCalledWith(1, { + name: undefined, + description: 'New desc', + active: undefined, + }); + }); + + it('should update active status', async () => { + // Arrange + const current = { id: 1, name: 'Dept', description: 'Desc', is_system: false, active: true }; + const updated = { ...current, active: false }; + + Department.findById.mockResolvedValue(current); + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.updateDepartment(1, 1, { active: false }, '127.0.0.1'); + + // Assert + expect(Department.update).toHaveBeenCalledWith(1, { + name: undefined, + description: undefined, + active: false, + }); + }); + + it('should reject updating system department', async () => { + // Arrange + const systemDept = { id: 1, name: 'Internal', is_system: true }; + Department.findById.mockResolvedValue(systemDept); + + // Act & Assert + await expect( + departmentService.updateDepartment(1, 1, { name: 'New Name' }, '127.0.0.1') + ).rejects.toThrow('Cannot edit system department'); + + expect(Department.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate name (different department)', async () => { + // Arrange + const current = { id: 1, name: 'Dept A', is_system: false }; + const existing = { id: 2, name: 'Dept B' }; + + Department.findById.mockResolvedValue(current); + Department.findByName.mockResolvedValue(existing); // Another dept exists + + // Act & Assert + await expect( + departmentService.updateDepartment(1, 1, { name: 'Dept B' }, '127.0.0.1') + ).rejects.toThrow('Department with this name already exists'); + + expect(Department.update).not.toHaveBeenCalled(); + }); + + it('should allow updating to same name (no duplicate)', async () => { + // Arrange + const current = { id: 1, name: 'Same Name', is_system: false }; + const updated = { id: 1, name: 'Same Name', description: 'Updated' }; + + Department.findById.mockResolvedValue(current); + Department.findByName.mockResolvedValue(current); // Same department + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.updateDepartment( + 1, + 1, + { name: 'Same Name', description: 'Updated' }, + '127.0.0.1' + ); + + // Assert + expect(Department.update).toHaveBeenCalled(); + }); + + it('should trim whitespace from name and description', async () => { + // Arrange + const current = { id: 1, name: 'Old', description: 'Old', is_system: false }; + const updated = { id: 1, name: 'New', description: 'Desc' }; + + Department.findById.mockResolvedValue(current); + Department.findByName.mockResolvedValue(null); + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.updateDepartment( + 1, + 1, + { name: ' New ', description: ' Desc ' }, + '127.0.0.1' + ); + + // Assert + expect(Department.update).toHaveBeenCalledWith(1, { + name: 'New', + description: 'Desc', + active: undefined, + }); + }); + + it('should throw error when update fails (returns undefined)', async () => { + // Arrange + const current = { id: 1, name: 'Dept', is_system: false }; + + Department.findById.mockResolvedValue(current); + Department.update.mockResolvedValue(undefined); // Update failed + + // Act & Assert + await expect( + departmentService.updateDepartment(1, 1, { name: 'New' }, '127.0.0.1') + ).rejects.toThrow('Failed to update department'); + }); + + it('should create audit log with old and new values', async () => { + // Arrange + const actorId = 5; + const current = { + id: 10, + name: 'Old Name', + description: 'Old Desc', + is_system: false, + active: true, + }; + const updated = { id: 10, name: 'New Name', description: 'New Desc', active: false }; + + Department.findById.mockResolvedValue(current); + Department.findByName.mockResolvedValue(null); + Department.update.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.updateDepartment( + actorId, + 10, + { name: 'New Name', description: 'New Desc', active: false }, + '10.0.0.1' + ); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 5, + action: 'UPDATE_DEPARTMENT', + targetType: 'department', + targetId: 10, + details: { + old: { name: 'Old Name', description: 'Old Desc', active: true }, + new: { name: 'New Name', description: 'New Desc', active: false }, + }, + ipAddress: '10.0.0.1', + }); + }); + }); + + describe('deactivateDepartment', () => { + it('should deactivate department when no users assigned', async () => { + // Arrange + const dept = { id: 1, name: 'Empty Dept', is_system: false }; + const deactivated = { ...dept, active: false }; + + Department.findById.mockResolvedValue(dept); + Department.countUsers.mockResolvedValue(0); + Department.deactivate.mockResolvedValue(deactivated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.deactivateDepartment(1, 1, '127.0.0.1'); + + // Assert + expect(result).toEqual(deactivated); + expect(Department.deactivate).toHaveBeenCalledWith(1); + }); + + it('should reject deactivation when users are assigned', async () => { + // Arrange + const dept = { id: 1, name: 'Busy Dept', is_system: false }; + + Department.findById.mockResolvedValue(dept); + Department.countUsers.mockResolvedValue(5); // 5 users assigned + + // Act & Assert + await expect(departmentService.deactivateDepartment(1, 1, '127.0.0.1')).rejects.toThrow( + 'Cannot deactivate department: 5 user(s) still assigned' + ); + + expect(Department.deactivate).not.toHaveBeenCalled(); + }); + + it('should reject deactivating system department', async () => { + // Arrange + const systemDept = { id: 1, name: 'Internal', is_system: true }; + + Department.findById.mockResolvedValue(systemDept); + + // Act & Assert + await expect(departmentService.deactivateDepartment(1, 1, '127.0.0.1')).rejects.toThrow( + 'Cannot deactivate system department' + ); + + expect(Department.countUsers).not.toHaveBeenCalled(); + expect(Department.deactivate).not.toHaveBeenCalled(); + }); + + it('should throw error when deactivation fails', async () => { + // Arrange + const dept = { id: 1, name: 'Dept', is_system: false }; + + Department.findById.mockResolvedValue(dept); + Department.countUsers.mockResolvedValue(0); + Department.deactivate.mockResolvedValue(undefined); // Failed + + // Act & Assert + await expect(departmentService.deactivateDepartment(1, 1, '127.0.0.1')).rejects.toThrow( + 'Failed to deactivate department' + ); + }); + + it('should create audit log', async () => { + // Arrange + const actorId = 3; + const dept = { id: 7, name: 'Test Dept', is_system: false }; + const deactivated = { ...dept, active: false }; + + Department.findById.mockResolvedValue(dept); + Department.countUsers.mockResolvedValue(0); + Department.deactivate.mockResolvedValue(deactivated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.deactivateDepartment(actorId, 7, '192.168.1.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 3, + action: 'DEACTIVATE_DEPARTMENT', + targetType: 'department', + targetId: 7, + details: { name: 'Test Dept' }, + ipAddress: '192.168.1.1', + }); + }); + }); + + describe('reactivateDepartment', () => { + it('should reactivate department', async () => { + // Arrange + const dept = { id: 1, name: 'Inactive Dept', active: false }; + const reactivated = { ...dept, active: true }; + + Department.findById.mockResolvedValue(dept); + Department.update.mockResolvedValue(reactivated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.reactivateDepartment(1, 1, '127.0.0.1'); + + // Assert + expect(result).toEqual(reactivated); + expect(Department.update).toHaveBeenCalledWith(1, { active: true }); + }); + + it('should create audit log', async () => { + // Arrange + const actorId = 2; + const dept = { id: 5, name: 'Reactivating Dept', active: false }; + const reactivated = { ...dept, active: true }; + + Department.findById.mockResolvedValue(dept); + Department.update.mockResolvedValue(reactivated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.reactivateDepartment(actorId, 5, '10.0.0.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 2, + action: 'REACTIVATE_DEPARTMENT', + targetType: 'department', + targetId: 5, + details: { name: 'Reactivating Dept' }, + ipAddress: '10.0.0.1', + }); + }); + }); + + describe('getDepartmentUsers', () => { + it('should return users assigned to department', async () => { + // Arrange + const dept = { id: 1, name: 'Emergency Department' }; + const mockUsers = [ + { id: 1, username: 'user1' }, + { id: 2, username: 'user2' }, + ]; + + Department.findById.mockResolvedValue(dept); + Department.getUsers.mockResolvedValue(mockUsers); + + // Act + const result = await departmentService.getDepartmentUsers(1); + + // Assert + expect(result).toEqual(mockUsers); + expect(Department.getUsers).toHaveBeenCalledWith('Emergency Department'); + }); + }); + + describe('getAvailableUsers', () => { + it('should return users available for assignment', async () => { + // Arrange + const dept = { id: 1, name: 'Cardiology' }; + const mockUsers = [ + { id: 3, username: 'available1', department: null }, + { id: 4, username: 'available2', department: 'Other Dept' }, + ]; + + Department.findById.mockResolvedValue(dept); + Department.getAvailableUsers.mockResolvedValue(mockUsers); + + // Act + const result = await departmentService.getAvailableUsers(1); + + // Assert + expect(result).toEqual(mockUsers); + expect(Department.getAvailableUsers).toHaveBeenCalledWith('Cardiology'); + }); + }); + + describe('assignUserToDepartment', () => { + it('should assign department user to department', async () => { + // Arrange + const dept = { id: 1, name: 'Radiology', is_system: false }; + const user = { id: 5, username: 'deptuser', role: 'department', department: null }; + const updated = { ...user, department: 'Radiology' }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(user); + User.updateDepartment.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.assignUserToDepartment(1, 1, 5, '127.0.0.1'); + + // Assert + expect(result).toEqual(updated); + expect(User.updateDepartment).toHaveBeenCalledWith(5, 'Radiology'); + }); + + it('should reject assignment to system department', async () => { + // Arrange + const systemDept = { id: 1, name: 'Internal', is_system: true }; + + Department.findById.mockResolvedValue(systemDept); + + // Act & Assert + await expect(departmentService.assignUserToDepartment(1, 1, 5, '127.0.0.1')).rejects.toThrow( + 'Cannot assign users to system department' + ); + + expect(User.findById).not.toHaveBeenCalled(); + }); + + it('should reject when user not found', async () => { + // Arrange + const dept = { id: 1, name: 'Pharmacy', is_system: false }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(null); + + // Act & Assert + await expect( + departmentService.assignUserToDepartment(1, 1, 999, '127.0.0.1') + ).rejects.toThrow('User not found'); + + expect(User.updateDepartment).not.toHaveBeenCalled(); + }); + + it('should reject non-department role users', async () => { + // Arrange + const dept = { id: 1, name: 'Laboratory', is_system: false }; + const adminUser = { id: 5, username: 'admin', role: 'admin' }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(adminUser); + + // Act & Assert + await expect(departmentService.assignUserToDepartment(1, 1, 5, '127.0.0.1')).rejects.toThrow( + 'Can only assign department users' + ); + + expect(User.updateDepartment).not.toHaveBeenCalled(); + }); + + it('should create audit log with old department', async () => { + // Arrange + const actorId = 2; + const dept = { id: 3, name: 'Surgery', is_system: false }; + const user = { id: 10, username: 'nurse.jane', role: 'department', department: 'Cardiology' }; + const updated = { ...user, department: 'Surgery' }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(user); + User.updateDepartment.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.assignUserToDepartment(actorId, 3, 10, '10.0.0.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 2, + action: 'ASSIGN_USER_TO_DEPARTMENT', + targetType: 'user', + targetId: 10, + details: { + userId: 10, + username: 'nurse.jane', + departmentId: 3, + departmentName: 'Surgery', + oldDepartment: 'Cardiology', + }, + ipAddress: '10.0.0.1', + }); + }); + }); + + describe('removeUserFromDepartment', () => { + it('should remove user when no active tickets', async () => { + // Arrange + const dept = { id: 1, name: 'Emergency Department' }; + const user = { id: 5, username: 'deptuser', department: 'Emergency Department' }; + const updated = { ...user, department: null }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(user); + User.countActiveTickets.mockResolvedValue(0); + User.updateDepartment.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + const result = await departmentService.removeUserFromDepartment(1, 1, 5, '127.0.0.1'); + + // Assert + expect(result).toEqual(updated); + expect(User.updateDepartment).toHaveBeenCalledWith(5, null); + }); + + it('should reject removal when user has active tickets', async () => { + // Arrange + const dept = { id: 1, name: 'Cardiology' }; + const user = { id: 5, username: 'deptuser', department: 'Cardiology' }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(user); + User.countActiveTickets.mockResolvedValue(3); // 3 active tickets + + // Act & Assert + await expect( + departmentService.removeUserFromDepartment(1, 1, 5, '127.0.0.1') + ).rejects.toThrow( + 'Cannot remove user: 3 active ticket(s). Please close or reassign tickets first.' + ); + + expect(User.updateDepartment).not.toHaveBeenCalled(); + }); + + it('should reject when user not found', async () => { + // Arrange + const dept = { id: 1, name: 'Radiology' }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(null); + + // Act & Assert + await expect( + departmentService.removeUserFromDepartment(1, 1, 999, '127.0.0.1') + ).rejects.toThrow('User not found'); + }); + + it('should create audit log', async () => { + // Arrange + const actorId = 3; + const dept = { id: 4, name: 'Pharmacy' }; + const user = { id: 8, username: 'pharmacist.tom', department: 'Pharmacy' }; + const updated = { ...user, department: null }; + + Department.findById.mockResolvedValue(dept); + User.findById.mockResolvedValue(user); + User.countActiveTickets.mockResolvedValue(0); + User.updateDepartment.mockResolvedValue(updated); + AuditLog.create.mockResolvedValue({}); + + // Act + await departmentService.removeUserFromDepartment(actorId, 4, 8, '192.168.1.1'); + + // Assert + expect(AuditLog.create).toHaveBeenCalledWith({ + actorId: 3, + action: 'REMOVE_USER_FROM_DEPARTMENT', + targetType: 'user', + targetId: 8, + details: { + userId: 8, + username: 'pharmacist.tom', + departmentId: 4, + departmentName: 'Pharmacy', + }, + ipAddress: '192.168.1.1', + }); + }); + }); +}); diff --git a/tests/unit/services/errorReportingService.test.js b/tests/unit/services/errorReportingService.test.js new file mode 100644 index 0000000..86d45c6 --- /dev/null +++ b/tests/unit/services/errorReportingService.test.js @@ -0,0 +1,399 @@ +/** + * Error Reporting Service Unit Tests + * + * Tests the Error Reporting Service in isolation. + * Since this is a singleton service using in-memory Map storage, + * we clear the Map between tests for isolation. + */ + +const errorReportingService = require('../../../services/errorReportingService'); +const logger = require('../../../utils/logger'); + +// Mock dependencies +jest.mock('../../../utils/logger'); + +describe('Error Reporting Service', () => { + beforeEach(() => { + // Clear the in-memory reports Map before each test + errorReportingService.reports.clear(); + jest.clearAllMocks(); + }); + + describe('reportError', () => { + it('should create error report with full context', async () => { + // Arrange + const correlationId = 'test-error-123'; + const category = 'JAVASCRIPT_ERROR'; + const userContext = { + userId: 42, + userAgent: 'Mozilla/5.0', + url: '/tickets/123', + ip: '192.168.1.1', + }; + const userDescription = 'The form submission failed unexpectedly'; + const additionalData = { stackTrace: 'Error at line 123' }; + + // Act + const result = await errorReportingService.reportError( + correlationId, + category, + userContext, + userDescription, + additionalData + ); + + // Assert + expect(result.success).toBe(true); + expect(result.reportId).toBe(correlationId); + expect(result.message).toBe('Error report submitted successfully'); + + const storedReport = errorReportingService.getReport(correlationId); + expect(storedReport).toBeDefined(); + expect(storedReport.category).toBe(category); + expect(storedReport.userContext.userId).toBe(42); + expect(storedReport.userDescription).toBe(userDescription); + expect(storedReport.additionalData).toEqual(additionalData); + expect(storedReport.status).toBe('reported'); + }); + + it('should create error report with minimal data (anonymous user)', async () => { + // Arrange + const correlationId = 'anon-error-456'; + const category = 'NETWORK_ERROR'; + + // Act + const result = await errorReportingService.reportError(correlationId, category); + + // Assert + expect(result.success).toBe(true); + + const storedReport = errorReportingService.getReport(correlationId); + expect(storedReport.userContext.userId).toBe('anonymous'); + expect(storedReport.userContext.userAgent).toBe('unknown'); + expect(storedReport.userContext.url).toBe('unknown'); + expect(storedReport.userContext.ip).toBe('unknown'); + expect(storedReport.userDescription).toBe(''); + }); + + it('should trim user description whitespace', async () => { + // Arrange + const correlationId = 'trim-test-789'; + const category = 'UI_ERROR'; + const descriptionWithSpaces = ' Description with leading and trailing spaces '; + + // Act + await errorReportingService.reportError(correlationId, category, {}, descriptionWithSpaces); + + // Assert + const storedReport = errorReportingService.getReport(correlationId); + expect(storedReport.userDescription).toBe('Description with leading and trailing spaces'); + }); + + it('should include timestamp in ISO format', async () => { + // Arrange + const correlationId = 'timestamp-test'; + const category = 'TEST_ERROR'; + + // Act + await errorReportingService.reportError(correlationId, category); + + // Assert + const storedReport = errorReportingService.getReport(correlationId); + expect(storedReport.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('should log error report submission', async () => { + // Arrange + const correlationId = 'log-test'; + const category = 'TEST_ERROR'; + const userContext = { userId: 10 }; + const userDescription = 'Test description'; + + // Act + await errorReportingService.reportError( + correlationId, + category, + userContext, + userDescription + ); + + // Assert + expect(logger.info).toHaveBeenCalledWith( + 'Error report submitted', + expect.objectContaining({ + correlationId, + category, + userId: 10, + hasDescription: true, + }) + ); + }); + + it('should return failure on internal error', async () => { + // Arrange + const correlationId = null; // Will cause error in Map.set + const category = 'TEST_ERROR'; + + // Mock Map.set to throw error + const originalSet = errorReportingService.reports.set; + errorReportingService.reports.set = jest.fn(() => { + throw new Error('Map error'); + }); + + // Act + const result = await errorReportingService.reportError(correlationId, category); + + // Assert + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to submit error report'); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to process error report', + expect.objectContaining({ + correlationId, + error: 'Map error', + }) + ); + + // Restore original method + errorReportingService.reports.set = originalSet; + }); + }); + + describe('getReport', () => { + it('should return error report when found', async () => { + // Arrange + const correlationId = 'existing-report'; + await errorReportingService.reportError(correlationId, 'TEST'); + + // Act + const result = errorReportingService.getReport(correlationId); + + // Assert + expect(result).toBeDefined(); + expect(result.correlationId).toBe(correlationId); + expect(result.category).toBe('TEST'); + }); + + it('should return null when report not found', () => { + // Act + const result = errorReportingService.getReport('non-existent-id'); + + // Assert + expect(result).toBeNull(); + }); + + it('should return complete report structure', async () => { + // Arrange + const correlationId = 'structure-test'; + await errorReportingService.reportError(correlationId, 'TEST', { userId: 5 }, 'Description'); + + // Act + const result = errorReportingService.getReport(correlationId); + + // Assert + expect(result).toHaveProperty('correlationId'); + expect(result).toHaveProperty('category'); + expect(result).toHaveProperty('timestamp'); + expect(result).toHaveProperty('userContext'); + expect(result).toHaveProperty('userDescription'); + expect(result).toHaveProperty('additionalData'); + expect(result).toHaveProperty('status'); + }); + }); + + describe('getAllReports', () => { + it('should return empty array when no reports exist', () => { + // Act + const result = errorReportingService.getAllReports(); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all stored reports as array', async () => { + // Arrange + await errorReportingService.reportError('error-1', 'CAT_A'); + await errorReportingService.reportError('error-2', 'CAT_B'); + await errorReportingService.reportError('error-3', 'CAT_C'); + + // Act + const result = errorReportingService.getAllReports(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result.map((r) => r.correlationId)).toEqual( + expect.arrayContaining(['error-1', 'error-2', 'error-3']) + ); + }); + + it('should return array of complete report objects', async () => { + // Arrange + await errorReportingService.reportError('test-report', 'TEST', { userId: 1 }, 'Description'); + + // Act + const result = errorReportingService.getAllReports(); + + // Assert + expect(result[0]).toHaveProperty('correlationId'); + expect(result[0]).toHaveProperty('category'); + expect(result[0]).toHaveProperty('userContext'); + expect(result[0]).toHaveProperty('status'); + }); + }); + + describe('getErrorStats', () => { + it('should return empty stats when no reports exist', () => { + // Act + const stats = errorReportingService.getErrorStats(); + + // Assert + expect(stats).toEqual({ + total: 0, + byCategory: {}, + byDate: {}, + unresolved: 0, + }); + }); + + it('should aggregate reports by category', async () => { + // Arrange + await errorReportingService.reportError('e1', 'JAVASCRIPT_ERROR'); + await errorReportingService.reportError('e2', 'JAVASCRIPT_ERROR'); + await errorReportingService.reportError('e3', 'NETWORK_ERROR'); + + // Act + const stats = errorReportingService.getErrorStats(); + + // Assert + expect(stats.total).toBe(3); + expect(stats.byCategory['JAVASCRIPT_ERROR']).toBe(2); + expect(stats.byCategory['NETWORK_ERROR']).toBe(1); + }); + + it('should aggregate reports by date', async () => { + // Arrange + await errorReportingService.reportError('e1', 'TEST'); + await errorReportingService.reportError('e2', 'TEST'); + + // Act + const stats = errorReportingService.getErrorStats(); + + // Assert + const today = new Date().toISOString().split('T')[0]; + expect(stats.byDate[today]).toBe(2); + }); + + it('should count unresolved reports', async () => { + // Arrange + await errorReportingService.reportError('e1', 'TEST'); + await errorReportingService.reportError('e2', 'TEST'); + await errorReportingService.reportError('e3', 'TEST'); + errorReportingService.resolveReport('e1', 'Fixed'); + + // Act + const stats = errorReportingService.getErrorStats(); + + // Assert + expect(stats.total).toBe(3); + expect(stats.unresolved).toBe(2); // e2 and e3 are still unresolved + }); + + it('should handle resolved reports correctly in stats', async () => { + // Arrange + await errorReportingService.reportError('e1', 'CAT_A'); + await errorReportingService.reportError('e2', 'CAT_B'); + errorReportingService.resolveReport('e1', 'Resolved'); + + // Act + const stats = errorReportingService.getErrorStats(); + + // Assert + expect(stats.total).toBe(2); + expect(stats.byCategory['CAT_A']).toBe(1); + expect(stats.byCategory['CAT_B']).toBe(1); + expect(stats.unresolved).toBe(1); + }); + }); + + describe('resolveReport', () => { + it('should mark report as resolved successfully', async () => { + // Arrange + const correlationId = 'resolve-test'; + await errorReportingService.reportError(correlationId, 'TEST'); + + // Act + const result = errorReportingService.resolveReport(correlationId, 'Issue was fixed in v2.0'); + + // Assert + expect(result).toBe(true); + + const report = errorReportingService.getReport(correlationId); + expect(report.status).toBe('resolved'); + expect(report.resolution).toBe('Issue was fixed in v2.0'); + expect(report.resolvedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('should mark report as resolved with empty resolution', async () => { + // Arrange + const correlationId = 'no-resolution'; + await errorReportingService.reportError(correlationId, 'TEST'); + + // Act + const result = errorReportingService.resolveReport(correlationId); + + // Assert + expect(result).toBe(true); + + const report = errorReportingService.getReport(correlationId); + expect(report.status).toBe('resolved'); + expect(report.resolution).toBe(''); + }); + + it('should return false when report not found', () => { + // Act + const result = errorReportingService.resolveReport('non-existent-id', 'Fix'); + + // Assert + expect(result).toBe(false); + }); + + it('should log resolution', async () => { + // Arrange + const correlationId = 'log-resolution'; + await errorReportingService.reportError(correlationId, 'TEST'); + jest.clearAllMocks(); // Clear reportError logs + + // Act + errorReportingService.resolveReport(correlationId, 'Fixed the issue'); + + // Assert + expect(logger.info).toHaveBeenCalledWith( + 'Error report resolved', + expect.objectContaining({ + correlationId, + resolution: 'Fixed the issue', + }) + ); + }); + + it('should truncate long resolutions in log (first 100 chars)', async () => { + // Arrange + const correlationId = 'long-resolution'; + await errorReportingService.reportError(correlationId, 'TEST'); + const longResolution = 'a'.repeat(200); + jest.clearAllMocks(); + + // Act + errorReportingService.resolveReport(correlationId, longResolution); + + // Assert + expect(logger.info).toHaveBeenCalledWith( + 'Error report resolved', + expect.objectContaining({ + resolution: 'a'.repeat(100), + }) + ); + }); + }); +}); diff --git a/tests/unit/validators/adminTicketValidators.test.js b/tests/unit/validators/adminTicketValidators.test.js new file mode 100644 index 0000000..9f89b19 --- /dev/null +++ b/tests/unit/validators/adminTicketValidators.test.js @@ -0,0 +1,405 @@ +/** + * Admin Ticket Validators Unit Tests + * + * Tests admin ticket validation middleware. + * Covers both admin ticket creation (all depts) and department ticket creation (excludes Internal). + * Mocks Department model for async custom validators. + */ + +const { validationResult } = require('express-validator'); +const { + validateAdminTicketCreation, + validateDepartmentTicketCreation, +} = require('../../../validators/adminTicketValidators'); +const Department = require('../../../models/Department'); +const { createMockRequest } = require('../../helpers/mocks'); +const { MAX_LENGTHS } = require('../../../constants/validation'); + +// Mock Department model +jest.mock('../../../models/Department'); + +/** + * Helper function to run validators and collect errors + */ +async function runValidators(validators, req) { + for (const validator of validators) { + await validator.run(req); + } + return validationResult(req); +} + +describe('Admin Ticket Validators', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateAdminTicketCreation', () => { + beforeEach(() => { + // Mock all departments INCLUDING 'Internal' (system dept) + Department.findAll.mockResolvedValue([ + { id: 1, name: 'Emergency Department', is_system: false }, + { id: 2, name: 'Cardiology', is_system: false }, + { id: 3, name: 'Internal', is_system: true }, + ]); + }); + + it('should pass validation for valid admin ticket data', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Internal system ticket', + description: 'Admin-only ticket description', + reporter_department: 'Internal', + priority: 'medium', + status: 'open', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + expect(Department.findAll).toHaveBeenCalledWith(true); // includeSystem=true + }); + + it('should allow Internal department (system dept)', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + reporter_department: 'Internal', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation without priority (optional)', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Test Ticket', + description: 'Description', + reporter_department: 'Emergency Department', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation without status (optional)', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Test Ticket', + description: 'Description', + reporter_department: 'Cardiology', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should fail when title is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + description: 'Description', + reporter_department: 'Internal', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('should fail when title exceeds MAX_LENGTHS.TICKET_TITLE', async () => { + // Arrange + const longTitle = 'A'.repeat(MAX_LENGTHS.TICKET_TITLE + 1); + const req = createMockRequest({ + body: { + title: longTitle, + description: 'Description', + reporter_department: 'Internal', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('should fail when description is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + reporter_department: 'Internal', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when description exceeds MAX_LENGTHS.TICKET_DESCRIPTION', async () => { + // Arrange + const longDesc = 'A'.repeat(MAX_LENGTHS.TICKET_DESCRIPTION + 1); + const req = createMockRequest({ + body: { + title: 'Title', + description: longDesc, + reporter_department: 'Internal', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when reporter_department is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'reporter_department')).toBe(true); + }); + + it('should fail when reporter_department is invalid', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_department: 'NonExistent Department', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'reporter_department')).toBe(true); + }); + + it('should fail when priority is invalid', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_department: 'Internal', + priority: 'urgent', // Invalid priority + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'priority')).toBe(true); + }); + + it('should fail when status is invalid', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_department: 'Internal', + status: 'invalid_status', + }, + }); + + // Act + const result = await runValidators(validateAdminTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'status')).toBe(true); + }); + }); + + describe('validateDepartmentTicketCreation', () => { + beforeEach(() => { + // Mock all departments EXCLUDING 'Internal' (system dept) + Department.findAll.mockResolvedValue([ + { id: 1, name: 'Emergency Department', is_system: false }, + { id: 2, name: 'Cardiology', is_system: false }, + { id: 3, name: 'Radiology', is_system: false }, + ]); + }); + + it('should pass validation for valid department ticket data', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Department ticket', + description: 'Ticket on behalf of department', + reporter_name: 'Dr. Smith', + reporter_department: 'Emergency Department', + priority: 'high', + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + expect(Department.findAll).toHaveBeenCalledWith(false); // includeSystem=false + }); + + it('should reject Internal department', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + reporter_name: 'Admin', + reporter_department: 'Internal', // System dept not allowed + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect( + errors.some( + (e) => + e.path === 'reporter_department' && + e.msg.includes('Cannot create department tickets for Internal') + ) + ).toBe(true); + }); + + it('should fail when reporter_name is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_department: 'Cardiology', + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'reporter_name')).toBe(true); + }); + + it('should fail when reporter_name exceeds MAX_LENGTHS.NAME', async () => { + // Arrange + const longName = 'A'.repeat(MAX_LENGTHS.NAME + 1); + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_name: longName, + reporter_department: 'Radiology', + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'reporter_name')).toBe(true); + }); + + it('should pass validation without priority (optional)', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Cardiology', + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should not have status field (always open)', async () => { + // Arrange - Status field should be ignored/not validated + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + reporter_name: 'Contact', + reporter_department: 'Emergency Department', + status: 'closed', // This field is not validated (will be ignored by service) + }, + }); + + // Act + const result = await runValidators(validateDepartmentTicketCreation, req); + + // Assert + // Validation should pass - status field is simply not validated + expect(result.isEmpty()).toBe(true); + }); + }); +}); diff --git a/tests/unit/validators/clientValidators.test.js b/tests/unit/validators/clientValidators.test.js new file mode 100644 index 0000000..892bdbb --- /dev/null +++ b/tests/unit/validators/clientValidators.test.js @@ -0,0 +1,475 @@ +/** + * Client Validators Unit Tests + * + * Tests client portal validation middleware for department users. + * Validates ticket creation, status updates, and comment creation. + * Tests field restrictions (no department/priority selection for dept users). + */ + +const { validationResult } = require('express-validator'); +const { + validateClientTicketCreation, + validateClientStatusUpdate, + validateClientCommentCreation, +} = require('../../../validators/clientValidators'); +const { createMockRequest } = require('../../helpers/mocks'); +const { MAX_LENGTHS } = require('../../../constants/validation'); + +/** + * Helper function to run validators and collect errors + */ +async function runValidators(validators, req) { + for (const validator of validators) { + await validator.run(req); + } + return validationResult(req); +} + +describe('Client Validators', () => { + describe('validateClientTicketCreation', () => { + it('should pass validation for valid minimal ticket data', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Ticket title', + description: 'Ticket description', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with phone number', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Ticket with phone', + description: 'Description', + reporter_phone: '+1234567890', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation without phone (optional)', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Ticket without phone', + description: 'Description', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should not validate department field (auto-populated)', async () => { + // Arrange - Department field not in validator (auto-populated from user) + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + reporter_department: 'Emergency Department', // This field is ignored + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should not validate priority field (forced to unset)', async () => { + // Arrange - Priority field not in validator (forced by service) + const req = createMockRequest({ + body: { + title: 'Test', + description: 'Test', + priority: 'critical', // This field is ignored + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should fail when title is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + description: 'Description only', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('should fail when title is empty string', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: '', + description: 'Description', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('should fail when title exceeds MAX_LENGTHS.TICKET_TITLE', async () => { + // Arrange + const longTitle = 'A'.repeat(MAX_LENGTHS.TICKET_TITLE + 1); + const req = createMockRequest({ + body: { + title: longTitle, + description: 'Description', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('should fail when description is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title only', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when description is empty string', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: 'Title', + description: '', + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when description exceeds MAX_LENGTHS.TICKET_DESCRIPTION', async () => { + // Arrange + const longDesc = 'A'.repeat(MAX_LENGTHS.TICKET_DESCRIPTION + 1); + const req = createMockRequest({ + body: { + title: 'Title', + description: longDesc, + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when phone exceeds MAX_LENGTHS.PHONE_NUMBER', async () => { + // Arrange + const longPhone = '1'.repeat(MAX_LENGTHS.PHONE_NUMBER + 1); + const req = createMockRequest({ + body: { + title: 'Title', + description: 'Description', + reporter_phone: longPhone, + }, + }); + + // Act + const result = await runValidators(validateClientTicketCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'reporter_phone')).toBe(true); + }); + + it('should trim whitespace from title and description', async () => { + // Arrange + const req = createMockRequest({ + body: { + title: ' Test Title ', + description: ' Test Description ', + }, + }); + + // Act + await runValidators(validateClientTicketCreation, req); + + // Assert + expect(req.body.title).toBe('Test Title'); + expect(req.body.description).toBe('Test Description'); + }); + }); + + describe('validateClientStatusUpdate', () => { + it('should allow waiting_on_admin status', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'waiting_on_admin', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should allow closed status', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'closed', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should reject open status (admin-only)', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'open', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect( + errors.some((e) => e.path === 'status' && e.msg.includes('waiting_on_admin, closed')) + ).toBe(true); + }); + + it('should reject in_progress status (admin-only)', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'in_progress', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'status')).toBe(true); + }); + + it('should reject waiting_on_department status (admin-only)', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'waiting_on_department', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'status')).toBe(true); + }); + + it('should fail when status is missing', async () => { + // Arrange + const req = createMockRequest({ + body: {}, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'status' && e.msg === 'Status is required')).toBe(true); + }); + + it('should fail when status is invalid', async () => { + // Arrange + const req = createMockRequest({ + body: { + status: 'invalid_status', + }, + }); + + // Act + const result = await runValidators(validateClientStatusUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'status')).toBe(true); + }); + }); + + describe('validateClientCommentCreation', () => { + it('should pass validation for valid comment', async () => { + // Arrange + const req = createMockRequest({ + body: { + content: 'This is a valid comment', + }, + }); + + // Act + const result = await runValidators(validateClientCommentCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should not validate visibility_type field (forced to public)', async () => { + // Arrange - visibility_type not in validator (forced by service) + const req = createMockRequest({ + body: { + content: 'Comment', + visibility_type: 'internal', // This field is ignored + }, + }); + + // Act + const result = await runValidators(validateClientCommentCreation, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should fail when content is missing', async () => { + // Arrange + const req = createMockRequest({ + body: {}, + }); + + // Act + const result = await runValidators(validateClientCommentCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'content' && e.msg === 'Comment cannot be empty')).toBe( + true + ); + }); + + it('should fail when content is empty string', async () => { + // Arrange + const req = createMockRequest({ + body: { + content: '', + }, + }); + + // Act + const result = await runValidators(validateClientCommentCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'content')).toBe(true); + }); + + it('should fail when content exceeds MAX_LENGTHS.COMMENT_CONTENT', async () => { + // Arrange + const longContent = 'A'.repeat(MAX_LENGTHS.COMMENT_CONTENT + 1); + const req = createMockRequest({ + body: { + content: longContent, + }, + }); + + // Act + const result = await runValidators(validateClientCommentCreation, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'content')).toBe(true); + }); + + it('should trim whitespace from content', async () => { + // Arrange + const req = createMockRequest({ + body: { + content: ' Comment content with spaces ', + }, + }); + + // Act + await runValidators(validateClientCommentCreation, req); + + // Assert + expect(req.body.content).toBe('Comment content with spaces'); + }); + }); +}); diff --git a/tests/unit/validators/departmentValidators.test.js b/tests/unit/validators/departmentValidators.test.js new file mode 100644 index 0000000..3c4e961 --- /dev/null +++ b/tests/unit/validators/departmentValidators.test.js @@ -0,0 +1,591 @@ +/** + * Department Validators Unit Tests + * + * Tests the department validation middleware using express-validator. + * Covers all 4 validator arrays with valid and invalid inputs. + */ + +const { validationResult } = require('express-validator'); +const { + validateDepartmentCreate, + validateDepartmentUpdate, + validateDepartmentId, + validateUserAssignment, +} = require('../../../validators/departmentValidators'); +const { createMockRequest } = require('../../helpers/mocks'); +const { MAX_LENGTHS } = require('../../../constants/validation'); +const Floor = require('../../../models/Floor'); + +jest.mock('../../../models/Floor'); + +/** + * Helper function to run validators and collect errors + */ +async function runValidators(validators, req) { + for (const validator of validators) { + await validator.run(req); + } + return validationResult(req); +} + +describe('Department Validators', () => { + beforeEach(() => { + Floor.findAll.mockResolvedValue([{ id: 1, name: 'Ground Floor' }]); + }); + + describe('validateDepartmentCreate', () => { + it('should pass validation for valid department data', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'Emergency Department', + description: 'Emergency and urgent care services', + floor: 'Ground Floor', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with minimum name length (2 chars)', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'ED', // 2 characters + description: 'Emergency Department', + floor: 'Ground Floor', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation without description (optional)', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'Cardiology', + floor: 'Ground Floor', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with empty description (nullable)', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'Radiology', + description: '', + floor: 'Ground Floor', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should trim whitespace from name', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: ' Surgery ', + description: 'Surgical services', + }, + }); + + // Act + await runValidators(validateDepartmentCreate, req); + + // Assert + expect(req.body.name).toBe('Surgery'); + }); + + it('should trim whitespace from description', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'Pharmacy', + description: ' Medication management ', + }, + }); + + // Act + await runValidators(validateDepartmentCreate, req); + + // Assert + expect(req.body.description).toBe('Medication management'); + }); + + it('should fail when name is missing', async () => { + // Arrange + const req = createMockRequest({ + body: { + description: 'Test description', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name' && e.msg === 'Department name is required')).toBe( + true + ); + }); + + it('should fail when name is empty string', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: '', + description: 'Test', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('should fail when name is too short (< 2 chars)', async () => { + // Arrange + const req = createMockRequest({ + body: { + name: 'A', // Only 1 character + description: 'Test', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name' && e.msg.includes('2-'))).toBe(true); + }); + + it('should fail when name exceeds MAX_LENGTHS.DEPARTMENT', async () => { + // Arrange + const longName = 'A'.repeat(MAX_LENGTHS.DEPARTMENT + 1); + const req = createMockRequest({ + body: { + name: longName, + description: 'Test', + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('should fail when description exceeds 500 characters', async () => { + // Arrange + const longDescription = 'A'.repeat(501); + const req = createMockRequest({ + body: { + name: 'Test Department', + description: longDescription, + }, + }); + + // Act + const result = await runValidators(validateDepartmentCreate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description' && e.msg.includes('500'))).toBe(true); + }); + }); + + describe('validateDepartmentUpdate', () => { + it('should pass validation for valid update data', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '1' }, + body: { + name: 'Updated Department', + description: 'Updated description', + active: true, + }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with only name update', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '5' }, + body: { + name: 'New Name Only', + }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with only description update', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '3' }, + body: { + description: 'New description only', + }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with only active status update', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '2' }, + body: { + active: false, + }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should pass validation with empty body (all fields optional)', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '1' }, + body: {}, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should fail when ID is invalid (not a number)', async () => { + // Arrange + const req = createMockRequest({ + params: { id: 'abc' }, + body: { name: 'Test' }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id' && e.msg === 'Invalid department ID')).toBe(true); + }); + + it('should fail when ID is zero', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '0' }, + body: { name: 'Test' }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id')).toBe(true); + }); + + it('should fail when ID is negative', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '-5' }, + body: { name: 'Test' }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id')).toBe(true); + }); + + it('should fail when name is too short', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '1' }, + body: { name: 'A' }, // 1 character + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('should fail when name is too long', async () => { + // Arrange + const longName = 'A'.repeat(MAX_LENGTHS.DEPARTMENT + 1); + const req = createMockRequest({ + params: { id: '1' }, + body: { name: longName }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('should fail when description exceeds 500 characters', async () => { + // Arrange + const longDescription = 'A'.repeat(501); + const req = createMockRequest({ + params: { id: '1' }, + body: { description: longDescription }, + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('should fail when active is not boolean', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '1' }, + body: { active: 'yes' }, // String instead of boolean + }); + + // Act + const result = await runValidators(validateDepartmentUpdate, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'active' && e.msg === 'Active must be boolean')).toBe( + true + ); + }); + }); + + describe('validateDepartmentId', () => { + it('should pass validation for valid positive integer ID', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '42' }, + }); + + // Act + const result = await runValidators(validateDepartmentId, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should fail when ID is not a number', async () => { + // Arrange + const req = createMockRequest({ + params: { id: 'notanumber' }, + }); + + // Act + const result = await runValidators(validateDepartmentId, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id' && e.msg === 'Invalid department ID')).toBe(true); + }); + + it('should fail when ID is zero', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '0' }, + }); + + // Act + const result = await runValidators(validateDepartmentId, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id')).toBe(true); + }); + + it('should fail when ID is negative', async () => { + // Arrange + const req = createMockRequest({ + params: { id: '-10' }, + }); + + // Act + const result = await runValidators(validateDepartmentId, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'id')).toBe(true); + }); + }); + + describe('validateUserAssignment', () => { + it('should pass validation for valid user_id', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: '25' }, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(true); + }); + + it('should convert user_id to integer', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: '10' }, + }); + + // Act + await runValidators(validateUserAssignment, req); + + // Assert + expect(req.body.user_id).toBe(10); // Converted to number + expect(typeof req.body.user_id).toBe('number'); + }); + + it('should fail when user_id is missing', async () => { + // Arrange + const req = createMockRequest({ + body: {}, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'user_id' && e.msg === 'User is required')).toBe(true); + }); + + it('should fail when user_id is empty string', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: '' }, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'user_id')).toBe(true); + }); + + it('should fail when user_id is not a number', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: 'abc' }, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'user_id' && e.msg === 'Invalid user ID')).toBe(true); + }); + + it('should fail when user_id is zero', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: '0' }, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'user_id')).toBe(true); + }); + + it('should fail when user_id is negative', async () => { + // Arrange + const req = createMockRequest({ + body: { user_id: '-5' }, + }); + + // Act + const result = await runValidators(validateUserAssignment, req); + + // Assert + expect(result.isEmpty()).toBe(false); + const errors = result.array(); + expect(errors.some((e) => e.path === 'user_id')).toBe(true); + }); + }); +});