/** * 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); }); }); });