import { setTimeout as delay } from 'node:timers/promises'; import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../src/prisma/prisma.service'; import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; import { candleGaps, type CandleRange, } from '../src/modules/investments/instruments/domain/candle-coverage'; describe('atomic shared candle reconciliation (SQLite)', () => { const prisma = new PrismaService( new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), ); const repository = new CandleRepository(prisma); const owner = { userId: 'owner', revision: 'revision' }; const tokens = new TbankTokenService( new ConfigService({ TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, }) as never, ); const upstream = { getCandles: jest.fn() }; const access = new TbankAccessService(prisma, tokens, upstream as never); const instrument = { uid: 'uid', currency: 'rub', first_1dayCandleDate: { seconds: String(Date.parse('2020-01-01Z') / 1000) }, }; const sources = { ensureDetail: jest.fn().mockResolvedValue(instrument), readMany: jest.fn().mockResolvedValue([ { input: { uid: 'uid', instrumentType: 'share', kind: 'detail' }, instrument, meta: { availability: 'complete', freshness: 'fresh', lastSuccessfulAt: null, resourceKey: 'detail/uid', dataRevision: '1', stateVersion: 'detail-v1', sync: { id: null, state: 'idle', errorCode: null }, }, }, ]), }; const createService = () => new CandleService( repository, sources as never, access, upstream as never, { event: jest.fn() } as never, ); async function settled() { for (let i = 0; i < 500; i++) { if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) return; await delay(5); } throw new Error('Candle work did not settle'); } const range = { from: new Date('2026-01-01Z'), to: new Date('2026-01-04Z') }; const candle = { time: '2026-01-02T00:00:00.000Z', close: '100.000000001', isComplete: true, }; async function reserve(ranges: CandleRange[] = [range]) { const result = await repository.reserve( owner, 'candles:uid', { uid: 'uid', ranges }, 'run', new Set(), ); if (!result) throw new Error('Missing attempt'); return { ...owner, id: result.work.id, attemptId: result.work.attemptId }; } beforeAll(async () => { await prisma.user.create({ data: { id: 'owner', email: 'owner@example.invalid', passwordHash: 'synthetic', }, }); }); beforeEach(async () => { jest.clearAllMocks(); upstream.getCandles.mockReset().mockResolvedValue([]); await prisma.tbankConnection.deleteMany(); await prisma.tbankInstrument.deleteMany(); await prisma.tbankConnection.create({ data: { ...owner, encryptedToken: tokens.encrypt('synthetic'), connectedAt: new Date(), }, }); await prisma.tbankInstrument.create({ data: { provider: 'tbank', uid: 'uid', instrumentType: 'share', metadata: {}, fetchedAt: new Date(), }, }); }); afterAll(async () => prisma.$disconnect()); it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => { const attempt = await reserve(); await repository.stage( attempt, range, [candle, candle, { ...candle, time: range.to.toISOString() }], new Date('2026-01-05Z'), ); expect((await repository.read('uid', range)).data).toEqual([]); await repository.publish(attempt, { uid: 'uid', ranges: [range] }); await prisma.$disconnect(); const read = await new CandleRepository(prisma).read('uid', range); expect(read.data).toEqual([candle]); expect(candleGaps(range, read.coverage, new Date('2030-01-01Z'))).toEqual( [], ); const emptyRange = { from: range.to, to: new Date('2026-01-06Z') }; const empty = await reserve([emptyRange]); await repository.stage(empty, emptyRange, [], new Date('2026-01-07Z')); await repository.publish(empty, { uid: 'uid', ranges: [emptyRange] }); const readEmpty = await repository.read('uid', emptyRange); expect(readEmpty.data).toEqual([]); expect( candleGaps(emptyRange, readEmpty.coverage, new Date('2030-01-01Z')), ).toEqual([]); }); it('keeps the entire previous snapshot and timestamps when a later refresh window fails', async () => { const initial = await reserve(); await repository.stage(initial, range, [candle], new Date('2026-01-05Z')); await repository.publish(initial, { uid: 'uid', ranges: [range] }); const before = await repository.read('uid', range); const windows = [ { from: range.from, to: new Date('2026-01-03Z') }, { from: new Date('2026-01-03Z'), to: range.to }, ]; const retry = await reserve(windows); await repository.stage( retry, windows[0], [{ ...candle, close: '2' }], new Date('2026-01-06Z'), ); await expect( repository.publish(retry, { uid: 'uid', ranges: windows }), ).rejects.toThrow('Incomplete candle reconciliation'); expect(await repository.read('uid', range)).toEqual(before); await repository.fail(retry, 'TBANK_UNAVAILABLE'); expect(await repository.read('uid', range)).toEqual(before); expect(await prisma.tbankSyncStage.count()).toBe(0); }); it('reconciles deleted rows atomically and preserves coverage outside the requested period', async () => { const initial = await reserve(); await repository.stage(initial, range, [candle], new Date('2026-01-05Z')); await repository.publish(initial, { uid: 'uid', ranges: [range] }); const middle = { from: new Date('2026-01-02Z'), to: new Date('2026-01-03Z'), }; const retry = await reserve([middle]); await repository.stage(retry, middle, [], new Date('2026-01-06Z')); expect((await repository.read('uid', range)).data).toEqual([candle]); await repository.publish(retry, { uid: 'uid', ranges: [middle] }); const after = await repository.read('uid', range); expect(after.data).toEqual([]); expect(candleGaps(range, after.coverage, new Date('2030-01-01Z'))).toEqual( [], ); }); it('fences superseded and deleted attempts including shared coverage timestamps', async () => { const old = await reserve(); await repository.stage(old, range, [candle], new Date('2026-01-05Z')); const current = await reserve(); expect(await repository.publish(old, { uid: 'uid', ranges: [range] })).toBe( false, ); expect( await repository.stage(old, range, [candle], new Date('2026-01-06Z')), ).toBe(false); await repository.stage(current, range, [candle], new Date('2026-01-06Z')); await prisma.tbankConnection.deleteMany(); expect( await repository.publish(current, { uid: 'uid', ranges: [range] }), ).toBe(false); expect((await repository.read('uid', range)).coverage).toEqual([]); }); it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => { const service = createService(); const input = { uid: 'uid', instrumentType: 'share' as const, period: 'all' as const, }; let release: (value: unknown[]) => void = () => {}; const blocked = new Promise((resolve) => { release = resolve; }); upstream.getCandles.mockReturnValueOnce(blocked); const cold = await Promise.all( Array.from({ length: 20 }, () => service.read('owner', input)), ); expect(cold.every((row) => row.data === null)).toBe(true); expect(await prisma.tbankSyncWork.count()).toBe(1); await service.read('owner', input, true); release([]); await settled(); const count = upstream.getCandles.mock.calls.length; expect(count).toBeGreaterThan(1); for (const call of upstream.getCandles.mock.calls as [ string, string, Date, Date, ][]) expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); await prisma.$disconnect(); const reopened = createService(); expect((await reopened.read('owner', input)).data?.candles).toEqual([]); expect(upstream.getCandles).toHaveBeenCalledTimes(count); await prisma.tbankCandleCoverage.updateMany({ where: { isComplete: false }, data: { fetchedAt: new Date(0) }, }); const stale = await reopened.read('owner', input, true); expect(stale.meta.availability).toBe('complete'); expect(stale.meta.freshness).toBe('stale'); expect(stale.meta.coverage?.missingRanges).toEqual([]); expect(stale.meta.coverage?.completeRanges.length).toBeGreaterThan(0); expect(stale.meta.coverage?.historyState).toBe('complete'); expect(upstream.getCandles).toHaveBeenCalledTimes(count); await reopened.read('owner', input); await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1); }); it('recovers abandoned work only on a new ordinary request', async () => { const input = { uid: 'uid', instrumentType: 'share' as const, period: 'month' as const, }; const abandoned = await repository.reserve( owner, 'candles:uid:month', { uid: 'uid', ranges: [] }, 'old-process', new Set(), ); const reopened = createService(); await reopened.read('owner', input, true); expect(upstream.getCandles).not.toHaveBeenCalled(); expect( (await repository.work('owner', 'revision', 'candles:uid:month')) ?.attemptId, ).toBe(abandoned?.work.attemptId); await reopened.read('owner', input); await settled(); expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1); expect( (await repository.work('owner', 'revision', 'candles:uid:month')) ?.attemptId, ).not.toBe(abandoned?.work.attemptId); }); it('retains full shown history when a forced reread fails in its second window', async () => { const input = { uid: 'uid', instrumentType: 'share' as const, period: 'all' as const, }; const service = createService(); await service.read('owner', input); await settled(); const before = await prisma.tbankCandleCoverage.findMany(); upstream.getCandles .mockResolvedValueOnce([ { time: { seconds: '1577923200' }, close: { units: '123', nano: 1 }, isComplete: true, }, ]) .mockRejectedValueOnce(new Error('second window unavailable')); await service.request('owner', 'revision', input, new Date(), true); await settled(); expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before); expect(await prisma.tbankCandle.count()).toBe(0); const read = await service.read('owner', input, true); expect(read.meta.sync.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => { const now = new Date(); now.setUTCHours(0, 0, 0, 0); upstream.getCandles.mockResolvedValue([ { time: { seconds: String(now.valueOf() / 1000) }, close: { units: '99', nano: 123456789 }, isComplete: false, }, ]); const service = createService(); const input = { uid: 'uid', instrumentType: 'bond' as const, period: 'month' as const, }; await service.read('owner', input); await settled(); const result = await service.read('owner', input, true); expect(result.data?.currency).toBeNull(); expect(result.data?.candles).toEqual([ { time: now.toISOString(), close: '99.123456789', isComplete: false }, ]); expect( await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }), ).toBe(1); }); it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => { await prisma.tbankInstrument.create({ data: { provider: 'tbank', uid: 'opened', instrumentType: 'share', metadata: {}, fetchedAt: new Date(), }, }); const service = createService(); let release: (value: unknown[]) => void = () => {}; const blocked = new Promise((resolve) => { release = resolve; }); upstream.getCandles.mockReturnValueOnce(blocked); await service.prefetch('owner', 'revision', [ { instrumentUid: 'uid', type: 'STOCK' }, { instrumentUid: 'uid', type: 'STOCK' }, { instrumentUid: 'not-supported', type: 'FUND' }, { instrumentUid: null, type: 'BOND' }, ]); for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) await delay(5); expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid'); await service.read('owner', { uid: 'opened', instrumentType: 'share', period: 'month', }); release([]); await settled(); expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe( 'opened', ); expect( new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), ).toEqual(new Set(['uid', 'opened'])); const count = upstream.getCandles.mock.calls.length; await service.prefetch('owner', 'revision', [ { instrumentUid: 'uid', type: 'STOCK' }, ]); expect(upstream.getCandles).toHaveBeenCalledTimes(count); expect( (await repository.work('owner', 'revision', 'candles:opened:all'))?.state, ).toBe('complete'); }); });