122 lines
5.5 KiB
TypeScript
122 lines
5.5 KiB
TypeScript
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<string, string>();
|
|
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);
|
|
});
|
|
});
|