548 lines
15 KiB
TypeScript
548 lines
15 KiB
TypeScript
/**
|
|
* Investment Account Service Unit Tests
|
|
*
|
|
* Tests for investment account service including:
|
|
* - Account creation and management
|
|
* - Balance tracking
|
|
* - Performance calculations
|
|
* - Account status management
|
|
*/
|
|
|
|
import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock';
|
|
|
|
// Mock database (account service uses in-memory storage)
|
|
jest.mock('../../../../shared/database', () => ({
|
|
db: mockDb,
|
|
}));
|
|
|
|
// Mock logger
|
|
jest.mock('../../../../shared/utils/logger', () => ({
|
|
logger: {
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
debug: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock product service
|
|
const mockGetProductById = jest.fn();
|
|
jest.mock('../product.service', () => ({
|
|
productService: {
|
|
getProductById: mockGetProductById,
|
|
getAllProducts: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Import service after mocks
|
|
import { accountService } from '../account.service';
|
|
|
|
describe('AccountService', () => {
|
|
beforeEach(() => {
|
|
resetDatabaseMocks();
|
|
mockGetProductById.mockReset();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('createAccount', () => {
|
|
it('should create a new investment account', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas - El Guardián',
|
|
riskProfile: 'conservative',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValueOnce(mockProduct);
|
|
|
|
const result = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
expect(result.userId).toBe('user-123');
|
|
expect(result.productId).toBe('product-123');
|
|
expect(result.balance).toBe(1000);
|
|
expect(result.initialInvestment).toBe(1000);
|
|
expect(result.status).toBe('active');
|
|
});
|
|
|
|
it('should validate minimum investment amount', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'orion',
|
|
name: 'Orion - El Explorador',
|
|
riskProfile: 'moderate',
|
|
minInvestment: 500,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValueOnce(mockProduct);
|
|
|
|
await expect(
|
|
accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 100,
|
|
})
|
|
).rejects.toThrow('Minimum investment is 500');
|
|
});
|
|
|
|
it('should reject inactive products', async () => {
|
|
const mockProduct = {
|
|
id: 'product-124',
|
|
code: 'inactive',
|
|
name: 'Inactive Product',
|
|
riskProfile: 'moderate',
|
|
minInvestment: 100,
|
|
isActive: false,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValueOnce(mockProduct);
|
|
|
|
await expect(
|
|
accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-124',
|
|
initialDeposit: 1000,
|
|
})
|
|
).rejects.toThrow('Product is not active');
|
|
});
|
|
|
|
it('should prevent duplicate accounts for same product', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
riskProfile: 'conservative',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await expect(
|
|
accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 500,
|
|
})
|
|
).rejects.toThrow('Account already exists for this product');
|
|
});
|
|
|
|
it('should handle non-existent product', async () => {
|
|
mockGetProductById.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'non-existent',
|
|
initialDeposit: 1000,
|
|
})
|
|
).rejects.toThrow('Product not found');
|
|
});
|
|
});
|
|
|
|
describe('getUserAccounts', () => {
|
|
it('should retrieve all accounts for a user', async () => {
|
|
const mockProducts = [
|
|
{ id: 'product-1', code: 'atlas', name: 'Atlas' },
|
|
{ id: 'product-2', code: 'orion', name: 'Orion' },
|
|
];
|
|
|
|
mockGetProductById.mockImplementation((id) =>
|
|
Promise.resolve(mockProducts.find(p => p.id === id))
|
|
);
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-1',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-2',
|
|
initialDeposit: 2000,
|
|
});
|
|
|
|
const result = await accountService.getUserAccounts('user-123');
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].userId).toBe('user-123');
|
|
expect(result[1].userId).toBe('user-123');
|
|
expect(result[0].product).toBeDefined();
|
|
});
|
|
|
|
it('should return empty array for user with no accounts', async () => {
|
|
const result = await accountService.getUserAccounts('user-999');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should include product information', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas - El Guardián',
|
|
riskProfile: 'conservative',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.getUserAccounts('user-123');
|
|
|
|
expect(result[0].product).toEqual(mockProduct);
|
|
});
|
|
});
|
|
|
|
describe('getAccountById', () => {
|
|
it('should retrieve an account by ID', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const created = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.getAccountById(created.id);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.id).toBe(created.id);
|
|
expect(result?.product).toBeDefined();
|
|
});
|
|
|
|
it('should return null for non-existent account', async () => {
|
|
const result = await accountService.getAccountById('non-existent');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getAccountByUserAndProduct', () => {
|
|
it('should retrieve account by user and product', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123');
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.userId).toBe('user-123');
|
|
expect(result?.productId).toBe('product-123');
|
|
});
|
|
|
|
it('should return null if account does not exist', async () => {
|
|
const result = await accountService.getAccountByUserAndProduct('user-999', 'product-999');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should exclude closed accounts', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await accountService.closeAccount(account.id);
|
|
|
|
const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateAccountBalance', () => {
|
|
it('should update account balance', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.updateAccountBalance(account.id, 1500);
|
|
|
|
expect(result.balance).toBe(1500);
|
|
expect(result.updatedAt).toBeDefined();
|
|
});
|
|
|
|
it('should calculate unrealized P&L', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.updateAccountBalance(account.id, 1200);
|
|
|
|
expect(result.unrealizedPnl).toBe(200);
|
|
expect(result.unrealizedPnlPercent).toBe(20);
|
|
});
|
|
|
|
it('should handle account not found', async () => {
|
|
await expect(
|
|
accountService.updateAccountBalance('non-existent', 1000)
|
|
).rejects.toThrow('Account not found');
|
|
});
|
|
});
|
|
|
|
describe('closeAccount', () => {
|
|
it('should close an active account', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.closeAccount(account.id);
|
|
|
|
expect(result.status).toBe('closed');
|
|
expect(result.closedAt).toBeDefined();
|
|
});
|
|
|
|
it('should require zero balance to close', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await expect(accountService.closeAccount(account.id)).rejects.toThrow(
|
|
'Cannot close account with non-zero balance'
|
|
);
|
|
});
|
|
|
|
it('should prevent closing already closed account', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await accountService.updateAccountBalance(account.id, 0);
|
|
await accountService.closeAccount(account.id);
|
|
|
|
await expect(accountService.closeAccount(account.id)).rejects.toThrow(
|
|
'Account is already closed'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('suspendAccount', () => {
|
|
it('should suspend an active account', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const result = await accountService.suspendAccount(account.id);
|
|
|
|
expect(result.status).toBe('suspended');
|
|
});
|
|
|
|
it('should prevent operations on suspended account', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await accountService.suspendAccount(account.id);
|
|
|
|
await expect(
|
|
accountService.updateAccountBalance(account.id, 1500)
|
|
).rejects.toThrow('Account is suspended');
|
|
});
|
|
});
|
|
|
|
describe('getAccountSummary', () => {
|
|
it('should calculate account summary for user', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
const account2 = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-124',
|
|
initialDeposit: 2000,
|
|
});
|
|
|
|
await accountService.updateAccountBalance(account2.id, 2500);
|
|
|
|
const result = await accountService.getAccountSummary('user-123');
|
|
|
|
expect(result.totalBalance).toBeGreaterThan(0);
|
|
expect(result.totalDeposited).toBe(3000);
|
|
expect(result.totalEarnings).toBeGreaterThan(0);
|
|
expect(result.overallReturn).toBeGreaterThan(0);
|
|
expect(result.accounts).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle user with no accounts', async () => {
|
|
const result = await accountService.getAccountSummary('user-999');
|
|
|
|
expect(result.totalBalance).toBe(0);
|
|
expect(result.totalDeposited).toBe(0);
|
|
expect(result.accounts).toEqual([]);
|
|
});
|
|
|
|
it('should exclude closed accounts from summary', async () => {
|
|
const mockProduct = {
|
|
id: 'product-123',
|
|
code: 'atlas',
|
|
name: 'Atlas',
|
|
minInvestment: 100,
|
|
isActive: true,
|
|
};
|
|
|
|
mockGetProductById.mockResolvedValue(mockProduct);
|
|
|
|
const account = await accountService.createAccount({
|
|
userId: 'user-123',
|
|
productId: 'product-123',
|
|
initialDeposit: 1000,
|
|
});
|
|
|
|
await accountService.updateAccountBalance(account.id, 0);
|
|
await accountService.closeAccount(account.id);
|
|
|
|
const result = await accountService.getAccountSummary('user-123');
|
|
|
|
expect(result.accounts).toHaveLength(0);
|
|
expect(result.totalBalance).toBe(0);
|
|
});
|
|
});
|
|
});
|