trading-platform-backend-v2/src/modules/investment/jobs/__tests__/distribution.job.spec.ts
Adrian Flores Cortes 35a94f0529 feat: Complete notifications system with push support and tests
- Add Firebase client for FCM push notifications
- Update notification service with push token management
- Add push token registration/removal endpoints
- Update all queries to use auth schema
- Add comprehensive unit tests for notification.service
- Add unit tests for distribution.job

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:56:34 -06:00

380 lines
12 KiB
TypeScript

/**
* Distribution Job Unit Tests
*
* Tests for the daily distribution job including:
* - Processing active accounts
* - Calculating returns based on product
* - Applying performance fees
* - Skipping negative return days
* - Sending notifications
* - Logging run summaries
*/
import { mockDb, createMockQueryResult, resetDatabaseMocks, createMockPoolClient } from '../../../../__tests__/mocks/database.mock';
// Mock database
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 notification service
const mockSendDistributionNotification = jest.fn();
jest.mock('../../../notifications', () => ({
notificationService: {
sendDistributionNotification: mockSendDistributionNotification,
},
}));
// Import after mocks
import { distributionJob } from '../distribution.job';
describe('DistributionJob', () => {
const mockPoolClient = createMockPoolClient();
beforeEach(() => {
resetDatabaseMocks();
mockSendDistributionNotification.mockClear();
mockPoolClient.query.mockClear();
jest.clearAllMocks();
// Setup transaction mock
mockDb.transaction.mockImplementation(async (callback) => {
return callback(mockPoolClient);
});
});
describe('run', () => {
const mockAccounts = [
{
id: 'account-1',
user_id: 'user-1',
product_id: 'product-1',
product_code: 'atlas',
product_name: 'Atlas - El Guardián',
account_number: 'ATL-001',
current_balance: '10000.00',
status: 'active',
},
{
id: 'account-2',
user_id: 'user-2',
product_id: 'product-1',
product_name: 'Atlas - El Guardián',
product_code: 'atlas',
account_number: 'ATL-002',
current_balance: '25000.00',
status: 'active',
},
];
const mockProducts = [
{
id: 'product-1',
code: 'atlas',
name: 'Atlas - El Guardián',
target_return_min: '3.00',
target_return_max: '5.00',
performance_fee: '20.00',
},
];
it('should process all active accounts', async () => {
// Mock queries
mockDb.query
// getActiveAccounts
.mockResolvedValueOnce(createMockQueryResult(mockAccounts))
// getProducts
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
// logDistributionRun
.mockResolvedValueOnce({ rowCount: 1 });
// Mock transaction queries
mockPoolClient.query
// Lock account row
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
// Update account balance
.mockResolvedValueOnce({ rowCount: 1 })
// Insert transaction
.mockResolvedValueOnce({ rowCount: 1 })
// Insert distribution history
.mockResolvedValueOnce({ rowCount: 1 })
// Lock account row (second account)
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '25000.00' }]))
// Update account balance
.mockResolvedValueOnce({ rowCount: 1 })
// Insert transaction
.mockResolvedValueOnce({ rowCount: 1 })
// Insert distribution history
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
const summary = await distributionJob.run();
expect(summary.totalAccounts).toBe(2);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('FROM investment.accounts'),
expect.any(Array)
);
});
it('should calculate correct returns based on product', async () => {
// Use Math.random mock to control variance
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5); // Will give variance = 0.1
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
// Verify update was called with calculated amount
expect(mockPoolClient.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE investment.accounts'),
expect.any(Array)
);
Math.random = originalRandom;
});
it('should apply performance fees', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
const summary = await distributionJob.run();
// Check that fees were collected
expect(summary.totalFees).toBeGreaterThanOrEqual(0);
Math.random = originalRandom;
});
it('should skip negative return days', async () => {
const originalRandom = Math.random;
// Return value that causes negative returns (< 0.3)
Math.random = jest.fn().mockReturnValue(0.1);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
const summary = await distributionJob.run();
// No distributions should occur on negative days
expect(mockPoolClient.query).not.toHaveBeenCalled();
expect(summary.successfulDistributions).toBe(0);
Math.random = originalRandom;
});
it('should send notifications to users', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
expect(mockSendDistributionNotification).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
productName: 'Atlas - El Guardián',
accountNumber: 'ATL-001',
})
);
Math.random = originalRandom;
});
it('should log run summary', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
.mockResolvedValueOnce(createMockQueryResult([{ current_balance: '10000.00' }]))
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 })
.mockResolvedValueOnce({ rowCount: 1 });
mockSendDistributionNotification.mockResolvedValue(undefined);
await distributionJob.run();
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO investment.distribution_runs'),
expect.any(Array)
);
Math.random = originalRandom;
});
it('should handle account not found during distribution', async () => {
const originalRandom = Math.random;
Math.random = jest.fn().mockReturnValue(0.5);
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([mockAccounts[0]]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
mockPoolClient.query
// Lock returns empty (account was deleted)
.mockResolvedValueOnce(createMockQueryResult([]));
mockDb.transaction.mockImplementation(async (callback) => {
try {
return await callback(mockPoolClient);
} catch (error) {
throw error;
}
});
const summary = await distributionJob.run();
expect(summary.failedDistributions).toBe(1);
Math.random = originalRandom;
});
it('should handle product not found for account', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([{
...mockAccounts[0],
product_id: 'non-existent-product',
}]))
.mockResolvedValueOnce(createMockQueryResult(mockProducts))
.mockResolvedValueOnce({ rowCount: 1 });
const summary = await distributionJob.run();
expect(summary.failedDistributions).toBe(1);
});
it('should not process if no active accounts', async () => {
mockDb.query.mockResolvedValueOnce(createMockQueryResult([]));
const summary = await distributionJob.run();
expect(summary.totalAccounts).toBe(0);
expect(summary.successfulDistributions).toBe(0);
});
it('should prevent concurrent runs', async () => {
// Start first run
mockDb.query
.mockResolvedValueOnce(createMockQueryResult(mockAccounts))
.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => {
resolve(createMockQueryResult(mockProducts));
}, 100)));
const firstRun = distributionJob.run();
// Attempt second run immediately
await expect(distributionJob.run()).rejects.toThrow('Distribution already in progress');
// Let first run complete
mockDb.query.mockResolvedValue({ rowCount: 1 });
mockPoolClient.query.mockResolvedValue(createMockQueryResult([{ current_balance: '10000.00' }]));
await firstRun;
});
});
describe('getStatus', () => {
it('should return running state', () => {
const status = distributionJob.getStatus();
expect(status).toHaveProperty('isRunning');
expect(status).toHaveProperty('lastRunAt');
expect(status).toHaveProperty('isScheduled');
});
it('should return last run time after successful run', async () => {
mockDb.query
.mockResolvedValueOnce(createMockQueryResult([]))
.mockResolvedValueOnce({ rowCount: 1 });
await distributionJob.run();
const status = distributionJob.getStatus();
expect(status.lastRunAt).toBeDefined();
});
});
describe('start/stop', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
distributionJob.stop();
jest.useRealTimers();
});
it('should schedule job for midnight UTC', () => {
distributionJob.start();
const status = distributionJob.getStatus();
expect(status.isScheduled).toBe(true);
});
it('should stop scheduled job', () => {
distributionJob.start();
distributionJob.stop();
const status = distributionJob.getStatus();
expect(status.isScheduled).toBe(false);
});
});
});