diff --git a/src/modules/projects/__tests__/projects-flow.integration.test.ts b/src/modules/projects/__tests__/projects-flow.integration.test.ts new file mode 100644 index 0000000..ff9099f --- /dev/null +++ b/src/modules/projects/__tests__/projects-flow.integration.test.ts @@ -0,0 +1,725 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + createMockProject, + createMockTask, + createMockTimesheet, +} from '../../../__tests__/helpers.js'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); +const mockGetClient = jest.fn(); +const mockClient = { + query: jest.fn(), + release: jest.fn(), +}; + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), + getClient: () => mockGetClient(), +})); + +// Import after mocking +import { projectsService } from '../projects.service.js'; +import { tasksService } from '../tasks.service.js'; +import { timesheetsService } from '../timesheets.service.js'; +import { billingService } from '../billing.service.js'; +import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; + +describe('Projects Module Integration Tests', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + const companyId = 'test-company-uuid'; + const managerId = 'manager-uuid'; + const partnerId = 'partner-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetClient.mockResolvedValue(mockClient); + mockClient.query.mockReset(); + mockClient.release.mockReset(); + }); + + describe('Flow 1: Project Creation -> Tasks -> Resource Assignment', () => { + it('should create a complete project workflow', async () => { + // Step 1: Create project + const projectData = { + company_id: companyId, + name: 'Website Redesign', + code: 'WEB-001', + description: 'Complete website redesign project', + manager_id: managerId, + partner_id: partnerId, + date_start: '2026-01-01', + date_end: '2026-06-30', + allow_timesheets: true, + }; + + const createdProject = createMockProject({ + ...projectData, + id: 'new-project-uuid', + status: 'draft', + }); + + mockQueryOne + .mockResolvedValueOnce(null) // code uniqueness check + .mockResolvedValueOnce(createdProject); // INSERT + + const project = await projectsService.create(projectData, tenantId, userId); + + expect(project.id).toBe('new-project-uuid'); + expect(project.name).toBe('Website Redesign'); + + // Step 2: Update project status to active + mockQueryOne.mockResolvedValue({ ...createdProject, status: 'active' }); + mockQuery.mockResolvedValue([]); + + await projectsService.update('new-project-uuid', { status: 'active' }, tenantId, userId); + + // Step 3: Create tasks for the project + const task1Data = { + project_id: 'new-project-uuid', + name: 'Design Phase', + description: 'Create mockups and wireframes', + estimated_hours: 40, + priority: 'high' as const, + }; + + const createdTask1 = createMockTask({ + ...task1Data, + id: 'task-1-uuid', + sequence: 1, + }); + + mockQueryOne + .mockResolvedValueOnce({ max_seq: 1 }) // sequence + .mockResolvedValueOnce(createdTask1); // INSERT + + const task1 = await tasksService.create(task1Data, tenantId, userId); + expect(task1.name).toBe('Design Phase'); + expect(task1.estimated_hours).toBe(40); + + // Step 4: Assign resource to task + const assignedTask = createMockTask({ + ...createdTask1, + assigned_to: 'designer-uuid', + assigned_name: 'John Designer', + }); + + mockQueryOne.mockResolvedValue(createdTask1); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValue(assignedTask); + + const taskWithAssignment = await tasksService.assign('task-1-uuid', 'designer-uuid', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('assigned_to = $1'), + expect.arrayContaining(['designer-uuid']) + ); + }); + + it('should prevent creating tasks for non-existent projects', async () => { + const taskData = { + project_id: 'nonexistent-project', + name: 'Orphan Task', + }; + + // Simulate foreign key constraint - project query returns null + mockQueryOne.mockResolvedValue({ max_seq: 1 }); + mockQueryOne.mockRejectedValue(new Error('foreign key constraint violation')); + + await expect( + tasksService.create(taskData, tenantId, userId) + ).rejects.toThrow(); + }); + }); + + describe('Flow 2: Task -> Time Registration (Timesheet Entries)', () => { + it('should complete timesheet entry workflow for a task', async () => { + const projectId = 'project-uuid-1'; + const taskId = 'task-uuid-1'; + + // Step 1: Create timesheet entry + const timesheetData = { + company_id: companyId, + project_id: projectId, + task_id: taskId, + date: '2026-01-15', + hours: 4, + description: 'Worked on mockups', + billable: true, + }; + + const createdTimesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: projectId, + task_id: taskId, + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + description: 'Worked on mockups', + billable: true, + status: 'draft' as const, + created_at: new Date(), + }; + + mockQueryOne.mockResolvedValue(createdTimesheet); + + const timesheet = await timesheetsService.create(timesheetData, tenantId, userId); + + expect(timesheet.hours).toBe(4); + expect(timesheet.status).toBe('draft'); + + // Step 2: Submit timesheet for approval + mockQueryOne + .mockResolvedValueOnce(createdTimesheet) // findById + .mockResolvedValueOnce({ ...createdTimesheet, status: 'submitted' }); // after update + mockQuery.mockResolvedValue([]); + + const submittedTimesheet = await timesheetsService.submit('timesheet-uuid-1', tenantId, userId); + + // Step 3: Approve timesheet (by manager) + const submittedState = { ...createdTimesheet, status: 'submitted' as const }; + mockQueryOne + .mockResolvedValueOnce(submittedState) // findById + .mockResolvedValueOnce({ ...submittedState, status: 'approved' }); // after update + mockQuery.mockResolvedValue([]); + + const approvedTimesheet = await timesheetsService.approve('timesheet-uuid-1', tenantId, managerId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'approved'"), + expect.any(Array) + ); + }); + + it('should track accumulated hours per task', async () => { + const taskId = 'task-uuid-1'; + + // Simulate multiple timesheet entries for a task + const timesheets = [ + createMockTimesheet({ id: '1', task_id: taskId, hours: 4 }), + createMockTimesheet({ id: '2', task_id: taskId, hours: 3 }), + createMockTimesheet({ id: '3', task_id: taskId, hours: 2 }), + ]; + + mockQueryOne.mockResolvedValue({ count: '3' }); + mockQuery.mockResolvedValue(timesheets); + + const result = await timesheetsService.findAll(tenantId, { task_id: taskId }); + + const totalHours = result.data.reduce((sum: number, ts: any) => sum + ts.hours, 0); + expect(totalHours).toBe(9); + expect(result.data).toHaveLength(3); + }); + + it('should validate daily hours limit', async () => { + // Try to create timesheet with > 24 hours + const invalidTimesheetData = { + company_id: companyId, + project_id: 'project-uuid', + task_id: 'task-uuid', + date: '2026-01-15', + hours: 25, // Invalid + description: 'Too many hours', + billable: true, + }; + + await expect( + timesheetsService.create(invalidTimesheetData, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Flow 3: Project Progress Calculation', () => { + it('should calculate project progress based on completed tasks', async () => { + const projectId = 'project-uuid-1'; + + const project = createMockProject({ id: projectId }); + const stats = { + total_tasks: 10, + completed_tasks: 6, + in_progress_tasks: 3, + total_hours: 120, + total_milestones: 4, + completed_milestones: 2, + }; + + mockQueryOne + .mockResolvedValueOnce(project) // findById + .mockResolvedValueOnce(stats); // getStats + + const projectStats = await projectsService.getStats(projectId, tenantId); + + expect(projectStats).toMatchObject({ + total_tasks: 10, + completed_tasks: 6, + completion_percentage: 60, // 6/10 * 100 + in_progress_tasks: 3, + total_hours: 120, + }); + }); + + it('should handle project with no tasks (0% progress)', async () => { + const projectId = 'empty-project-uuid'; + + const project = createMockProject({ id: projectId }); + const stats = { + total_tasks: 0, + completed_tasks: 0, + in_progress_tasks: 0, + total_hours: 0, + total_milestones: 0, + completed_milestones: 0, + }; + + mockQueryOne + .mockResolvedValueOnce(project) + .mockResolvedValueOnce(stats); + + const projectStats = await projectsService.getStats(projectId, tenantId); + + expect((projectStats as any).completion_percentage).toBe(0); + }); + + it('should update task status and reflect in progress', async () => { + const taskId = 'task-uuid-1'; + + // Update task to done + const existingTask = createMockTask({ id: taskId, status: 'in_progress' }); + mockQueryOne.mockResolvedValue(existingTask); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValue({ ...existingTask, status: 'done' }); + + await tasksService.update(taskId, { status: 'done' }, tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('status = $'), + expect.arrayContaining(['done']) + ); + }); + }); + + describe('Flow 4: Project Billing (Time & Materials)', () => { + it('should get billing rate for project with priority resolution', async () => { + const projectId = 'project-uuid-1'; + const workerId = 'worker-uuid'; + + // Mock billing rate query - should return most specific rate + const billingRate = { + id: 'rate-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: projectId, + user_id: workerId, + rate_type: 'project_user', + hourly_rate: 150.00, + currency_id: 'currency-mxn', + currency_code: 'MXN', + effective_from: null, + effective_to: null, + active: true, + created_at: new Date(), + }; + + mockQuery.mockResolvedValue([billingRate]); + + const rate = await billingService.getBillingRate( + tenantId, + companyId, + projectId, + workerId, + new Date() + ); + + expect(rate).toBeDefined(); + expect(rate?.hourly_rate).toBe(150.00); + expect(rate?.rate_type).toBe('project_user'); + }); + + it('should calculate unbilled timesheet amounts', async () => { + const projectId = 'project-uuid-1'; + + // Mock unbilled timesheets with billing amounts + const unbilledTimesheets = [ + { + id: 'ts-1', + project_id: projectId, + project_name: 'Test Project', + user_id: 'worker-1', + user_name: 'John Doe', + date: new Date('2026-01-15'), + hours: 8, + description: 'Development', + hourly_rate: 150.00, + billable_amount: 1200.00, + currency_id: 'curr-mxn', + currency_code: 'MXN', + }, + { + id: 'ts-2', + project_id: projectId, + project_name: 'Test Project', + user_id: 'worker-1', + user_name: 'John Doe', + date: new Date('2026-01-16'), + hours: 6, + description: 'Testing', + hourly_rate: 150.00, + billable_amount: 900.00, + currency_id: 'curr-mxn', + currency_code: 'MXN', + }, + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(unbilledTimesheets); + + const result = await billingService.getUnbilledTimesheets(tenantId, companyId, { + project_id: projectId, + }); + + expect(result.data).toHaveLength(2); + const totalBillable = result.data.reduce((sum, ts) => sum + ts.billable_amount, 0); + expect(totalBillable).toBe(2100.00); + }); + + it('should create billing rate with validation', async () => { + const createRateDto = { + company_id: companyId, + project_id: 'project-uuid', + user_id: 'worker-uuid', + hourly_rate: 200.00, + currency_id: 'currency-mxn', + }; + + const createdRate = { + id: 'new-rate-uuid', + tenant_id: tenantId, + ...createRateDto, + rate_type: 'project_user', + active: true, + created_at: new Date(), + }; + + mockQueryOne.mockResolvedValue(createdRate); + + const rate = await billingService.createBillingRate(createRateDto, tenantId, userId); + + expect(rate.hourly_rate).toBe(200.00); + expect(rate.rate_type).toBe('project_user'); + }); + + it('should reject negative hourly rates', async () => { + const invalidRateDto = { + company_id: companyId, + hourly_rate: -50.00, + currency_id: 'currency-mxn', + }; + + await expect( + billingService.createBillingRate(invalidRateDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Flow 5: Project Reports', () => { + it('should generate billing summary by project', async () => { + const summaryData = [ + { + project_id: 'proj-1', + project_name: 'Project Alpha', + partner_id: 'partner-1', + partner_name: 'Client A', + total_hours: 80, + total_amount: 12000.00, + currency_id: 'curr-mxn', + currency_code: 'MXN', + timesheet_count: 20, + date_from: new Date('2026-01-01'), + date_to: new Date('2026-01-31'), + }, + { + project_id: 'proj-2', + project_name: 'Project Beta', + partner_id: 'partner-2', + partner_name: 'Client B', + total_hours: 120, + total_amount: 18000.00, + currency_id: 'curr-mxn', + currency_code: 'MXN', + timesheet_count: 30, + date_from: new Date('2026-01-01'), + date_to: new Date('2026-01-31'), + }, + ]; + + mockQuery.mockResolvedValue(summaryData); + + const summary = await billingService.getBillingSummary(tenantId, companyId, {}); + + expect(summary).toHaveLength(2); + expect(summary[0].total_hours).toBe(80); + expect(summary[1].total_hours).toBe(120); + + const totalAmount = summary.reduce((sum, s) => sum + s.total_amount, 0); + expect(totalAmount).toBe(30000.00); + }); + + it('should get project billing history', async () => { + const projectId = 'project-uuid-1'; + + // Mock billed stats + const billedStats = { hours: '40', amount: '6000' }; + // Mock unbilled stats + const unbilledStats = { hours: '20', amount: '3000' }; + // Mock invoices + const invoices = [ + { id: 'inv-1', number: 'INV-001', date: new Date('2026-01-15'), amount: 3000, status: 'paid' }, + { id: 'inv-2', number: 'INV-002', date: new Date('2026-01-30'), amount: 3000, status: 'open' }, + ]; + + mockQueryOne + .mockResolvedValueOnce(billedStats) + .mockResolvedValueOnce(unbilledStats); + mockQuery.mockResolvedValue(invoices); + + const history = await billingService.getProjectBillingHistory(tenantId, projectId); + + expect(history.total_hours_billed).toBe(40); + expect(history.total_amount_billed).toBe(6000); + expect(history.unbilled_hours).toBe(20); + expect(history.unbilled_amount).toBe(3000); + expect(history.invoices).toHaveLength(2); + }); + }); + + describe('Flow 6: Task Hierarchy and Subtasks', () => { + it('should create subtasks under parent task', async () => { + const parentTaskId = 'parent-task-uuid'; + const subtaskData = { + project_id: 'project-uuid', + name: 'Subtask 1', + parent_id: parentTaskId, + estimated_hours: 4, + }; + + const createdSubtask = createMockTask({ + ...subtaskData, + id: 'subtask-uuid', + sequence: 1, + }); + + mockQueryOne + .mockResolvedValueOnce({ max_seq: 1 }) + .mockResolvedValueOnce(createdSubtask); + + const subtask = await tasksService.create(subtaskData, tenantId, userId); + + expect(subtask.parent_id).toBe(parentTaskId); + }); + + it('should prevent task from being its own parent', async () => { + const taskId = 'task-uuid-1'; + const existingTask = createMockTask({ id: taskId }); + + mockQueryOne.mockResolvedValue(existingTask); + + await expect( + tasksService.update(taskId, { parent_id: taskId }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Flow 7: Task Stage Movement (Kanban)', () => { + it('should move task between stages', async () => { + const taskId = 'task-uuid-1'; + const newStageId = 'stage-in-progress-uuid'; + const newSequence = 3; + + const existingTask = createMockTask({ + id: taskId, + stage_id: 'stage-todo-uuid', + sequence: 1, + }); + + mockQueryOne.mockResolvedValue(existingTask); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValue({ + ...existingTask, + stage_id: newStageId, + sequence: newSequence, + }); + + const movedTask = await tasksService.move(taskId, newStageId, newSequence, tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('stage_id = $1, sequence = $2'), + expect.arrayContaining([newStageId, newSequence]) + ); + }); + }); + + describe('Flow 8: Timesheet Approval Workflow', () => { + it('should complete full approval workflow', async () => { + const timesheetId = 'timesheet-uuid-1'; + const workerId = 'worker-uuid'; + + // Initial draft state + const draftTimesheet = { + id: timesheetId, + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: workerId, + date: new Date('2026-01-15'), + hours: 8, + billable: true, + status: 'draft' as const, + created_at: new Date(), + }; + + // Step 1: Submit by worker + mockQueryOne + .mockResolvedValueOnce(draftTimesheet) + .mockResolvedValueOnce({ ...draftTimesheet, status: 'submitted' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.submit(timesheetId, tenantId, workerId); + + // Step 2: Reject by manager (needs changes) + const submittedTimesheet = { ...draftTimesheet, status: 'submitted' as const }; + mockQueryOne + .mockResolvedValueOnce(submittedTimesheet) + .mockResolvedValueOnce({ ...submittedTimesheet, status: 'rejected' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.reject(timesheetId, tenantId, managerId); + + // Note: After rejection, worker would need to edit and resubmit + // This simulates the workflow but actual re-editing requires + // the status to be reset to draft first + }); + + it('should not allow editing approved timesheets', async () => { + const approvedTimesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 8, + billable: true, + status: 'approved' as const, + created_at: new Date(), + }; + + mockQueryOne.mockResolvedValue(approvedTimesheet); + + await expect( + timesheetsService.update('timesheet-uuid-1', { hours: 10 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle project not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + projectsService.findById('nonexistent', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should handle task not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + tasksService.findById('nonexistent', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should handle timesheet not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + timesheetsService.findById('nonexistent', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should handle concurrent modifications gracefully', async () => { + const project = createMockProject(); + + // Simulate first read + mockQueryOne.mockResolvedValue(project); + mockQuery.mockResolvedValue([]); + + // Multiple concurrent updates should not throw + await Promise.all([ + projectsService.update('project-uuid-1', { name: 'Update 1' }, tenantId, userId), + projectsService.update('project-uuid-1', { name: 'Update 2' }, tenantId, 'other-user'), + ]); + + expect(mockQuery).toHaveBeenCalledTimes(2); + }); + + it('should handle empty result sets', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + const result = await projectsService.findAll(tenantId, { + status: 'nonexistent-status', + }); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('Time Calculations', () => { + it('should calculate total time spent on project', async () => { + const projectId = 'project-uuid-1'; + + const project = createMockProject({ id: projectId }); + const stats = { + total_tasks: 5, + completed_tasks: 3, + in_progress_tasks: 2, + total_hours: 156.5, // Decimal hours + total_milestones: 2, + completed_milestones: 1, + }; + + mockQueryOne + .mockResolvedValueOnce(project) + .mockResolvedValueOnce(stats); + + const projectStats = await projectsService.getStats(projectId, tenantId); + + expect((projectStats as any).total_hours).toBe(156.5); + }); + + it('should track estimated vs actual hours per task', async () => { + const task = createMockTask({ + id: 'task-uuid-1', + estimated_hours: 20, + spent_hours: 15, + }); + + mockQueryOne.mockResolvedValue(task); + + const result = await tasksService.findById('task-uuid-1', tenantId); + + expect(result.estimated_hours).toBe(20); + expect(result.spent_hours).toBe(15); + + // Calculate remaining hours + const remaining = (result.estimated_hours || 0) - (result.spent_hours || 0); + expect(remaining).toBe(5); + }); + }); +}); diff --git a/src/modules/projects/__tests__/timesheets.service.test.ts b/src/modules/projects/__tests__/timesheets.service.test.ts new file mode 100644 index 0000000..41e8e49 --- /dev/null +++ b/src/modules/projects/__tests__/timesheets.service.test.ts @@ -0,0 +1,571 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockTimesheet, createMockProject, createMockTask } from '../../../__tests__/helpers.js'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), +})); + +// Import after mocking +import { timesheetsService, Timesheet } from '../timesheets.service.js'; +import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; + +describe('TimesheetsService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + const companyId = 'test-company-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return timesheets with pagination', async () => { + const mockTimesheets = [ + createMockTimesheet({ id: '1', hours: 4 }), + createMockTimesheet({ id: '2', hours: 8 }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockTimesheets); + + const result = await timesheetsService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by project_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.findAll(tenantId, { project_id: 'project-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.project_id = $'), + expect.arrayContaining([tenantId, 'project-uuid']) + ); + }); + + it('should filter by task_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.findAll(tenantId, { task_id: 'task-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.task_id = $'), + expect.arrayContaining([tenantId, 'task-uuid']) + ); + }); + + it('should filter by user_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.findAll(tenantId, { user_id: userId }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.user_id = $'), + expect.arrayContaining([tenantId, userId]) + ); + }); + + it('should filter by date range', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.findAll(tenantId, { + date_from: '2026-01-01', + date_to: '2026-01-31' + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.date >= $'), + expect.arrayContaining([tenantId, '2026-01-01', '2026-01-31']) + ); + }); + + it('should filter by status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await timesheetsService.findAll(tenantId, { status: 'approved' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.status = $'), + expect.arrayContaining([tenantId, 'approved']) + ); + }); + }); + + describe('findById', () => { + it('should return timesheet when found', async () => { + const mockTimesheet = createMockTimesheet(); + mockQueryOne.mockResolvedValue(mockTimesheet); + + const result = await timesheetsService.findById('timesheet-uuid-1', tenantId); + + expect(result).toEqual(mockTimesheet); + }); + + it('should throw NotFoundError when not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + timesheetsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + const createDto = { + company_id: companyId, + project_id: 'project-uuid', + task_id: 'task-uuid', + date: '2026-01-15', + hours: 8, + description: 'Development work', + billable: true, + }; + + it('should create timesheet successfully', async () => { + const createdTimesheet = createMockTimesheet({ ...createDto }); + mockQueryOne.mockResolvedValue(createdTimesheet); + + const result = await timesheetsService.create(createDto, tenantId, userId); + + expect(result.hours).toBe(8); + expect(mockQueryOne).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO projects.timesheets'), + expect.any(Array) + ); + }); + + it('should throw ValidationError when hours is 0 or negative', async () => { + await expect( + timesheetsService.create({ ...createDto, hours: 0 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + + await expect( + timesheetsService.create({ ...createDto, hours: -1 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when hours exceeds 24', async () => { + await expect( + timesheetsService.create({ ...createDto, hours: 25 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should create timesheet without task_id', async () => { + const dtoWithoutTask = { ...createDto, task_id: undefined }; + const createdTimesheet = createMockTimesheet({ ...dtoWithoutTask, task_id: null }); + mockQueryOne.mockResolvedValue(createdTimesheet); + + const result = await timesheetsService.create(dtoWithoutTask, tenantId, userId); + + expect(result).toBeDefined(); + }); + + it('should default billable to true', async () => { + const dtoWithoutBillable = { ...createDto, billable: undefined }; + const createdTimesheet = createMockTimesheet({ ...dtoWithoutBillable, billable: true }); + mockQueryOne.mockResolvedValue(createdTimesheet); + + const result = await timesheetsService.create(dtoWithoutBillable, tenantId, userId); + + expect(result.billable).toBe(true); + }); + }); + + describe('update', () => { + it('should update timesheet successfully when in draft status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne + .mockResolvedValueOnce(existingTimesheet) // findById + .mockResolvedValueOnce({ ...existingTimesheet, hours: 8 }); // after update + mockQuery.mockResolvedValue([]); + + await timesheetsService.update('timesheet-uuid-1', { hours: 8 }, tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE projects.timesheets SET'), + expect.any(Array) + ); + }); + + it('should throw ValidationError when timesheet is not in draft status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'submitted', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.update('timesheet-uuid-1', { hours: 8 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when user is not the owner', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'other-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.update('timesheet-uuid-1', { hours: 8 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when updating hours outside valid range', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.update('timesheet-uuid-1', { hours: 25 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should return unchanged timesheet when no fields to update', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + const result = await timesheetsService.update('timesheet-uuid-1', {}, tenantId, userId); + + expect(result.id).toBe(existingTimesheet.id); + }); + }); + + describe('delete', () => { + it('should delete timesheet when in draft status and owned by user', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + mockQuery.mockResolvedValue([]); + + await timesheetsService.delete('timesheet-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM projects.timesheets'), + expect.any(Array) + ); + }); + + it('should throw ValidationError when timesheet is not in draft status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'approved', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.delete('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when user is not the owner', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'other-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.delete('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw NotFoundError when timesheet not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + timesheetsService.delete('nonexistent-id', tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('submit', () => { + it('should submit timesheet successfully', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne + .mockResolvedValueOnce(existingTimesheet) // findById + .mockResolvedValueOnce({ ...existingTimesheet, status: 'submitted' }); // after update + mockQuery.mockResolvedValue([]); + + const result = await timesheetsService.submit('timesheet-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'submitted'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when not in draft status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: userId, + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'submitted', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.submit('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when user is not the owner', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'other-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.submit('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('approve', () => { + it('should approve timesheet successfully', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'worker-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'submitted', + created_at: new Date(), + }; + const approverId = 'manager-uuid'; + mockQueryOne + .mockResolvedValueOnce(existingTimesheet) // findById + .mockResolvedValueOnce({ ...existingTimesheet, status: 'approved' }); // after update + mockQuery.mockResolvedValue([]); + + const result = await timesheetsService.approve('timesheet-uuid-1', tenantId, approverId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'approved'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when not in submitted status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'worker-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'draft', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.approve('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('reject', () => { + it('should reject timesheet successfully', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'worker-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'submitted', + created_at: new Date(), + }; + const approverId = 'manager-uuid'; + mockQueryOne + .mockResolvedValueOnce(existingTimesheet) // findById + .mockResolvedValueOnce({ ...existingTimesheet, status: 'rejected' }); // after update + mockQuery.mockResolvedValue([]); + + const result = await timesheetsService.reject('timesheet-uuid-1', tenantId, approverId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'rejected'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when not in submitted status', async () => { + const existingTimesheet: Timesheet = { + id: 'timesheet-uuid-1', + tenant_id: tenantId, + company_id: companyId, + project_id: 'project-uuid', + user_id: 'worker-user-uuid', + date: new Date('2026-01-15'), + hours: 4, + billable: true, + status: 'approved', + created_at: new Date(), + }; + mockQueryOne.mockResolvedValue(existingTimesheet); + + await expect( + timesheetsService.reject('timesheet-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('getMyTimesheets', () => { + it('should return timesheets for current user', async () => { + const mockTimesheets = [ + createMockTimesheet({ user_id: userId }), + ]; + + mockQueryOne.mockResolvedValue({ count: '1' }); + mockQuery.mockResolvedValue(mockTimesheets); + + const result = await timesheetsService.getMyTimesheets(tenantId, userId, {}); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.user_id = $'), + expect.arrayContaining([tenantId, userId]) + ); + expect(result.data).toHaveLength(1); + }); + }); + + describe('getPendingApprovals', () => { + it('should return submitted timesheets pending approval', async () => { + const mockTimesheets = [ + createMockTimesheet({ status: 'submitted' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '1' }); + mockQuery.mockResolvedValue(mockTimesheets); + + const result = await timesheetsService.getPendingApprovals(tenantId, {}); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ts.status = $'), + expect.arrayContaining([tenantId, 'submitted']) + ); + expect(result.data).toHaveLength(1); + }); + }); +});