[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