[TASK-007] test: Add sales integration and unit tests

Add comprehensive test coverage for the Sales module:
- pricelists.service.test.ts: Unit tests for pricelist CRUD and pricing
- sales-flow.integration.test.ts: Integration tests for Order-to-Cash flow

Test scenarios cover:
1. Quotation -> Approval -> Sales Order conversion
2. Sales Order -> Picking -> Delivery confirmation
3. Delivery -> Invoice generation
4. Order cancellation and stock release
5. Pricelist application and discounts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 18:46:01 -06:00
parent 56f5663583
commit 968b64c773
2 changed files with 1534 additions and 0 deletions

View File

@ -0,0 +1,561 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
// 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),
getClient: jest.fn(),
}));
// Import after mocking
import { pricelistsService } from '../pricelists.service.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
// Helper to create mock pricelist
function createMockPricelist(overrides: Record<string, any> = {}) {
return {
id: 'pricelist-uuid-1',
tenant_id: 'test-tenant-uuid',
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Standard Pricelist',
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
active: true,
items: [],
created_at: new Date(),
...overrides,
};
}
// Helper to create mock pricelist item
function createMockPricelistItem(overrides: Record<string, any> = {}) {
return {
id: 'pricelist-item-uuid-1',
pricelist_id: 'pricelist-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
product_category_id: null,
category_name: null,
price: 100,
min_quantity: 1,
valid_from: null,
valid_to: null,
active: true,
...overrides,
};
}
describe('PricelistsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return pricelists with pagination', async () => {
const mockPricelists = [
createMockPricelist({ id: '1', name: 'Standard' }),
createMockPricelist({ id: '2', name: 'VIP' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockPricelists);
const result = await pricelistsService.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 pricelistsService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by active status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await pricelistsService.findAll(tenantId, { active: true });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.active = $'),
expect.arrayContaining([tenantId, true])
);
});
it('should filter by inactive status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await pricelistsService.findAll(tenantId, { active: false });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('p.active = $'),
expect.arrayContaining([tenantId, false])
);
});
it('should apply pagination correctly', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await pricelistsService.findAll(tenantId, { page: 3, limit: 10 });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
);
});
it('should use default pagination when not provided', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await pricelistsService.findAll(tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([20, 0]) // default limit=20, offset=0
);
});
});
describe('findById', () => {
it('should return pricelist with items when found', async () => {
const mockPricelist = createMockPricelist();
const mockItems = [createMockPricelistItem()];
mockQueryOne.mockResolvedValue(mockPricelist);
mockQuery.mockResolvedValue(mockItems);
const result = await pricelistsService.findById('pricelist-uuid-1', tenantId);
expect(result).toEqual({ ...mockPricelist, items: mockItems });
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('WHERE p.id = $1 AND p.tenant_id = $2'),
['pricelist-uuid-1', tenantId]
);
});
it('should throw NotFoundError when pricelist not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
pricelistsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
it('should return empty items array when no items exist', async () => {
const mockPricelist = createMockPricelist();
mockQueryOne.mockResolvedValue(mockPricelist);
mockQuery.mockResolvedValue([]);
const result = await pricelistsService.findById('pricelist-uuid-1', tenantId);
expect(result.items).toEqual([]);
});
});
describe('create', () => {
const createDto = {
name: 'New Pricelist',
currency_id: 'currency-uuid',
};
it('should create pricelist successfully', async () => {
mockQueryOne
.mockResolvedValueOnce(null) // unique name check
.mockResolvedValueOnce(createMockPricelist({ name: 'New Pricelist' })); // INSERT
const result = await pricelistsService.create(createDto, tenantId, userId);
expect(result.name).toBe('New Pricelist');
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO sales.pricelists'),
expect.any(Array)
);
});
it('should create pricelist with company_id', async () => {
const dtoWithCompany = { ...createDto, company_id: 'company-uuid' };
mockQueryOne
.mockResolvedValueOnce(null) // unique name check
.mockResolvedValueOnce(createMockPricelist({ name: 'New Pricelist', company_id: 'company-uuid' }));
await pricelistsService.create(dtoWithCompany, tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO sales.pricelists'),
expect.arrayContaining(['company-uuid'])
);
});
it('should throw ConflictError when name already exists', async () => {
mockQueryOne.mockResolvedValueOnce({ id: 'existing-id' }); // name exists
await expect(
pricelistsService.create(createDto, tenantId, userId)
).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('should update pricelist name', async () => {
const existingPricelist = createMockPricelist();
mockQueryOne
.mockResolvedValueOnce(existingPricelist) // findById
.mockResolvedValueOnce(null) // unique name check
.mockResolvedValueOnce(existingPricelist); // findById after update
mockQuery
.mockResolvedValueOnce([]) // items for findById
.mockResolvedValueOnce(undefined) // UPDATE
.mockResolvedValueOnce([]); // items for findById after update
await pricelistsService.update(
'pricelist-uuid-1',
{ name: 'Updated Pricelist' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE sales.pricelists SET'),
expect.any(Array)
);
});
it('should update pricelist currency_id', async () => {
const existingPricelist = createMockPricelist();
mockQueryOne
.mockResolvedValueOnce(existingPricelist)
.mockResolvedValueOnce(existingPricelist);
mockQuery
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
await pricelistsService.update(
'pricelist-uuid-1',
{ currency_id: 'new-currency-uuid' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('currency_id'),
expect.arrayContaining(['new-currency-uuid'])
);
});
it('should update pricelist active status', async () => {
const existingPricelist = createMockPricelist();
mockQueryOne
.mockResolvedValueOnce(existingPricelist)
.mockResolvedValueOnce(existingPricelist);
mockQuery
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
await pricelistsService.update(
'pricelist-uuid-1',
{ active: false },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('active'),
expect.arrayContaining([false])
);
});
it('should throw ConflictError when updating to existing name', async () => {
const existingPricelist = createMockPricelist();
mockQueryOne
.mockResolvedValueOnce(existingPricelist) // findById
.mockResolvedValueOnce({ id: 'other-pricelist' }); // name conflict
mockQuery.mockResolvedValue([]);
await expect(
pricelistsService.update('pricelist-uuid-1', { name: 'Existing Name' }, tenantId, userId)
).rejects.toThrow(ConflictError);
});
it('should throw NotFoundError when pricelist not found', async () => {
mockQueryOne.mockResolvedValueOnce(null);
await expect(
pricelistsService.update('nonexistent-id', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
describe('addItem', () => {
const itemDto = {
product_id: 'product-uuid',
price: 150,
min_quantity: 10,
};
it('should add item with product_id', async () => {
const pricelist = createMockPricelist();
const newItem = createMockPricelistItem({ product_id: 'product-uuid', price: 150 });
mockQueryOne
.mockResolvedValueOnce(pricelist) // findById
.mockResolvedValueOnce(newItem); // INSERT
mockQuery.mockResolvedValue([]); // items for findById
const result = await pricelistsService.addItem('pricelist-uuid-1', itemDto, tenantId, userId);
expect(result.product_id).toBe('product-uuid');
expect(result.price).toBe(150);
});
it('should add item with product_category_id', async () => {
const categoryItemDto = {
product_category_id: 'category-uuid',
price: 200,
};
const pricelist = createMockPricelist();
const newItem = createMockPricelistItem({ product_category_id: 'category-uuid', price: 200 });
mockQueryOne
.mockResolvedValueOnce(pricelist)
.mockResolvedValueOnce(newItem);
mockQuery.mockResolvedValue([]);
const result = await pricelistsService.addItem('pricelist-uuid-1', categoryItemDto, tenantId, userId);
expect(result.price).toBe(200);
});
it('should add item with validity dates', async () => {
const itemWithDates = {
product_id: 'product-uuid',
price: 100,
valid_from: '2024-01-01',
valid_to: '2024-12-31',
};
const pricelist = createMockPricelist();
const newItem = createMockPricelistItem(itemWithDates);
mockQueryOne
.mockResolvedValueOnce(pricelist)
.mockResolvedValueOnce(newItem);
mockQuery.mockResolvedValue([]);
await pricelistsService.addItem('pricelist-uuid-1', itemWithDates, tenantId, userId);
expect(mockQueryOne).toHaveBeenLastCalledWith(
expect.stringContaining('INSERT INTO sales.pricelist_items'),
expect.arrayContaining(['2024-01-01', '2024-12-31'])
);
});
it('should throw ValidationError when neither product nor category specified', async () => {
const invalidDto = { price: 100 };
const pricelist = createMockPricelist();
mockQueryOne.mockResolvedValueOnce(pricelist);
mockQuery.mockResolvedValue([]);
await expect(
pricelistsService.addItem('pricelist-uuid-1', invalidDto as any, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when both product and category specified', async () => {
const invalidDto = {
product_id: 'product-uuid',
product_category_id: 'category-uuid',
price: 100,
};
const pricelist = createMockPricelist();
mockQueryOne.mockResolvedValueOnce(pricelist);
mockQuery.mockResolvedValue([]);
await expect(
pricelistsService.addItem('pricelist-uuid-1', invalidDto, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw NotFoundError when pricelist not found', async () => {
mockQueryOne.mockResolvedValueOnce(null);
await expect(
pricelistsService.addItem('nonexistent-id', itemDto, tenantId, userId)
).rejects.toThrow(NotFoundError);
});
it('should use default min_quantity of 1', async () => {
const itemWithoutMinQty = {
product_id: 'product-uuid',
price: 100,
};
const pricelist = createMockPricelist();
const newItem = createMockPricelistItem();
mockQueryOne
.mockResolvedValueOnce(pricelist)
.mockResolvedValueOnce(newItem);
mockQuery.mockResolvedValue([]);
await pricelistsService.addItem('pricelist-uuid-1', itemWithoutMinQty, tenantId, userId);
expect(mockQueryOne).toHaveBeenLastCalledWith(
expect.stringContaining('INSERT INTO sales.pricelist_items'),
expect.arrayContaining([1]) // default min_quantity
);
});
});
describe('removeItem', () => {
it('should remove item from pricelist', async () => {
const pricelist = createMockPricelist();
mockQueryOne.mockResolvedValue(pricelist);
mockQuery
.mockResolvedValueOnce([createMockPricelistItem()]) // items for findById
.mockResolvedValueOnce(undefined); // DELETE
await pricelistsService.removeItem('pricelist-uuid-1', 'item-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM sales.pricelist_items'),
['item-uuid', 'pricelist-uuid-1']
);
});
it('should throw NotFoundError when pricelist not found', async () => {
mockQueryOne.mockResolvedValueOnce(null);
await expect(
pricelistsService.removeItem('nonexistent-id', 'item-uuid', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('getProductPrice', () => {
it('should return product-specific price', async () => {
mockQueryOne.mockResolvedValue({ price: 150 });
const result = await pricelistsService.getProductPrice(
'product-uuid',
'pricelist-uuid',
5
);
expect(result).toBe(150);
});
it('should return category price when no product-specific price', async () => {
mockQueryOne.mockResolvedValue({ price: 120 });
const result = await pricelistsService.getProductPrice(
'product-uuid',
'pricelist-uuid',
1
);
expect(result).toBe(120);
});
it('should return null when no price found', async () => {
mockQueryOne.mockResolvedValue(null);
const result = await pricelistsService.getProductPrice(
'product-uuid',
'pricelist-uuid',
1
);
expect(result).toBeNull();
});
it('should use default quantity of 1', async () => {
mockQueryOne.mockResolvedValue({ price: 100 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid');
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('min_quantity <= $3'),
['pricelist-uuid', 'product-uuid', 1]
);
});
it('should filter by min_quantity', async () => {
mockQueryOne.mockResolvedValue({ price: 80 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 100);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('min_quantity <= $3'),
expect.arrayContaining([100])
);
});
it('should filter by valid dates', async () => {
mockQueryOne.mockResolvedValue({ price: 100 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('valid_from IS NULL OR valid_from <= CURRENT_DATE'),
expect.any(Array)
);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('valid_to IS NULL OR valid_to >= CURRENT_DATE'),
expect.any(Array)
);
});
it('should only return active items', async () => {
mockQueryOne.mockResolvedValue({ price: 100 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('active = true'),
expect.any(Array)
);
});
it('should prioritize product-specific over category prices', async () => {
mockQueryOne.mockResolvedValue({ price: 100 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY product_id NULLS LAST'),
expect.any(Array)
);
});
it('should prioritize higher min_quantity', async () => {
mockQueryOne.mockResolvedValue({ price: 100 });
await pricelistsService.getProductPrice('product-uuid', 'pricelist-uuid', 1);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('min_quantity DESC'),
expect.any(Array)
);
});
});
});

View File

@ -0,0 +1,973 @@
/**
* Sales Flow Integration Tests
*
* Tests the complete Order-to-Cash flow:
* 1. Quotation -> Approval -> Sales Order conversion
* 2. Sales Order -> Picking -> Delivery confirmation
* 3. Delivery -> Invoice generation
* 4. Order cancellation and credit note scenarios
* 5. Pricelist application and discounts
*/
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import {
createMockQuotation,
createMockQuotationLine,
createMockSalesOrder,
createMockSalesOrderLine,
} from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
// Create a mock client for transaction handling
const createMockClient = () => ({
query: jest.fn(),
release: 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((code: string) => {
if (code === 'SO') return Promise.resolve('SO-000001');
if (code === 'WH/OUT') return Promise.resolve('WH/OUT/000001');
return Promise.resolve('SEQ-000001');
}),
},
SEQUENCE_CODES: {
SALES_ORDER: 'SO',
PICKING_OUT: 'WH/OUT',
},
}));
// Mock stockReservationService
const mockReserveWithClient = jest.fn();
const mockReleaseWithClient = jest.fn();
jest.mock('../../inventory/stock-reservation.service.js', () => ({
stockReservationService: {
reserveWithClient: (...args: any[]) => mockReserveWithClient(...args),
releaseWithClient: (...args: any[]) => mockReleaseWithClient(...args),
checkAvailability: jest.fn(() => Promise.resolve({ available: true, lines: [] })),
},
}));
// Mock logger
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Import services after mocking
import { quotationsService } from '../quotations.service.js';
import { ordersService } from '../orders.service.js';
import { pricelistsService } from '../pricelists.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('Sales Flow Integration Tests', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const companyId = 'company-uuid-1';
const partnerId = 'partner-uuid-1';
const currencyId = 'currency-uuid-1';
const productId = 'product-uuid-1';
beforeEach(() => {
jest.clearAllMocks();
mockReserveWithClient.mockResolvedValue({ success: true, errors: [] });
mockReleaseWithClient.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Flow 1: Quotation -> Approval -> Sales Order Conversion', () => {
it('should create quotation, add lines, and convert to sales order', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
// Step 1: Create quotation
const quotationData = createMockQuotation({
id: 'quotation-uuid',
name: 'QUO-000001',
status: 'draft',
company_id: companyId,
partner_id: partnerId,
currency_id: currencyId,
});
mockQueryOne
.mockResolvedValueOnce({ next_num: 1 }) // sequence for quotation
.mockResolvedValueOnce(quotationData); // INSERT quotation
const createdQuotation = await quotationsService.create({
company_id: companyId,
partner_id: partnerId,
currency_id: currencyId,
validity_date: '2024-12-31',
}, tenantId, userId);
expect(createdQuotation.name).toBe('QUO-000001');
expect(createdQuotation.status).toBe('draft');
// Step 2: Add line to quotation
const lineData = createMockQuotationLine({
id: 'line-uuid',
quotation_id: 'quotation-uuid',
product_id: productId,
quantity: 10,
price_unit: 100,
});
mockQueryOne
.mockResolvedValueOnce(quotationData) // findById for addLine
.mockResolvedValueOnce(lineData); // INSERT line
mockQuery
.mockResolvedValueOnce([]) // lines for findById
.mockResolvedValueOnce(undefined); // updateTotals
const addedLine = await quotationsService.addLine('quotation-uuid', {
product_id: productId,
description: 'Test Product',
quantity: 10,
uom_id: 'uom-uuid',
price_unit: 100,
}, tenantId, userId);
expect(addedLine.product_id).toBe(productId);
// Step 3: Confirm quotation (convert to sales order)
const quotationWithLines = {
...quotationData,
lines: [lineData],
};
mockQueryOne
.mockResolvedValueOnce(quotationWithLines) // findById for confirm
.mockResolvedValueOnce(quotationWithLines); // findById after confirm
mockQuery
.mockResolvedValueOnce([lineData]) // lines for first findById
.mockResolvedValueOnce([lineData]); // lines for second findById
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // order sequence
.mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] }) // INSERT order
.mockResolvedValueOnce(undefined) // INSERT order lines
.mockResolvedValueOnce(undefined) // UPDATE quotation
.mockResolvedValueOnce(undefined); // COMMIT
const { quotation, orderId } = await quotationsService.confirm('quotation-uuid', tenantId, userId);
expect(orderId).toBe('order-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should not convert quotation without lines', async () => {
const quotationWithoutLines = createMockQuotation({
id: 'quotation-uuid',
status: 'draft',
lines: [],
});
mockQueryOne.mockResolvedValue(quotationWithoutLines);
mockQuery.mockResolvedValue([]); // no lines
await expect(
quotationsService.confirm('quotation-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not convert already confirmed quotation', async () => {
const confirmedQuotation = createMockQuotation({
id: 'quotation-uuid',
status: 'confirmed',
});
mockQueryOne.mockResolvedValue(confirmedQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.confirm('quotation-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should convert sent quotation to sales order', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const sentQuotation = createMockQuotation({
id: 'quotation-uuid',
status: 'sent',
lines: [createMockQuotationLine()],
});
mockQueryOne
.mockResolvedValueOnce(sentQuotation)
.mockResolvedValueOnce(sentQuotation);
mockQuery.mockResolvedValue([createMockQuotationLine()]);
mockClient.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] })
.mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] })
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { orderId } = await quotationsService.confirm('quotation-uuid', tenantId, userId);
expect(orderId).toBe('order-uuid');
});
});
describe('Flow 2: Sales Order -> Picking -> Delivery Confirmation', () => {
it('should confirm sales order and create picking', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const orderWithLines = createMockSalesOrder({
id: 'order-uuid',
name: 'SO-000001',
status: 'draft',
company_id: companyId,
lines: [createMockSalesOrderLine({ product_id: productId, quantity: 5 })],
});
mockQueryOne
.mockResolvedValueOnce(orderWithLines) // findById before confirm
.mockResolvedValueOnce({ ...orderWithLines, status: 'sent', picking_id: 'picking-uuid' }); // findById after confirm
mockQuery
.mockResolvedValueOnce([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]) // lines before
.mockResolvedValueOnce([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]); // lines after
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] }) // location query
.mockResolvedValueOnce({ rows: [{ id: 'customer-location-uuid' }] }) // customer location
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking
.mockResolvedValueOnce({ rows: [] }) // INSERT stock_move
.mockResolvedValueOnce({ rows: [] }) // UPDATE order status
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const confirmedOrder = await ordersService.confirm('order-uuid', tenantId, userId);
expect(confirmedOrder.status).toBe('sent');
expect(confirmedOrder.picking_id).toBe('picking-uuid');
expect(mockReserveWithClient).toHaveBeenCalled();
});
it('should fail confirmation if stock is insufficient', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
mockReserveWithClient.mockResolvedValue({
success: false,
errors: ['Insufficient stock for product Test Product'],
});
const orderWithLines = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
company_id: companyId,
lines: [createMockSalesOrderLine({ product_id: productId, quantity: 1000 })],
});
mockQueryOne.mockResolvedValue(orderWithLines);
mockQuery.mockResolvedValue([createMockSalesOrderLine({ product_id: productId, quantity: 1000 })]);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] })
.mockResolvedValueOnce({ rows: [{ id: 'customer-location-uuid' }] });
await expect(
ordersService.confirm('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
it('should not confirm order without lines', async () => {
const orderWithoutLines = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
lines: [],
});
mockQueryOne.mockResolvedValue(orderWithoutLines);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.confirm('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not confirm already confirmed order', async () => {
const confirmedOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
});
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.confirm('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should create customer location if not exists', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const orderWithLines = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
company_id: companyId,
lines: [createMockSalesOrderLine()],
});
mockQueryOne
.mockResolvedValueOnce(orderWithLines)
.mockResolvedValueOnce({ ...orderWithLines, status: 'sent' });
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid', warehouse_id: 'warehouse-uuid' }] })
.mockResolvedValueOnce({ rows: [] }) // no customer location
.mockResolvedValueOnce({ rows: [{ id: 'new-customer-location-uuid' }] }) // INSERT customer location
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] });
await ordersService.confirm('order-uuid', tenantId, userId);
// Verify customer location creation query was called
// The INSERT statement should contain inventory.locations
const calls = mockClient.query.mock.calls;
const insertLocationCall = calls.find((call: any[]) =>
typeof call[0] === 'string' && call[0].includes('INSERT INTO inventory.locations')
);
expect(insertLocationCall).toBeDefined();
});
});
describe('Flow 3: Delivery Confirmed -> Invoice Generation', () => {
it('should create invoice from confirmed order (policy=order)', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const confirmedOrder = createMockSalesOrder({
id: 'order-uuid',
name: 'SO-000001',
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
company_id: companyId,
currency_id: currencyId,
partner_id: partnerId,
lines: [createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_invoiced: 0,
price_unit: 100,
discount: 0,
})],
});
mockQueryOne.mockResolvedValue(confirmedOrder);
mockQuery.mockResolvedValue([createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_invoiced: 0,
price_unit: 100,
discount: 0,
})]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // invoice sequence
.mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] }) // INSERT invoice
.mockResolvedValueOnce(undefined) // INSERT invoice line
.mockResolvedValueOnce(undefined) // UPDATE qty_invoiced
.mockResolvedValueOnce(undefined) // UPDATE invoice totals
.mockResolvedValueOnce(undefined) // UPDATE order status
.mockResolvedValueOnce(undefined); // COMMIT
const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId);
expect(invoiceId).toBe('invoice-uuid');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should create invoice based on delivered quantity (policy=delivery)', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const orderWithDelivery = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'delivery',
lines: [createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_delivered: 5,
qty_invoiced: 0,
price_unit: 100,
})],
});
mockQueryOne.mockResolvedValue(orderWithDelivery);
mockQuery.mockResolvedValue([createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_delivered: 5,
qty_invoiced: 0,
price_unit: 100,
})]);
mockClient.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] })
.mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] })
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId);
expect(invoiceId).toBe('invoice-uuid');
});
it('should not invoice draft order', async () => {
const draftOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
});
mockQueryOne.mockResolvedValue(draftOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.createInvoice('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not invoice fully invoiced order', async () => {
const fullyInvoicedOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
invoice_status: 'invoiced',
});
mockQueryOne.mockResolvedValue(fullyInvoicedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.createInvoice('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not invoice when no lines to invoice', async () => {
const orderFullyInvoicedLines = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
lines: [createMockSalesOrderLine({
quantity: 10,
qty_invoiced: 10, // fully invoiced
})],
});
mockQueryOne.mockResolvedValue(orderFullyInvoicedLines);
mockQuery.mockResolvedValue([createMockSalesOrderLine({
quantity: 10,
qty_invoiced: 10,
})]);
await expect(
ordersService.createInvoice('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should handle partial invoicing', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
// First invoice - partial
const partiallyInvoicedOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
invoice_status: 'pending',
invoice_policy: 'order',
lines: [createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_invoiced: 5, // partially invoiced
price_unit: 100,
})],
});
mockQueryOne.mockResolvedValue(partiallyInvoicedOrder);
mockQuery.mockResolvedValue([createMockSalesOrderLine({
id: 'line-uuid',
quantity: 10,
qty_invoiced: 5,
price_unit: 100,
})]);
mockClient.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ rows: [{ next_num: 2 }] })
.mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid-2' }] })
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { invoiceId } = await ordersService.createInvoice('order-uuid', tenantId, userId);
expect(invoiceId).toBe('invoice-uuid-2');
});
});
describe('Flow 4: Order Cancellation', () => {
it('should cancel draft order', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const draftOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
delivery_status: 'pending',
invoice_status: 'pending',
});
mockQueryOne
.mockResolvedValueOnce(draftOrder)
.mockResolvedValueOnce({ ...draftOrder, status: 'cancelled' });
mockQuery.mockResolvedValue([]);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const cancelledOrder = await ordersService.cancel('order-uuid', tenantId, userId);
expect(cancelledOrder.status).toBe('cancelled');
});
it('should cancel confirmed order and release stock', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const confirmedOrder = createMockSalesOrder({
id: 'order-uuid',
name: 'SO-000001',
status: 'sent',
delivery_status: 'pending',
invoice_status: 'pending',
picking_id: 'picking-uuid',
lines: [createMockSalesOrderLine({ product_id: productId, quantity: 5 })],
});
mockQueryOne
.mockResolvedValueOnce(confirmedOrder)
.mockResolvedValueOnce({ ...confirmedOrder, status: 'cancelled' });
mockQuery.mockResolvedValue([createMockSalesOrderLine({ product_id: productId, quantity: 5 })]);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-location-uuid' }] }) // get picking location
.mockResolvedValueOnce({ rows: [] }) // UPDATE picking cancelled
.mockResolvedValueOnce({ rows: [] }) // UPDATE stock_moves cancelled
.mockResolvedValueOnce({ rows: [] }) // UPDATE order status
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await ordersService.cancel('order-uuid', tenantId, userId);
expect(mockReleaseWithClient).toHaveBeenCalled();
});
it('should not cancel completed order', async () => {
const completedOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'done',
});
mockQueryOne.mockResolvedValue(completedOrder);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not cancel order with deliveries', async () => {
const orderWithDeliveries = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
delivery_status: 'partial',
invoice_status: 'pending',
});
mockQueryOne.mockResolvedValue(orderWithDeliveries);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should not cancel order with invoices', async () => {
const orderWithInvoices = createMockSalesOrder({
id: 'order-uuid',
status: 'sent',
delivery_status: 'pending',
invoice_status: 'partial',
});
mockQueryOne.mockResolvedValue(orderWithInvoices);
mockQuery.mockResolvedValue([]);
await expect(
ordersService.cancel('order-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should cancel quotation in draft status', async () => {
const draftQuotation = createMockQuotation({
id: 'quotation-uuid',
status: 'draft',
});
mockQueryOne.mockResolvedValue(draftQuotation);
mockQuery
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined);
await quotationsService.cancel('quotation-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should not cancel confirmed quotation', async () => {
const confirmedQuotation = createMockQuotation({
id: 'quotation-uuid',
status: 'confirmed',
});
mockQueryOne.mockResolvedValue(confirmedQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.cancel('quotation-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('Flow 5: Pricelist Application and Discounts', () => {
it('should get product price from pricelist', async () => {
mockQueryOne.mockResolvedValue({ price: 85 }); // discounted price
const price = await pricelistsService.getProductPrice(
productId,
'pricelist-uuid',
10
);
expect(price).toBe(85);
});
it('should return higher discount for larger quantities', async () => {
// First call with small quantity
mockQueryOne.mockResolvedValueOnce({ price: 100 });
const smallQtyPrice = await pricelistsService.getProductPrice(
productId,
'pricelist-uuid',
1
);
expect(smallQtyPrice).toBe(100);
// Second call with large quantity
mockQueryOne.mockResolvedValueOnce({ price: 80 });
const largeQtyPrice = await pricelistsService.getProductPrice(
productId,
'pricelist-uuid',
100
);
expect(largeQtyPrice).toBe(80);
});
it('should return null when product not in pricelist', async () => {
mockQueryOne.mockResolvedValue(null);
const price = await pricelistsService.getProductPrice(
'unknown-product',
'pricelist-uuid',
1
);
expect(price).toBeNull();
});
it('should respect pricelist validity dates', async () => {
mockQueryOne.mockResolvedValue({ price: 90 });
await pricelistsService.getProductPrice(productId, 'pricelist-uuid', 1);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('valid_from IS NULL OR valid_from <= CURRENT_DATE'),
expect.any(Array)
);
});
});
describe('Error Handling and Rollback', () => {
it('should rollback on quotation confirmation error', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const quotation = createMockQuotation({
status: 'draft',
lines: [createMockQuotationLine()],
});
mockQueryOne.mockResolvedValue(quotation);
mockQuery.mockResolvedValue([createMockQuotationLine()]);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('Database error')); // fail on sequence
await expect(
quotationsService.confirm('quotation-uuid', tenantId, userId)
).rejects.toThrow('Database error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
it('should rollback on order confirmation error', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const order = createMockSalesOrder({
status: 'draft',
company_id: companyId,
lines: [createMockSalesOrderLine()],
});
mockQueryOne.mockResolvedValue(order);
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'loc-uuid', warehouse_id: 'wh-uuid' }] })
.mockResolvedValueOnce({ rows: [{ id: 'cust-loc-uuid' }] })
.mockRejectedValueOnce(new Error('Picking creation failed'));
await expect(
ordersService.confirm('order-uuid', tenantId, userId)
).rejects.toThrow('Picking creation failed');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
it('should rollback on invoice creation error', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
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('Sequence error'));
await expect(
ordersService.createInvoice('order-uuid', tenantId, userId)
).rejects.toThrow('Sequence error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('State Transitions', () => {
it('should transition quotation: draft -> sent', async () => {
const draftQuotation = createMockQuotation({
status: 'draft',
lines: [createMockQuotationLine()],
});
mockQueryOne
.mockResolvedValueOnce(draftQuotation)
.mockResolvedValueOnce({ ...draftQuotation, status: 'sent' });
mockQuery
.mockResolvedValueOnce([createMockQuotationLine()])
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([createMockQuotationLine()]);
const sentQuotation = await quotationsService.send('quotation-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'sent'"),
expect.any(Array)
);
});
it('should not send quotation without lines', async () => {
const emptyQuotation = createMockQuotation({
status: 'draft',
lines: [],
});
mockQueryOne.mockResolvedValue(emptyQuotation);
mockQuery.mockResolvedValue([]);
await expect(
quotationsService.send('quotation-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should track order delivery_status transitions', async () => {
// pending -> partial -> delivered
const orderStates = [
createMockSalesOrder({ delivery_status: 'pending' }),
createMockSalesOrder({ delivery_status: 'partial' }),
createMockSalesOrder({ delivery_status: 'delivered' }),
];
for (const order of orderStates) {
mockQueryOne.mockResolvedValueOnce(order);
mockQuery.mockResolvedValueOnce([]);
const result = await ordersService.findById('order-uuid', tenantId);
expect(['pending', 'partial', 'delivered']).toContain(result.delivery_status);
}
});
it('should track order invoice_status transitions', async () => {
// pending -> partial -> invoiced
const orderStates = [
createMockSalesOrder({ invoice_status: 'pending' }),
createMockSalesOrder({ invoice_status: 'partial' }),
createMockSalesOrder({ invoice_status: 'invoiced' }),
];
for (const order of orderStates) {
mockQueryOne.mockResolvedValueOnce(order);
mockQuery.mockResolvedValueOnce([]);
const result = await ordersService.findById('order-uuid', tenantId);
expect(['pending', 'partial', 'invoiced']).toContain(result.invoice_status);
}
});
});
describe('Multi-line Order Processing', () => {
it('should handle order with multiple lines', async () => {
const mockClient = createMockClient();
mockGetClient.mockResolvedValue(mockClient);
const multiLineOrder = createMockSalesOrder({
id: 'order-uuid',
status: 'draft',
company_id: companyId,
lines: [
createMockSalesOrderLine({ id: 'line-1', product_id: 'product-1', quantity: 5 }),
createMockSalesOrderLine({ id: 'line-2', product_id: 'product-2', quantity: 10 }),
createMockSalesOrderLine({ id: 'line-3', product_id: 'product-3', quantity: 3 }),
],
});
const lines = [
createMockSalesOrderLine({ id: 'line-1', product_id: 'product-1', quantity: 5 }),
createMockSalesOrderLine({ id: 'line-2', product_id: 'product-2', quantity: 10 }),
createMockSalesOrderLine({ id: 'line-3', product_id: 'product-3', quantity: 3 }),
];
mockQueryOne
.mockResolvedValueOnce(multiLineOrder)
.mockResolvedValueOnce({ ...multiLineOrder, status: 'sent' });
mockQuery
.mockResolvedValueOnce(lines)
.mockResolvedValueOnce(lines);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ location_id: 'loc-uuid', warehouse_id: 'wh-uuid' }] })
.mockResolvedValueOnce({ rows: [{ id: 'cust-loc-uuid' }] })
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] })
.mockResolvedValueOnce({ rows: [] }) // stock_move for line 1
.mockResolvedValueOnce({ rows: [] }) // stock_move for line 2
.mockResolvedValueOnce({ rows: [] }) // stock_move for line 3
.mockResolvedValueOnce({ rows: [] }) // UPDATE order
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await ordersService.confirm('order-uuid', tenantId, userId);
// Verify stock reservation was called with all lines
expect(mockReserveWithClient).toHaveBeenCalledWith(
mockClient,
expect.arrayContaining([
expect.objectContaining({ productId: 'product-1', quantity: 5 }),
expect.objectContaining({ productId: 'product-2', quantity: 10 }),
expect.objectContaining({ productId: 'product-3', quantity: 3 }),
]),
tenantId,
expect.any(String),
false
);
});
});
});