test(fase3): Add unit tests for business modules

Add comprehensive unit tests for FASE 3 modules:
- Sales: quotations.service (15 tests), orders.service (27 tests)
- Purchases: purchases.service (21 tests), rfqs.service (39 tests)
- CRM: leads.service (25 tests), opportunities.service (23 tests), stages.service (19 tests)
- Projects: projects.service (15 tests), tasks.service (19 tests)

Updated helpers.ts with factory functions for all new entity types.
Total: 203 new tests (348 tests total, all passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 04:36:21 -06:00
parent 301628f759
commit a7bf403367
10 changed files with 3859 additions and 0 deletions

View File

@ -166,3 +166,392 @@ export function createMockAccount(overrides = {}) {
...overrides,
};
}
// Quotation factory
export function createMockQuotation(overrides: Record<string, any> = {}) {
return {
id: 'quotation-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'QUO-000001',
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
quotation_date: new Date(),
validity_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
pricelist_id: null,
user_id: 'user-uuid-1',
sales_team_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
sale_order_id: null,
notes: null,
terms_conditions: null,
lines: [],
created_at: new Date(),
...overrides,
};
}
// Quotation line factory
export function createMockQuotationLine(overrides: Record<string, any> = {}) {
return {
id: 'quotation-line-uuid-1',
quotation_id: 'quotation-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
description: 'Test product description',
quantity: 10,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
tax_ids: [],
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
...overrides,
};
}
// Sales Order factory
export function createMockSalesOrder(overrides: Record<string, any> = {}) {
return {
id: 'order-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'SO-000001',
client_order_ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
order_date: new Date(),
validity_date: null,
commitment_date: null,
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
pricelist_id: null,
payment_term_id: null,
user_id: 'user-uuid-1',
sales_team_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
invoice_status: 'pending' as const,
delivery_status: 'pending' as const,
invoice_policy: 'order' as const,
picking_id: null,
notes: null,
terms_conditions: null,
lines: [],
created_at: new Date(),
confirmed_at: null,
...overrides,
};
}
// Sales Order line factory
export function createMockSalesOrderLine(overrides: Record<string, any> = {}) {
return {
id: 'order-line-uuid-1',
order_id: 'order-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
description: 'Test product description',
quantity: 10,
qty_delivered: 0,
qty_invoiced: 0,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
tax_ids: [],
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
analytic_account_id: null,
...overrides,
};
}
// Purchase Order factory
export function createMockPurchaseOrder(overrides: Record<string, any> = {}) {
return {
id: 'purchase-order-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'PO-000001',
ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Supplier',
order_date: new Date(),
expected_date: null,
effective_date: null,
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
payment_term_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
receipt_status: 'pending',
invoice_status: 'pending',
notes: null,
lines: [],
created_at: new Date(),
confirmed_at: null,
...overrides,
};
}
// Purchase Order line factory
export function createMockPurchaseOrderLine(overrides: Record<string, any> = {}) {
return {
id: 'purchase-line-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
product_code: 'PROD-001',
description: 'Test product description',
quantity: 10,
qty_received: 0,
qty_invoiced: 0,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
expected_date: null,
...overrides,
};
}
// RFQ factory
export function createMockRfq(overrides: Record<string, any> = {}) {
return {
id: 'rfq-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'RFQ-000001',
partner_ids: ['supplier-uuid-1'],
partner_names: ['Test Supplier'],
request_date: new Date(),
deadline_date: null,
response_date: null,
status: 'draft' as const,
description: null,
notes: null,
lines: [],
created_at: new Date(),
...overrides,
};
}
// RFQ line factory
export function createMockRfqLine(overrides: Record<string, any> = {}) {
return {
id: 'rfq-line-uuid-1',
rfq_id: 'rfq-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
product_code: 'PROD-001',
description: 'Test product description',
quantity: 10,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
created_at: new Date(),
...overrides,
};
}
// Lead factory
export function createMockLead(overrides: Record<string, any> = {}) {
return {
id: 'lead-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Lead',
ref: null,
contact_name: 'John Doe',
email: 'john@test.com',
phone: '+1234567890',
mobile: null,
website: null,
company_prospect_name: 'Prospect Inc',
job_position: 'Manager',
industry: 'Technology',
stage_id: 'stage-uuid-1',
stage_name: 'New',
status: 'new' as const,
user_id: 'user-uuid-1',
sales_team_id: null,
source: 'website' as const,
priority: 1,
probability: 10,
expected_revenue: 5000,
date_open: new Date(),
date_closed: null,
partner_id: null,
opportunity_id: null,
lost_reason_id: null,
description: null,
notes: null,
tags: [],
created_at: new Date(),
...overrides,
};
}
// Opportunity factory
export function createMockOpportunity(overrides: Record<string, any> = {}) {
return {
id: 'opportunity-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Opportunity',
ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
contact_name: 'John Doe',
email: 'john@test.com',
phone: '+1234567890',
stage_id: 'stage-uuid-1',
stage_name: 'Qualification',
status: 'open' as const,
user_id: 'user-uuid-1',
sales_team_id: null,
priority: 2,
probability: 30,
expected_revenue: 10000,
recurring_revenue: null,
recurring_plan: null,
date_deadline: null,
date_closed: null,
date_last_activity: null,
lead_id: null,
source: 'website' as const,
lost_reason_id: null,
quotation_id: null,
order_id: null,
description: null,
notes: null,
tags: [],
created_at: new Date(),
...overrides,
};
}
// Stage factory (for both Lead and Opportunity stages)
export function createMockStage(overrides: Record<string, any> = {}) {
return {
id: 'stage-uuid-1',
tenant_id: global.testTenantId,
name: 'New',
sequence: 1,
is_won: false,
probability: 10,
requirements: null,
active: true,
created_at: new Date(),
...overrides,
};
}
// Lost Reason factory
export function createMockLostReason(overrides: Record<string, any> = {}) {
return {
id: 'lost-reason-uuid-1',
tenant_id: global.testTenantId,
name: 'Too expensive',
description: 'Customer found a cheaper alternative',
active: true,
created_at: new Date(),
...overrides,
};
}
// Project factory
export function createMockProject(overrides: Record<string, any> = {}) {
return {
id: 'project-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Project',
code: 'PROJ-001',
description: 'Test project description',
manager_id: 'user-uuid-1',
manager_name: 'John Manager',
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
analytic_account_id: null,
date_start: new Date(),
date_end: null,
status: 'active' as const,
privacy: 'public' as const,
allow_timesheets: true,
color: '#3498db',
task_count: 5,
completed_task_count: 2,
created_at: new Date(),
...overrides,
};
}
// Task factory
export function createMockTask(overrides: Record<string, any> = {}) {
return {
id: 'task-uuid-1',
tenant_id: global.testTenantId,
project_id: 'project-uuid-1',
project_name: 'Test Project',
stage_id: 'stage-uuid-1',
stage_name: 'To Do',
name: 'Test Task',
description: 'Test task description',
assigned_to: 'user-uuid-1',
assigned_name: 'John Doe',
parent_id: null,
parent_name: null,
date_deadline: null,
estimated_hours: 8,
spent_hours: 0,
priority: 'normal' as const,
status: 'todo' as const,
sequence: 1,
color: null,
created_at: new Date(),
...overrides,
};
}
// Timesheet factory
export function createMockTimesheet(overrides: Record<string, any> = {}) {
return {
id: 'timesheet-uuid-1',
tenant_id: global.testTenantId,
project_id: 'project-uuid-1',
project_name: 'Test Project',
task_id: 'task-uuid-1',
task_name: 'Test Task',
employee_id: 'employee-uuid-1',
employee_name: 'John Doe',
date: new Date(),
hours: 4,
description: 'Worked on feature X',
billable: true,
invoiced: false,
created_at: new Date(),
...overrides,
};
}

View File

@ -0,0 +1,309 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockLead } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { leadsService } from '../leads.service.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
describe('LeadsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return leads with pagination', async () => {
const mockLeads = [
createMockLead({ id: '1', name: 'Lead 1' }),
createMockLead({ id: '2', name: 'Lead 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockLeads);
const result = await leadsService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { status: 'new' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.status = $'),
expect.arrayContaining([tenantId, 'new'])
);
});
it('should filter by stage_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { stage_id: 'stage-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.stage_id = $'),
expect.arrayContaining([tenantId, 'stage-uuid'])
);
});
it('should filter by source', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { source: 'website' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.source = $'),
expect.arrayContaining([tenantId, 'website'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { search: 'John' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.name ILIKE'),
expect.arrayContaining([tenantId, '%John%'])
);
});
});
describe('findById', () => {
it('should return lead when found', async () => {
const mockLead = createMockLead();
mockQueryOne.mockResolvedValue(mockLead);
const result = await leadsService.findById('lead-uuid-1', tenantId);
expect(result).toEqual(mockLead);
});
it('should throw NotFoundError when lead not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
leadsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'New Lead',
contact_name: 'Jane Doe',
email: 'jane@test.com',
};
it('should create lead successfully', async () => {
const createdLead = createMockLead({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(createdLead) // INSERT
.mockResolvedValueOnce(createdLead); // findById
const result = await leadsService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
});
describe('update', () => {
it('should update lead successfully', async () => {
const existingLead = createMockLead({ status: 'new' });
mockQueryOne.mockResolvedValue(existingLead);
mockQuery.mockResolvedValue([]);
await leadsService.update(
'lead-uuid-1',
{ name: 'Updated Lead' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.leads SET'),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('moveStage', () => {
it('should move lead to new stage', async () => {
const lead = createMockLead({ status: 'new' });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1'),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('convert', () => {
it('should convert lead to opportunity', async () => {
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
mockQueryOne.mockResolvedValue(lead);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // existing partner check
.mockResolvedValueOnce({ rows: [{ id: 'new-partner-uuid' }] }) // create partner
.mockResolvedValueOnce({ rows: [{ id: 'stage-uuid' }] }) // get default stage
.mockResolvedValueOnce({ rows: [{ id: 'opportunity-uuid' }] }) // create opportunity
.mockResolvedValueOnce(undefined) // update lead
.mockResolvedValueOnce(undefined); // COMMIT
const result = await leadsService.convert('lead-uuid-1', tenantId, userId);
expect(result.opportunity_id).toBe('opportunity-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when lead is already converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
mockQueryOne.mockResolvedValue(lead);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error'));
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('markLost', () => {
it('should mark lead as lost', async () => {
const lead = createMockLead({ status: 'qualified' });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Too expensive', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'lost'"),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is already lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('delete', () => {
it('should delete lead without opportunity', async () => {
const lead = createMockLead({ opportunity_id: null });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.delete('lead-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.leads'),
expect.any(Array)
);
});
it('should throw ConflictError when lead has opportunity', async () => {
const lead = createMockLead({ opportunity_id: 'opportunity-uuid' });
mockQueryOne.mockResolvedValue(lead);
await expect(
leadsService.delete('lead-uuid-1', tenantId)
).rejects.toThrow(ConflictError);
});
});
});

View File

@ -0,0 +1,361 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockOpportunity, createMockStage } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { opportunitiesService } from '../opportunities.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('OpportunitiesService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return opportunities with pagination', async () => {
const mockOpportunities = [
createMockOpportunity({ id: '1', name: 'Opp 1' }),
createMockOpportunity({ id: '2', name: 'Opp 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockOpportunities);
const result = await opportunitiesService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { status: 'open' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.status = $'),
expect.arrayContaining([tenantId, 'open'])
);
});
it('should filter by partner_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { partner_id: 'partner-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.partner_id = $'),
expect.arrayContaining([tenantId, 'partner-uuid'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
});
describe('findById', () => {
it('should return opportunity when found', async () => {
const mockOpp = createMockOpportunity();
mockQueryOne.mockResolvedValue(mockOpp);
const result = await opportunitiesService.findById('opp-uuid-1', tenantId);
expect(result).toEqual(mockOpp);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
opportunitiesService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'New Opportunity',
partner_id: 'partner-uuid',
};
it('should create opportunity successfully', async () => {
const createdOpp = createMockOpportunity({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(createdOpp) // INSERT
.mockResolvedValueOnce(createdOpp); // findById
const result = await opportunitiesService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
});
describe('update', () => {
it('should update opportunity successfully', async () => {
const existingOpp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(existingOpp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.update(
'opp-uuid-1',
{ name: 'Updated Opportunity' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.opportunities SET'),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.update('opp-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('moveStage', () => {
it('should move opportunity to new stage', async () => {
const opp = createMockOpportunity({ status: 'open' });
const stage = createMockStage({ id: 'new-stage-uuid', probability: 50 });
mockQueryOne
.mockResolvedValueOnce(opp) // findById
.mockResolvedValueOnce(stage); // get stage
mockQuery.mockResolvedValue([]);
await opportunitiesService.moveStage('opp-uuid-1', 'new-stage-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1'),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.moveStage('opp-uuid-1', 'stage-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw NotFoundError when stage not found', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne
.mockResolvedValueOnce(opp) // findById
.mockResolvedValueOnce(null); // stage not found
await expect(
opportunitiesService.moveStage('opp-uuid-1', 'nonexistent-stage', tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
describe('markWon', () => {
it('should mark opportunity as won', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.markWon('opp-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'won'"),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const lostOpp = createMockOpportunity({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostOpp);
await expect(
opportunitiesService.markWon('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('markLost', () => {
it('should mark opportunity as lost', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'lost'"),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('createQuotation', () => {
it('should create quotation from opportunity', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
mockQueryOne.mockResolvedValue(opp);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
.mockResolvedValueOnce({ rows: [{ id: 'currency-uuid' }] }) // currency
.mockResolvedValueOnce({ rows: [{ id: 'quotation-uuid' }] }) // create quotation
.mockResolvedValueOnce(undefined) // update opportunity
.mockResolvedValueOnce(undefined); // COMMIT
const result = await opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId);
expect(result.quotation_id).toBe('quotation-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when quotation already exists', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: 'existing-quotation' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
mockQueryOne.mockResolvedValue(opp);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error'));
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('delete', () => {
it('should delete opportunity without quotation or order', async () => {
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: null });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.delete('opp-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.opportunities'),
expect.any(Array)
);
});
it('should throw ValidationError when has quotation', async () => {
const opp = createMockOpportunity({ quotation_id: 'quotation-uuid' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.delete('opp-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when has order', async () => {
const opp = createMockOpportunity({ order_id: 'order-uuid' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.delete('opp-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
it('should update lead when deleting opportunity with lead', async () => {
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: 'lead-uuid' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.delete('opp-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.leads SET opportunity_id = NULL'),
expect.any(Array)
);
});
});
describe('getPipeline', () => {
it('should return pipeline with stages and opportunities', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'Qualification', sequence: 1 }),
createMockStage({ id: '2', name: 'Proposal', sequence: 2 }),
];
const mockOpps = [
createMockOpportunity({ id: '1', stage_id: '1', expected_revenue: 5000 }),
createMockOpportunity({ id: '2', stage_id: '2', expected_revenue: 10000 }),
];
mockQuery
.mockResolvedValueOnce(mockStages) // stages
.mockResolvedValueOnce(mockOpps); // opportunities
const result = await opportunitiesService.getPipeline(tenantId);
expect(result.stages).toHaveLength(2);
expect(result.totals.total_opportunities).toBe(2);
});
});
});

View File

@ -0,0 +1,286 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockStage, createMockLostReason } 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 { stagesService } from '../stages.service.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
describe('StagesService', () => {
const tenantId = 'test-tenant-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('Lead Stages', () => {
describe('getLeadStages', () => {
it('should return active lead stages', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'New' }),
createMockStage({ id: '2', name: 'Qualified' }),
];
mockQuery.mockResolvedValue(mockStages);
const result = await stagesService.getLeadStages(tenantId);
expect(result).toHaveLength(2);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('active = TRUE'),
[tenantId]
);
});
it('should include inactive stages when requested', async () => {
mockQuery.mockResolvedValue([]);
await stagesService.getLeadStages(tenantId, true);
expect(mockQuery).toHaveBeenCalledWith(
expect.not.stringContaining('active = TRUE'),
[tenantId]
);
});
});
describe('getLeadStageById', () => {
it('should return stage when found', async () => {
const mockStage = createMockStage();
mockQueryOne.mockResolvedValue(mockStage);
const result = await stagesService.getLeadStageById('stage-uuid', tenantId);
expect(result).toEqual(mockStage);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
stagesService.getLeadStageById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('createLeadStage', () => {
it('should create lead stage successfully', async () => {
const newStage = createMockStage({ name: 'New Stage' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newStage); // INSERT
const result = await stagesService.createLeadStage({ name: 'New Stage' }, tenantId);
expect(result.name).toBe('New Stage');
});
it('should throw ConflictError when name exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
stagesService.createLeadStage({ name: 'Existing Stage' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('updateLeadStage', () => {
it('should update lead stage successfully', async () => {
const existingStage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(existingStage) // getById
.mockResolvedValueOnce(null) // unique name check
.mockResolvedValueOnce({ ...existingStage, name: 'Updated' }); // getById after update
mockQuery.mockResolvedValue([]);
const result = await stagesService.updateLeadStage('stage-uuid', { name: 'Updated' }, tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.lead_stages SET'),
expect.any(Array)
);
});
it('should throw ConflictError when name exists for another stage', async () => {
const existingStage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(existingStage) // getById
.mockResolvedValueOnce({ id: 'other-uuid' }); // name exists
await expect(
stagesService.updateLeadStage('stage-uuid', { name: 'Duplicate' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('deleteLeadStage', () => {
it('should delete stage without leads', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '0' }); // in use check
mockQuery.mockResolvedValue([]);
await stagesService.deleteLeadStage('stage-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.lead_stages'),
expect.any(Array)
);
});
it('should throw ConflictError when stage has leads', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '5' }); // in use
await expect(
stagesService.deleteLeadStage('stage-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
describe('Opportunity Stages', () => {
describe('getOpportunityStages', () => {
it('should return active opportunity stages', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'Qualification' }),
createMockStage({ id: '2', name: 'Proposal' }),
];
mockQuery.mockResolvedValue(mockStages);
const result = await stagesService.getOpportunityStages(tenantId);
expect(result).toHaveLength(2);
});
});
describe('createOpportunityStage', () => {
it('should create opportunity stage successfully', async () => {
const newStage = createMockStage({ name: 'New Stage' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newStage); // INSERT
const result = await stagesService.createOpportunityStage({ name: 'New Stage' }, tenantId);
expect(result.name).toBe('New Stage');
});
});
describe('deleteOpportunityStage', () => {
it('should delete stage without opportunities', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '0' }); // in use check
mockQuery.mockResolvedValue([]);
await stagesService.deleteOpportunityStage('stage-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.opportunity_stages'),
expect.any(Array)
);
});
it('should throw ConflictError when stage has opportunities', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '3' }); // in use
await expect(
stagesService.deleteOpportunityStage('stage-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
describe('Lost Reasons', () => {
describe('getLostReasons', () => {
it('should return active lost reasons', async () => {
const mockReasons = [
createMockLostReason({ id: '1', name: 'Too expensive' }),
createMockLostReason({ id: '2', name: 'Competitor' }),
];
mockQuery.mockResolvedValue(mockReasons);
const result = await stagesService.getLostReasons(tenantId);
expect(result).toHaveLength(2);
});
});
describe('createLostReason', () => {
it('should create lost reason successfully', async () => {
const newReason = createMockLostReason({ name: 'New Reason' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newReason); // INSERT
const result = await stagesService.createLostReason({ name: 'New Reason' }, tenantId);
expect(result.name).toBe('New Reason');
});
it('should throw ConflictError when name exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
stagesService.createLostReason({ name: 'Existing' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('deleteLostReason', () => {
it('should delete reason not in use', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '0' }) // leads check
.mockResolvedValueOnce({ count: '0' }); // opportunities check
mockQuery.mockResolvedValue([]);
await stagesService.deleteLostReason('reason-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.lost_reasons'),
expect.any(Array)
);
});
it('should throw ConflictError when reason is in use by leads', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '2' }); // leads check
await expect(
stagesService.deleteLostReason('reason-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
it('should throw ConflictError when reason is in use by opportunities', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '0' }) // leads check
.mockResolvedValueOnce({ count: '3' }); // opportunities check
await expect(
stagesService.deleteLostReason('reason-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
});

View File

@ -0,0 +1,242 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockProject } 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 { projectsService } from '../projects.service.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
describe('ProjectsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return projects with pagination', async () => {
const mockProjects = [
createMockProject({ id: '1', name: 'Project 1' }),
createMockProject({ id: '2', name: 'Project 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockProjects);
const result = await projectsService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await projectsService.findAll(tenantId, { status: 'active' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.status = $'),
expect.arrayContaining([tenantId, 'active'])
);
});
it('should filter by manager_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await projectsService.findAll(tenantId, { manager_id: 'manager-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.manager_id = $'),
expect.arrayContaining([tenantId, 'manager-uuid'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await projectsService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
});
describe('findById', () => {
it('should return project when found', async () => {
const mockProject = createMockProject();
mockQueryOne.mockResolvedValue(mockProject);
const result = await projectsService.findById('project-uuid-1', tenantId);
expect(result).toEqual(mockProject);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
projectsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'New Project',
code: 'PROJ-001',
};
it('should create project successfully', async () => {
const createdProject = createMockProject({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(null) // unique code check
.mockResolvedValueOnce(createdProject); // INSERT
const result = await projectsService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
it('should throw ConflictError when code exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
projectsService.create(createDto, tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should create project without code', async () => {
const dtoWithoutCode = { company_id: 'company-uuid', name: 'Project' };
const createdProject = createMockProject({ ...dtoWithoutCode, code: null });
mockQueryOne.mockResolvedValue(createdProject);
const result = await projectsService.create(dtoWithoutCode, tenantId, userId);
expect(result.name).toBe(dtoWithoutCode.name);
});
});
describe('update', () => {
it('should update project successfully', async () => {
const existingProject = createMockProject();
mockQueryOne.mockResolvedValue(existingProject);
mockQuery.mockResolvedValue([]);
await projectsService.update(
'project-uuid-1',
{ name: 'Updated Project' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE projects.projects SET'),
expect.any(Array)
);
});
it('should throw ConflictError when code exists for another project', async () => {
const existingProject = createMockProject();
mockQueryOne
.mockResolvedValueOnce(existingProject) // findById
.mockResolvedValueOnce({ id: 'other-uuid' }); // code exists
await expect(
projectsService.update('project-uuid-1', { code: 'DUPLICATE' }, tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should return unchanged project when no fields to update', async () => {
const existingProject = createMockProject();
mockQueryOne.mockResolvedValue(existingProject);
const result = await projectsService.update('project-uuid-1', {}, tenantId, userId);
expect(result.id).toBe(existingProject.id);
});
});
describe('delete', () => {
it('should soft delete project', async () => {
const project = createMockProject();
mockQueryOne.mockResolvedValue(project);
mockQuery.mockResolvedValue([]);
await projectsService.delete('project-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('deleted_at = CURRENT_TIMESTAMP'),
expect.any(Array)
);
});
it('should throw NotFoundError when project not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
projectsService.delete('nonexistent-id', tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
describe('getStats', () => {
it('should return project statistics', async () => {
const project = createMockProject();
const stats = {
total_tasks: 10,
completed_tasks: 5,
in_progress_tasks: 3,
total_hours: 40,
total_milestones: 3,
completed_milestones: 1,
};
mockQueryOne
.mockResolvedValueOnce(project) // findById
.mockResolvedValueOnce(stats); // getStats
const result = await projectsService.getStats('project-uuid-1', tenantId);
expect(result).toMatchObject({
total_tasks: 10,
completed_tasks: 5,
completion_percentage: 50,
});
});
it('should return 0% completion when no tasks', async () => {
const project = createMockProject();
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 result = await projectsService.getStats('project-uuid-1', tenantId);
expect((result as any).completion_percentage).toBe(0);
});
});
});

View File

@ -0,0 +1,274 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { 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 { tasksService } from '../tasks.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('TasksService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return tasks with pagination', async () => {
const mockTasks = [
createMockTask({ id: '1', name: 'Task 1' }),
createMockTask({ id: '2', name: 'Task 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockTasks);
const result = await tasksService.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 tasksService.findAll(tenantId, { project_id: 'project-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('t.project_id = $'),
expect.arrayContaining([tenantId, 'project-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await tasksService.findAll(tenantId, { status: 'in_progress' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('t.status = $'),
expect.arrayContaining([tenantId, 'in_progress'])
);
});
it('should filter by assigned_to', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await tasksService.findAll(tenantId, { assigned_to: 'user-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('t.assigned_to = $'),
expect.arrayContaining([tenantId, 'user-uuid'])
);
});
it('should filter by priority', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await tasksService.findAll(tenantId, { priority: 'high' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('t.priority = $'),
expect.arrayContaining([tenantId, 'high'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await tasksService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('t.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
});
describe('findById', () => {
it('should return task when found', async () => {
const mockTask = createMockTask();
mockQueryOne.mockResolvedValue(mockTask);
const result = await tasksService.findById('task-uuid-1', tenantId);
expect(result).toEqual(mockTask);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
tasksService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
project_id: 'project-uuid',
name: 'New Task',
};
it('should create task with auto-generated sequence', async () => {
const createdTask = createMockTask({ ...createDto, sequence: 1 });
mockQueryOne
.mockResolvedValueOnce({ max_seq: 1 }) // sequence
.mockResolvedValueOnce(createdTask); // INSERT
const result = await tasksService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
it('should create task with default priority', async () => {
const createdTask = createMockTask({ ...createDto, priority: 'normal' });
mockQueryOne
.mockResolvedValueOnce({ max_seq: 1 })
.mockResolvedValueOnce(createdTask);
const result = await tasksService.create(createDto, tenantId, userId);
expect(result.priority).toBe('normal');
});
});
describe('update', () => {
it('should update task successfully', async () => {
const existingTask = createMockTask();
mockQueryOne.mockResolvedValue(existingTask);
mockQuery.mockResolvedValue([]);
await tasksService.update(
'task-uuid-1',
{ name: 'Updated Task' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE projects.tasks SET'),
expect.any(Array)
);
});
it('should throw ValidationError when setting task as its own parent', async () => {
const existingTask = createMockTask();
mockQueryOne.mockResolvedValue(existingTask);
await expect(
tasksService.update('task-uuid-1', { parent_id: 'task-uuid-1' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should return unchanged task when no fields to update', async () => {
const existingTask = createMockTask();
mockQueryOne.mockResolvedValue(existingTask);
const result = await tasksService.update('task-uuid-1', {}, tenantId, userId);
expect(result.id).toBe(existingTask.id);
});
it('should update task status', async () => {
const existingTask = createMockTask({ status: 'todo' });
mockQueryOne.mockResolvedValue(existingTask);
mockQuery.mockResolvedValue([]);
await tasksService.update('task-uuid-1', { status: 'done' }, tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('status = $'),
expect.arrayContaining(['done'])
);
});
});
describe('delete', () => {
it('should soft delete task', async () => {
const task = createMockTask();
mockQueryOne.mockResolvedValue(task);
mockQuery.mockResolvedValue([]);
await tasksService.delete('task-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('deleted_at = CURRENT_TIMESTAMP'),
expect.any(Array)
);
});
it('should throw NotFoundError when task not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
tasksService.delete('nonexistent-id', tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
describe('move', () => {
it('should move task to new stage and position', async () => {
const task = createMockTask();
mockQueryOne.mockResolvedValue(task);
mockQuery.mockResolvedValue([]);
await tasksService.move('task-uuid-1', 'new-stage-uuid', 5, tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1, sequence = $2'),
expect.arrayContaining(['new-stage-uuid', 5, userId])
);
});
it('should move task to no stage (null)', async () => {
const task = createMockTask();
mockQueryOne.mockResolvedValue(task);
mockQuery.mockResolvedValue([]);
await tasksService.move('task-uuid-1', null, 1, tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1'),
expect.arrayContaining([null, 1, userId])
);
});
});
describe('assign', () => {
it('should assign task to user', async () => {
const task = createMockTask();
mockQueryOne.mockResolvedValue(task);
mockQuery.mockResolvedValue([]);
await tasksService.assign('task-uuid-1', 'new-user-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('assigned_to = $1'),
expect.arrayContaining(['new-user-uuid', userId])
);
});
it('should throw NotFoundError when task not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
tasksService.assign('nonexistent-id', 'user-uuid', tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
});

View File

@ -0,0 +1,388 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockPurchaseOrder, createMockPurchaseOrderLine } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { purchasesService } from '../purchases.service.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
describe('PurchasesService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return purchase orders with pagination', async () => {
const mockOrders = [
createMockPurchaseOrder({ id: '1', name: 'PO-000001' }),
createMockPurchaseOrder({ id: '2', name: 'PO-000002' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockOrders);
const result = await purchasesService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by company_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('po.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by partner_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { partner_id: 'partner-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('po.partner_id = $'),
expect.arrayContaining([tenantId, 'partner-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { status: 'draft' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('po.status = $'),
expect.arrayContaining([tenantId, 'draft'])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('po.order_date >= $'),
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { search: 'PO-001' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('po.name ILIKE'),
expect.arrayContaining([tenantId, '%PO-001%'])
);
});
it('should apply pagination correctly', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await purchasesService.findAll(tenantId, { page: 3, limit: 10 });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([10, 20])
);
});
});
describe('findById', () => {
it('should return purchase order with lines when found', async () => {
const mockOrder = createMockPurchaseOrder();
const mockLines = [createMockPurchaseOrderLine()];
mockQueryOne.mockResolvedValue(mockOrder);
mockQuery.mockResolvedValue(mockLines);
const result = await purchasesService.findById('po-uuid-1', tenantId);
expect(result).toEqual({ ...mockOrder, lines: mockLines });
});
it('should throw NotFoundError when purchase order not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
purchasesService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'PO-000001',
partner_id: 'partner-uuid',
order_date: '2024-06-15',
currency_id: 'currency-uuid',
lines: [
{
product_id: 'product-uuid',
description: 'Test product',
quantity: 10,
uom_id: 'uom-uuid',
price_unit: 100,
amount_untaxed: 1000,
},
],
};
it('should create purchase order with lines', async () => {
const mockOrder = createMockPurchaseOrder();
const mockLines = [createMockPurchaseOrderLine()];
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [mockOrder] }) // INSERT order
.mockResolvedValueOnce(undefined) // INSERT line
.mockResolvedValueOnce(undefined); // COMMIT
mockQueryOne.mockResolvedValue({ ...mockOrder, lines: mockLines });
mockQuery.mockResolvedValue(mockLines);
const result = await purchasesService.create(createDto, tenantId, userId);
expect(result.name).toBe(mockOrder.name);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when no lines provided', async () => {
const emptyDto = { ...createDto, lines: [] };
await expect(
purchasesService.create(emptyDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // INSERT fails
await expect(
purchasesService.create(createDto, tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update purchase order in draft status', async () => {
const existingOrder = createMockPurchaseOrder({ status: 'draft' });
const mockLines = [createMockPurchaseOrderLine()];
mockQueryOne.mockResolvedValue(existingOrder);
mockQuery.mockResolvedValue(mockLines);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce(undefined) // UPDATE
.mockResolvedValueOnce(undefined); // COMMIT
await purchasesService.update(
'po-uuid-1',
{ partner_id: 'new-partner-uuid' },
tenantId,
userId
);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ConflictError when order is not draft', async () => {
const confirmedOrder = createMockPurchaseOrder({ status: 'confirmed' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.update('po-uuid-1', { partner_id: 'new-partner' }, tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should update lines when provided', async () => {
const existingOrder = createMockPurchaseOrder({ status: 'draft' });
const mockLines = [createMockPurchaseOrderLine()];
mockQueryOne.mockResolvedValue(existingOrder);
mockQuery.mockResolvedValue(mockLines);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce(undefined) // UPDATE
.mockResolvedValueOnce(undefined) // DELETE lines
.mockResolvedValueOnce(undefined) // INSERT line
.mockResolvedValueOnce(undefined) // UPDATE totals
.mockResolvedValueOnce(undefined); // COMMIT
await purchasesService.update(
'po-uuid-1',
{
lines: [
{
product_id: 'product-uuid',
description: 'Updated product',
quantity: 20,
uom_id: 'uom-uuid',
price_unit: 150,
amount_untaxed: 3000,
},
],
},
tenantId,
userId
);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM purchase.purchase_order_lines'),
expect.any(Array)
);
});
});
describe('confirm', () => {
it('should confirm draft order with lines', async () => {
const draftOrder = createMockPurchaseOrder({
status: 'draft',
lines: [createMockPurchaseOrderLine()],
});
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([createMockPurchaseOrderLine()]);
await purchasesService.confirm('po-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'confirmed'"),
expect.any(Array)
);
});
it('should throw ConflictError when order is not draft', async () => {
const confirmedOrder = createMockPurchaseOrder({ status: 'confirmed' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.confirm('po-uuid-1', tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should throw ValidationError when order has no lines', async () => {
const draftOrder = createMockPurchaseOrder({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.confirm('po-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('cancel', () => {
it('should cancel draft order', async () => {
const draftOrder = createMockPurchaseOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await purchasesService.cancel('po-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should cancel confirmed order', async () => {
const confirmedOrder = createMockPurchaseOrder({ status: 'confirmed' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await purchasesService.cancel('po-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should throw ConflictError when order is already cancelled', async () => {
const cancelledOrder = createMockPurchaseOrder({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.cancel('po-uuid-1', tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should throw ConflictError when order is done', async () => {
const doneOrder = createMockPurchaseOrder({ status: 'done' });
mockQueryOne.mockResolvedValue(doneOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.cancel('po-uuid-1', tenantId, userId)
).rejects.toThrow(ConflictError);
});
});
describe('delete', () => {
it('should delete purchase order in draft status', async () => {
const draftOrder = createMockPurchaseOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await purchasesService.delete('po-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM purchase.purchase_orders'),
expect.any(Array)
);
});
it('should throw ConflictError when order is not draft', async () => {
const confirmedOrder = createMockPurchaseOrder({ status: 'confirmed' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
purchasesService.delete('po-uuid-1', tenantId)
).rejects.toThrow(ConflictError);
});
});
});

View File

@ -0,0 +1,551 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRfq, createMockRfqLine } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { rfqsService } from '../rfqs.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('RfqsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return RFQs with pagination', async () => {
const mockRfqs = [
createMockRfq({ id: '1', name: 'RFQ-000001' }),
createMockRfq({ id: '2', name: 'RFQ-000002' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockRfqs);
const result = await rfqsService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by company_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await rfqsService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('r.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await rfqsService.findAll(tenantId, { status: 'draft' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('r.status = $'),
expect.arrayContaining([tenantId, 'draft'])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await rfqsService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('r.request_date >= $'),
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await rfqsService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('r.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
});
describe('findById', () => {
it('should return RFQ with lines when found', async () => {
const mockRfq = createMockRfq({ partner_ids: ['partner-1'] });
const mockPartners = [{ id: 'partner-1', name: 'Partner 1' }];
const mockLines = [createMockRfqLine()];
mockQueryOne.mockResolvedValue(mockRfq);
mockQuery
.mockResolvedValueOnce(mockPartners) // partner names
.mockResolvedValueOnce(mockLines); // lines
const result = await rfqsService.findById('rfq-uuid-1', tenantId);
expect(result.lines).toEqual(mockLines);
expect(result.partner_names).toEqual(['Partner 1']);
});
it('should throw NotFoundError when RFQ not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
rfqsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
partner_ids: ['supplier-uuid-1'],
lines: [
{
product_id: 'product-uuid',
description: 'Test product',
quantity: 10,
uom_id: 'uom-uuid',
},
],
};
it('should create RFQ with auto-generated number', async () => {
const mockRfq = createMockRfq({ name: 'RFQ-000001' });
const mockLines = [createMockRfqLine()];
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
.mockResolvedValueOnce({ rows: [mockRfq] }) // INSERT RFQ
.mockResolvedValueOnce(undefined) // INSERT line
.mockResolvedValueOnce(undefined); // COMMIT
mockQueryOne.mockResolvedValue(mockRfq);
mockQuery.mockResolvedValue(mockLines);
const result = await rfqsService.create(createDto, tenantId, userId);
expect(result.name).toBe('RFQ-000001');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when no lines provided', async () => {
const emptyDto = { ...createDto, lines: [] };
await expect(
rfqsService.create(emptyDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when no suppliers provided', async () => {
const noSuppliersDto = { ...createDto, partner_ids: [] };
await expect(
rfqsService.create(noSuppliersDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // sequence fails
await expect(
rfqsService.create(createDto, tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update RFQ in draft status', async () => {
const existingRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.update(
'rfq-uuid-1',
{ partner_ids: ['new-supplier-uuid'] },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE purchase.rfqs SET'),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.update('rfq-uuid-1', { partner_ids: ['new-supplier'] }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should return unchanged RFQ when no fields to update', async () => {
const existingRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingRfq);
mockQuery.mockResolvedValue([]);
const result = await rfqsService.update('rfq-uuid-1', {}, tenantId, userId);
expect(result.id).toBe(existingRfq.id);
});
});
describe('addLine', () => {
const lineDto = {
product_id: 'product-uuid',
description: 'Test product',
quantity: 5,
uom_id: 'uom-uuid',
};
it('should add line to draft RFQ', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
const newLine = createMockRfqLine();
mockQueryOne
.mockResolvedValueOnce(draftRfq) // findById
.mockResolvedValueOnce(newLine); // INSERT line
mockQuery.mockResolvedValue([]);
const result = await rfqsService.addLine('rfq-uuid-1', lineDto, tenantId);
expect(result.id).toBe(newLine.id);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.addLine('rfq-uuid-1', lineDto, tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('updateLine', () => {
it('should update line in draft RFQ', async () => {
const existingLine = createMockRfqLine({ id: 'line-uuid' });
const draftRfq = createMockRfq({ status: 'draft' });
const updatedLine = { ...existingLine, quantity: 20 };
// findById: queryOne for rfq, query for partners (if partner_ids has values), query for lines
// updateLine: queryOne for UPDATE ... RETURNING
mockQueryOne
.mockResolvedValueOnce(draftRfq) // findById - get rfq
.mockResolvedValueOnce(updatedLine); // UPDATE line RETURNING
mockQuery
.mockResolvedValueOnce([{ id: 'supplier-uuid-1', name: 'Test Supplier' }]) // findById - get partners
.mockResolvedValueOnce([existingLine]); // findById - get lines (must return existing line)
const result = await rfqsService.updateLine('rfq-uuid-1', 'line-uuid', { quantity: 20 }, tenantId);
expect(result.quantity).toBe(20);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.updateLine('rfq-uuid-1', 'line-uuid', { quantity: 20 }, tenantId)
).rejects.toThrow(ValidationError);
});
it('should throw NotFoundError when line not found', async () => {
const draftRfq = createMockRfq({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.updateLine('rfq-uuid-1', 'nonexistent-line', { quantity: 20 }, tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('removeLine', () => {
it('should remove line from draft RFQ', async () => {
const line1 = createMockRfqLine({ id: 'line-1' });
const line2 = createMockRfqLine({ id: 'line-2' });
const draftRfq = createMockRfq({ status: 'draft', lines: [line1, line2] });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([line1, line2]);
await rfqsService.removeLine('rfq-uuid-1', 'line-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM purchase.rfq_lines'),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.removeLine('rfq-uuid-1', 'line-uuid', tenantId)
).rejects.toThrow(ValidationError);
});
it('should throw NotFoundError when line not found', async () => {
const draftRfq = createMockRfq({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.removeLine('rfq-uuid-1', 'nonexistent-line', tenantId)
).rejects.toThrow(NotFoundError);
});
it('should throw ValidationError when trying to remove the last line', async () => {
const line = createMockRfqLine({ id: 'line-uuid' });
const draftRfq = createMockRfq({ status: 'draft', lines: [line] });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([line]);
await expect(
rfqsService.removeLine('rfq-uuid-1', 'line-uuid', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('send', () => {
it('should send draft RFQ with lines', async () => {
const draftRfq = createMockRfq({
status: 'draft',
lines: [createMockRfqLine()],
});
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([createMockRfqLine()]);
await rfqsService.send('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'sent'"),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.send('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when RFQ has no lines', async () => {
const draftRfq = createMockRfq({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.send('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('markResponded', () => {
it('should mark sent RFQ as responded', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.markResponded('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'responded'"),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is not sent', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.markResponded('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('accept', () => {
it('should accept sent RFQ', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.accept('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'accepted'"),
expect.any(Array)
);
});
it('should accept responded RFQ', async () => {
const respondedRfq = createMockRfq({ status: 'responded' });
mockQueryOne.mockResolvedValue(respondedRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.accept('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'accepted'"),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is draft', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.accept('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('reject', () => {
it('should reject sent RFQ', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.reject('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'rejected'"),
expect.any(Array)
);
});
it('should reject responded RFQ', async () => {
const respondedRfq = createMockRfq({ status: 'responded' });
mockQueryOne.mockResolvedValue(respondedRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.reject('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'rejected'"),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is draft', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.reject('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('cancel', () => {
it('should cancel draft RFQ', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.cancel('rfq-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is already cancelled', async () => {
const cancelledRfq = createMockRfq({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.cancel('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when RFQ is accepted', async () => {
const acceptedRfq = createMockRfq({ status: 'accepted' });
mockQueryOne.mockResolvedValue(acceptedRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.cancel('rfq-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('delete', () => {
it('should delete RFQ in draft status', async () => {
const draftRfq = createMockRfq({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftRfq);
mockQuery.mockResolvedValue([]);
await rfqsService.delete('rfq-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM purchase.rfqs'),
expect.any(Array)
);
});
it('should throw ValidationError when RFQ is not draft', async () => {
const sentRfq = createMockRfq({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentRfq);
mockQuery.mockResolvedValue([]);
await expect(
rfqsService.delete('rfq-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
});
});

View File

@ -0,0 +1,583 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockSalesOrder, createMockSalesOrderLine } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Mock taxesService
jest.mock('../../financial/taxes.service.js', () => ({
taxesService: {
calculateTaxes: jest.fn(() => Promise.resolve({
amountUntaxed: 1000,
amountTax: 160,
amountTotal: 1160,
})),
},
}));
// Mock sequencesService
jest.mock('../../core/sequences.service.js', () => ({
sequencesService: {
getNextNumber: jest.fn(() => Promise.resolve('SO-000001')),
},
SEQUENCE_CODES: {
SALES_ORDER: 'SO',
},
}));
// Import after mocking
import { ordersService } from '../orders.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('OrdersService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return orders with pagination', async () => {
const mockOrders = [
createMockSalesOrder({ id: '1', name: 'SO-000001' }),
createMockSalesOrder({ id: '2', name: 'SO-000002' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockOrders);
const result = await ordersService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by company_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by partner_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { partner_id: 'partner-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.partner_id = $'),
expect.arrayContaining([tenantId, 'partner-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { status: 'draft' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.status = $'),
expect.arrayContaining([tenantId, 'draft'])
);
});
it('should filter by invoice_status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { invoice_status: 'pending' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.invoice_status = $'),
expect.arrayContaining([tenantId, 'pending'])
);
});
it('should filter by delivery_status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { delivery_status: 'pending' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.delivery_status = $'),
expect.arrayContaining([tenantId, 'pending'])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.order_date >= $'),
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('so.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
it('should apply pagination correctly', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await ordersService.findAll(tenantId, { page: 3, limit: 10 });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
);
});
});
describe('findById', () => {
it('should return order with lines when found', async () => {
const mockOrder = createMockSalesOrder();
const mockLines = [createMockSalesOrderLine()];
mockQueryOne.mockResolvedValue(mockOrder);
mockQuery.mockResolvedValue(mockLines);
const result = await ordersService.findById('order-uuid-1', tenantId);
expect(result).toEqual({ ...mockOrder, lines: mockLines });
});
it('should throw NotFoundError when order not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
ordersService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
partner_id: 'partner-uuid',
currency_id: 'currency-uuid',
};
it('should create order with auto-generated number', async () => {
mockQueryOne.mockResolvedValue(createMockSalesOrder({ name: 'SO-000001' }));
const result = await ordersService.create(createDto, tenantId, userId);
expect(result.name).toBe('SO-000001');
});
it('should use provided order_date', async () => {
mockQueryOne.mockResolvedValue(createMockSalesOrder());
await ordersService.create({ ...createDto, order_date: '2024-06-15' }, tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO sales.sales_orders'),
expect.arrayContaining(['2024-06-15'])
);
});
it('should set default invoice_policy to order', async () => {
mockQueryOne.mockResolvedValue(createMockSalesOrder());
await ordersService.create(createDto, tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO sales.sales_orders'),
expect.arrayContaining(['order'])
);
});
});
describe('update', () => {
it('should update order in draft status', async () => {
const existingOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingOrder);
mockQuery.mockResolvedValue([]);
await ordersService.update(
'order-uuid-1',
{ partner_id: 'new-partner-uuid' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE sales.sales_orders SET'),
expect.any(Array)
);
});
it('should throw ValidationError when order is not draft', async () => {
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.update('order-uuid-1', { partner_id: 'new-partner' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should return unchanged order when no fields to update', async () => {
const existingOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingOrder);
mockQuery.mockResolvedValue([]);
const result = await ordersService.update(
'order-uuid-1',
{},
tenantId,
userId
);
expect(result.id).toBe(existingOrder.id);
});
});
describe('delete', () => {
it('should delete order in draft status', async () => {
const draftOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await ordersService.delete('order-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM sales.sales_orders'),
expect.any(Array)
);
});
it('should throw ValidationError when order is not draft', async () => {
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.delete('order-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('addLine', () => {
const lineDto = {
product_id: 'product-uuid',
description: 'Test product',
quantity: 5,
uom_id: 'uom-uuid',
price_unit: 100,
};
it('should add line to draft order', async () => {
const draftOrder = createMockSalesOrder({ status: 'draft' });
const newLine = createMockSalesOrderLine();
// findById: queryOne for order, query for lines
// addLine: queryOne for INSERT, query for updateTotals
mockQueryOne
.mockResolvedValueOnce(draftOrder) // findById - get order
.mockResolvedValueOnce(newLine); // INSERT line
mockQuery
.mockResolvedValueOnce([]) // findById - get lines
.mockResolvedValueOnce([]); // updateTotals
const result = await ordersService.addLine('order-uuid-1', lineDto, tenantId, userId);
expect(result.id).toBe(newLine.id);
});
it('should throw ValidationError when order is not draft', async () => {
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.addLine('order-uuid-1', lineDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('removeLine', () => {
it('should remove line from draft order', async () => {
const draftOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await ordersService.removeLine('order-uuid-1', 'line-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM sales.sales_order_lines'),
expect.any(Array)
);
});
it('should throw ValidationError when order is not draft', async () => {
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.removeLine('order-uuid-1', 'line-uuid', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('confirm', () => {
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
mockGetClient.mockResolvedValue(mockClient);
});
it('should confirm draft order with lines', async () => {
const order = createMockSalesOrder({
status: 'draft',
lines: [createMockSalesOrderLine()],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce(undefined) // UPDATE status
.mockResolvedValueOnce(undefined); // COMMIT
const result = await ordersService.confirm('order-uuid-1', tenantId, userId);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when order is not draft', async () => {
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.confirm('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when order has no lines', async () => {
const order = createMockSalesOrder({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.confirm('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const order = createMockSalesOrder({
status: 'draft',
lines: [createMockSalesOrderLine()],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // UPDATE fails
await expect(
ordersService.confirm('order-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
describe('cancel', () => {
it('should cancel draft order', async () => {
const draftOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await ordersService.cancel('order-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should throw ValidationError when order is done', async () => {
const doneOrder = createMockSalesOrder({ status: 'done' });
mockQueryOne.mockResolvedValue(doneOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when order is already cancelled', async () => {
const cancelledOrder = createMockSalesOrder({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when order has deliveries', async () => {
const orderWithDeliveries = createMockSalesOrder({
status: 'sent',
delivery_status: 'partial',
});
mockQueryOne.mockResolvedValue(orderWithDeliveries);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when order has invoices', async () => {
const orderWithInvoices = createMockSalesOrder({
status: 'sent',
invoice_status: 'partial',
});
mockQueryOne.mockResolvedValue(orderWithInvoices);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('createInvoice', () => {
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
mockGetClient.mockResolvedValue(mockClient);
});
it('should create invoice from confirmed order', async () => {
const order = createMockSalesOrder({
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
.mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] }) // INSERT invoice
.mockResolvedValueOnce(undefined) // INSERT line
.mockResolvedValueOnce(undefined) // UPDATE qty_invoiced
.mockResolvedValueOnce(undefined) // UPDATE invoice totals
.mockResolvedValueOnce(undefined) // UPDATE order status
.mockResolvedValueOnce(undefined); // COMMIT
const result = await ordersService.createInvoice('order-uuid-1', tenantId, userId);
expect(result.invoiceId).toBe('invoice-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when order is draft', async () => {
const draftOrder = createMockSalesOrder({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.createInvoice('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when order is fully invoiced', async () => {
const fullyInvoicedOrder = createMockSalesOrder({
status: 'sent',
invoice_status: 'invoiced',
});
mockQueryOne.mockResolvedValue(fullyInvoicedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.createInvoice('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when no lines to invoice', async () => {
const order = createMockSalesOrder({
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 10 })],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 10 })]);
await expect(
ordersService.createInvoice('order-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const order = createMockSalesOrder({
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // sequence fails
await expect(
ordersService.createInvoice('order-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,476 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockQuotation, createMockQuotationLine } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Mock taxesService
jest.mock('../../financial/taxes.service.js', () => ({
taxesService: {
calculateTaxes: jest.fn(() => Promise.resolve({
amountUntaxed: 1000,
amountTax: 160,
amountTotal: 1160,
})),
},
}));
// Import after mocking
import { quotationsService } from '../quotations.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('QuotationsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return quotations with pagination', async () => {
const mockQuotations = [
createMockQuotation({ id: '1', name: 'QUO-000001' }),
createMockQuotation({ id: '2', name: 'QUO-000002' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockQuotations);
const result = await quotationsService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by company_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('q.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by partner_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { partner_id: 'partner-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('q.partner_id = $'),
expect.arrayContaining([tenantId, 'partner-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { status: 'draft' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('q.status = $'),
expect.arrayContaining([tenantId, 'draft'])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('q.quotation_date >= $'),
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('q.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
it('should apply pagination correctly', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await quotationsService.findAll(tenantId, { page: 3, limit: 10 });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
);
});
});
describe('findById', () => {
it('should return quotation with lines when found', async () => {
const mockQuotation = createMockQuotation();
const mockLines = [createMockQuotationLine()];
mockQueryOne.mockResolvedValue(mockQuotation);
mockQuery.mockResolvedValue(mockLines);
const result = await quotationsService.findById('quotation-uuid-1', tenantId);
expect(result).toEqual({ ...mockQuotation, lines: mockLines });
});
it('should throw NotFoundError when quotation not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
quotationsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
partner_id: 'partner-uuid',
validity_date: '2024-12-31',
currency_id: 'currency-uuid',
};
it('should create quotation with auto-generated number', async () => {
mockQueryOne
.mockResolvedValueOnce({ next_num: 1 }) // sequence
.mockResolvedValueOnce(createMockQuotation({ name: 'QUO-000001' })); // INSERT
const result = await quotationsService.create(createDto, tenantId, userId);
expect(result.name).toBe('QUO-000001');
});
it('should use provided quotation_date', async () => {
mockQueryOne
.mockResolvedValueOnce({ next_num: 2 })
.mockResolvedValueOnce(createMockQuotation());
await quotationsService.create({ ...createDto, quotation_date: '2024-06-15' }, tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO sales.quotations'),
expect.arrayContaining(['2024-06-15'])
);
});
});
describe('update', () => {
it('should update quotation in draft status', async () => {
const existingQuotation = createMockQuotation({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingQuotation);
mockQuery.mockResolvedValue([]);
await quotationsService.update(
'quotation-uuid-1',
{ partner_id: 'new-partner-uuid' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE sales.quotations SET'),
expect.any(Array)
);
});
it('should throw ValidationError when quotation is not draft', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.update('quotation-uuid-1', { partner_id: 'new-partner' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should return unchanged quotation when no fields to update', async () => {
const existingQuotation = createMockQuotation({ status: 'draft' });
mockQueryOne.mockResolvedValue(existingQuotation);
mockQuery.mockResolvedValue([]);
const result = await quotationsService.update(
'quotation-uuid-1',
{},
tenantId,
userId
);
expect(result.id).toBe(existingQuotation.id);
});
});
describe('delete', () => {
it('should delete quotation in draft status', async () => {
const draftQuotation = createMockQuotation({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery.mockResolvedValue([]);
await quotationsService.delete('quotation-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM sales.quotations'),
expect.any(Array)
);
});
it('should throw ValidationError when quotation is not draft', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.delete('quotation-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('addLine', () => {
const lineDto = {
product_id: 'product-uuid',
description: 'Test product',
quantity: 5,
uom_id: 'uom-uuid',
price_unit: 100,
};
it('should add line to draft quotation', async () => {
const draftQuotation = createMockQuotation({ status: 'draft' });
const newLine = createMockQuotationLine();
// findById: queryOne for quotation, query for lines
// addLine: queryOne for INSERT, query for updateTotals
mockQueryOne
.mockResolvedValueOnce(draftQuotation) // findById - get quotation
.mockResolvedValueOnce(newLine); // INSERT line
mockQuery
.mockResolvedValueOnce([]) // findById - get lines
.mockResolvedValueOnce([]); // updateTotals
const result = await quotationsService.addLine('quotation-uuid-1', lineDto, tenantId, userId);
expect(result.id).toBe(newLine.id);
});
it('should throw ValidationError when quotation is not draft', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.addLine('quotation-uuid-1', lineDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('removeLine', () => {
it('should remove line from draft quotation', async () => {
const draftQuotation = createMockQuotation({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery.mockResolvedValue([]);
await quotationsService.removeLine('quotation-uuid-1', 'line-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM sales.quotation_lines'),
expect.any(Array)
);
});
it('should throw ValidationError when quotation is not draft', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.removeLine('quotation-uuid-1', 'line-uuid', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('send', () => {
it('should send draft quotation with lines', async () => {
const draftQuotation = createMockQuotation({ status: 'draft' });
const mockLines = [createMockQuotationLine()];
// findById: queryOne for quotation, query for lines
// send: query for UPDATE
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery
.mockResolvedValueOnce(mockLines) // findById - get lines
.mockResolvedValueOnce([]); // UPDATE status
await quotationsService.send('quotation-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'sent'"),
expect.any(Array)
);
});
it('should throw ValidationError when quotation is not draft', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.send('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when quotation has no lines', async () => {
const draftQuotation = createMockQuotation({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.send('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('confirm', () => {
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
mockGetClient.mockResolvedValue(mockClient);
});
it('should confirm quotation and create sales order', async () => {
const quotation = createMockQuotation({
status: 'draft',
lines: [createMockQuotationLine()],
});
mockQueryOne.mockResolvedValue(quotation);
mockQuery.mockResolvedValue([createMockQuotationLine()]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
.mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] }) // INSERT order
.mockResolvedValueOnce(undefined) // INSERT lines
.mockResolvedValueOnce(undefined) // UPDATE quotation
.mockResolvedValueOnce(undefined); // COMMIT
const result = await quotationsService.confirm('quotation-uuid-1', tenantId, userId);
expect(result.orderId).toBe('order-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when quotation status is invalid', async () => {
const cancelledQuotation = createMockQuotation({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when quotation has no lines', async () => {
const quotation = createMockQuotation({ status: 'draft', lines: [] });
mockQueryOne.mockResolvedValue(quotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const quotation = createMockQuotation({
status: 'draft',
lines: [createMockQuotationLine()],
});
mockQueryOne.mockResolvedValue(quotation);
mockQuery.mockResolvedValue([createMockQuotationLine()]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // sequence fails
await expect(
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
describe('cancel', () => {
it('should cancel draft quotation', async () => {
const draftQuotation = createMockQuotation({ status: 'draft' });
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery.mockResolvedValue([]);
await quotationsService.cancel('quotation-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should cancel sent quotation', async () => {
const sentQuotation = createMockQuotation({ status: 'sent' });
mockQueryOne.mockResolvedValue(sentQuotation);
mockQuery.mockResolvedValue([]);
await quotationsService.cancel('quotation-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should throw ValidationError when quotation is confirmed', async () => {
const confirmedQuotation = createMockQuotation({ status: 'confirmed' });
mockQueryOne.mockResolvedValue(confirmedQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.cancel('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when quotation is already cancelled', async () => {
const cancelledQuotation = createMockQuotation({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.cancel('quotation-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
});