[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:
parent
56f5663583
commit
968b64c773
561
src/modules/sales/__tests__/pricelists.service.test.ts
Normal file
561
src/modules/sales/__tests__/pricelists.service.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
973
src/modules/sales/__tests__/sales-flow.integration.test.ts
Normal file
973
src/modules/sales/__tests__/sales-flow.integration.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user