import { MarketIndexHistoryService } from './market-index.service'; jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} })); type State = { coveredFrom: null | string; coveredTo: null | string; lastAttemptAt: Date | null; lastSuccessAt: Date | null; }; type Close = { close: string; tradeDate: string }; const HOUR_MS = 3_600_000; // In-memory stand-in mirroring the repository's merge rules. function setup(history: Close[]) { let state: null | State = null; const stored = new Map(); const repository = { read: jest.fn((_: string, from: string, to: string) => Promise.resolve({ closes: [...stored.entries()] .filter(([date]) => date >= from && date <= to) .sort(([a], [b]) => a.localeCompare(b)) .map(([tradeDate, close]) => ({ tradeDate, close })), state: state && { ...state }, })), attempt: jest.fn((_: string, at: Date) => { state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at }; return Promise.resolve(); }), save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => { for (const close of closes) stored.set(close.tradeDate, close.close); const current = state as State; const joined = Boolean(current.coveredFrom && current.coveredTo && from <= current.coveredTo && to >= current.coveredFrom); state = { ...current, lastSuccessAt: at, coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from, coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to, }; return Promise.resolve(); }), }; const moex = { indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve( history.filter((row) => row.tradeDate >= from && row.tradeDate <= till), )), }; const service = new MarketIndexHistoryService( repository as never, moex as never, { event: jest.fn() } as never, ); return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) }; } const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' })); describe('MarketIndexHistoryService', () => { afterEach(() => jest.useRealTimers()); it('loads a ten-day lookback once and reuses the confirmed range', async () => { const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05')); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3); expect(moex.indexHistory).toHaveBeenCalledTimes(1); expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05'); }); it('extends history backward at once, even right after a successful load', async () => { const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05')); await service.read('MCFTRR', '2026-10-01', '2026-10-05'); expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3); expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05'); }); it('re-checks a missing tail close after an hour, but not sooner', async () => { jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') }); const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02')); await service.read('MCFTRR', '2026-10-02', '2026-10-05'); setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05')); await service.read('MCFTRR', '2026-10-02', '2026-10-05'); expect(moex.indexHistory).toHaveBeenCalledTimes(1); jest.setSystemTime(Date.now() + HOUR_MS + 1); const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05'); expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05'); expect(moex.indexHistory).toHaveBeenCalledTimes(2); }); it('keeps saved history usable when a later refresh fails and throttles retries', async () => { jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') }); const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); await service.read('MCFTRR', '2026-10-02', '2026-10-02'); jest.setSystemTime(Date.now() + 1000); moex.indexHistory.mockRejectedValue(new Error('MOEX down')); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2); expect(moex.indexHistory).toHaveBeenCalledTimes(2); }); it('returns no history when nothing was ever loaded and MOEX fails', async () => { const { service, moex } = setup([]); moex.indexHistory.mockRejectedValue(new Error('MOEX down')); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull(); }); it('refuses a range that does not reach the last cash flow', async () => { const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); await service.read('MCFTRR', '2026-10-02', '2026-10-02'); moex.indexHistory.mockRejectedValue(new Error('MOEX down')); expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull(); }); it('shares one request for parallel reads', async () => { const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); await Promise.all([ service.read('IMOEX', '2026-10-02', '2026-10-05'), service.read('IMOEX', '2026-10-02', '2026-10-05'), ]); expect(moex.indexHistory).toHaveBeenCalledTimes(1); }); });