- 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>
380 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|