[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:
Adrian Flores Cortes 2026-01-26 19:25:22 -06:00
parent 6c6ce41343
commit 01c5d98c54
2 changed files with 1296 additions and 0 deletions

View 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);
});
});
});

View 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);
});
});
});