[TASK-010] test: Add projects integration and unit tests
- projects-flow.integration.test.ts: Full project lifecycle tests - timesheets.service.test.ts: Timesheet unit tests - Coverage: Project -> Tasks -> Time registration, billing, progress Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6c6ce41343
commit
01c5d98c54
725
src/modules/projects/__tests__/projects-flow.integration.test.ts
Normal file
725
src/modules/projects/__tests__/projects-flow.integration.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
571
src/modules/projects/__tests__/timesheets.service.test.ts
Normal file
571
src/modules/projects/__tests__/timesheets.service.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user