From 2a0feb504ac59eacf4c03e782c7681491bdd8c6b Mon Sep 17 00:00:00 2001 From: Thodorhs Perros Date: Thu, 3 Sep 2026 16:18:28 +0300 Subject: [PATCH] test: add unit coverage for admin/client ticket, department, and error-reporting services Salvages test files from the long-stale test/phase-1-5-comprehensive-testing branch (diverged since Jan 2026) for modules that exist in main today with zero test coverage: adminTicketService, clientTicketService, departmentService, errorReportingService, and their validators (adminTicketValidators, clientValidators, departmentValidators). Ported as standalone files rather than merging the branch (which conflicts on 19 files including package.json and core test infra). Adapted to signature drift since these were written: clientTicketService.getDepartmentTickets moved from (userId, filters) to (userId, department, filters) in v2.2.0, mutation methods gained an auditContext param, and departments now require a floor field. +189 tests, 1004 -> 1193 passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011tWtJbqWmefyKJUjXcEmfo --- .../unit/services/adminTicketService.test.js | 484 +++++++++++ .../unit/services/clientTicketService.test.js | 522 ++++++++++++ tests/unit/services/departmentService.test.js | 785 ++++++++++++++++++ .../services/errorReportingService.test.js | 399 +++++++++ .../validators/adminTicketValidators.test.js | 405 +++++++++ .../unit/validators/clientValidators.test.js | 475 +++++++++++ .../validators/departmentValidators.test.js | 591 +++++++++++++ 7 files changed, 3661 insertions(+) create mode 100644 tests/unit/services/adminTicketService.test.js create mode 100644 tests/unit/services/clientTicketService.test.js create mode 100644 tests/unit/services/departmentService.test.js create mode 100644 tests/unit/services/errorReportingService.test.js create mode 100644 tests/unit/validators/adminTicketValidators.test.js create mode 100644 tests/unit/validators/clientValidators.test.js create mode 100644 tests/unit/validators/departmentValidators.test.js 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); + }); + }); +});