import { MoexClient } from '../src/integrations/moex/moex.api'; import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; import type { StockCandlesResourceDto, StockCompanyResourceDto, StockQuoteResourceDto, } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; import type { BondMetricsResourceDto, BondQuoteResourceDto, } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; import assert from 'node:assert/strict'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; import type { StockResourceDto, StockSearchResourceDto, } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; // HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races. describe('local stock/bond search (SQLite HTTP)', () => { let app: INestApplication; let prisma: PrismaService; const upstream = { findShares: jest.fn(), findBonds: jest.fn(), getShare: jest.fn(), getBond: jest.fn(), getLastPrices: jest.fn(), getOpenAccounts: jest.fn(), getAssetDescription: jest.fn(), getFundamentals: jest.fn(), getTradingStatus: jest.fn(), getCandles: jest.fn(), getBondCoupons: jest.fn(), }; const moex = { bondYield: jest.fn() }; const ratings = { rating: jest.fn() }; const share = { uid: 'share', ticker: 'SBER', name: 'Synthetic share', isin: 'SHARE', classCode: 'TQBR', instrumentKind: 'INSTRUMENT_TYPE_SHARE', apiTradeAvailableFlag: true, }; const bond = { uid: 'bond', ticker: 'BOND', name: 'Synthetic bond', isin: 'BOND', classCode: 'TQOB', instrumentKind: 'INSTRUMENT_TYPE_BOND', }; const auth = (user = 'owner') => `Bearer ${new JwtService().sign({ sub: user, type: 'access' }, { secret: process.env.JWT_SECRET })}`; const get = (path = 'stocks?query=SBER', user = 'owner') => request(app.getHttpServer()) .get(`/api/v1/${path}`) .set('Authorization', auth(user)) .timeout(1500); const read = async (observe = true, user = 'owner') => ( await get( `stocks?query=SBER${observe ? '&sync=observe' : ''}`, user, ).expect(200) ).body as StockSearchResourceDto; async function settled() { for (let i = 0; i < 400; i++) { await delay(5); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) ) { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) ) return; } } throw new Error('Catalog sources did not settle'); } beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .overrideProvider(MoexClient) .useValue(moex) .overrideProvider(NcrRatingsClient) .useValue(ratings) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); for (const id of ['owner', 'other']) await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); await prisma.tbankInstrumentLookup.deleteMany(); await prisma.tbankInstrument.deleteMany(); await prisma.tbankCatalogDiscovery.deleteMany(); jest.resetAllMocks(); upstream.getAssetDescription.mockResolvedValue('Synthetic company'); upstream.getFundamentals.mockResolvedValue({ assetUid: 'asset', currency: 'rub', peRatioTtm: 6, netIncomeTtm: 10, }); upstream.getTradingStatus.mockResolvedValue( 'SECURITY_TRADING_STATUS_NORMAL_TRADING', ); upstream.getCandles.mockResolvedValue([]); upstream.getBondCoupons.mockResolvedValue([]); moex.bondYield.mockResolvedValue(null); ratings.rating.mockResolvedValue(null); upstream.findShares.mockResolvedValue([share]); upstream.findBonds.mockResolvedValue([bond]); upstream.getShare.mockImplementation((_token: string, uid: string) => Promise.resolve({ ...share, uid, currency: 'rub', sector: 'financial', assetUid: 'asset', lot: 10, }), ); upstream.getBond.mockResolvedValue({ ...bond, currency: 'rub', nominal: { currency: 'rub', units: '800' }, aciValue: { currency: 'rub', units: '12', nano: 1 }, }); upstream.getLastPrices.mockImplementation( (_token: string, uids: string[]) => Promise.resolve( uids.map((uid) => ({ instrumentUid: uid, price: { units: '99', nano: 100000001 }, time: { seconds: '1700000000' }, })), ), ); for (const userId of ['owner', 'other']) await prisma.tbankConnection.create({ data: { userId, revision: `${userId}-revision`, encryptedToken: app .get(TbankTokenService) .encrypt(`synthetic-${userId}`), connectedAt: new Date(), }, }); }); afterAll(async () => app?.close()); it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => { const found = deferred<(typeof share)[]>(); const detail = deferred(); upstream.findShares.mockReturnValueOnce(found.promise); upstream.getShare.mockReturnValueOnce(detail.promise); expect((await read()).data).toBeNull(); expect(upstream.findShares).not.toHaveBeenCalled(); const cold = await read(false); expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('missing'); found.resolve([share]); for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) await delay(5); const partial = await read(); expect(partial.data?.[0].instrumentUid).toBe('share'); expect(partial.meta.availability).toBe('partial'); expect(partial.data?.[0].price).toBeNull(); expect(partial.meta.resourceKey).toBe(cold.meta.resourceKey); expect( partial.meta.dependencies?.some( (dependency) => dependency.sync.state === 'running', ), ).toBe(true); detail.resolve({ ...share }); await settled(); expect((await read()).meta.availability).toBe('complete'); expect(upstream.findShares).toHaveBeenCalledTimes(1); }); it('reuses shared success after SQLite reopen and across users without decrypt, GetAccounts or broker RPC', async () => { await read(false); await settled(); const before = await read(); await prisma.$disconnect(); const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); const after = await read(false, 'other'); expect(after.data).toEqual(before.data); expect(after.data?.[0].price).toEqual({ currency: 'RUB', value: '99.100000001', }); expect( after.meta.dependencies?.every( (dependency) => dependency.sync.id === null, ), ).toBe(true); expect(decrypt).not.toHaveBeenCalled(); decrypt.mockRestore(); const metadata = await app .get(InstrumentMetadataService) .read('other', { instrumentUid: 'share' }); expect(metadata?.complete).toBe(true); expect(metadata?.data?.name).toBe(share.name); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getShare).toHaveBeenCalledTimes(1); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); expect(upstream.findShares).toHaveBeenCalledTimes(1); }); it('uses one price batch for thirty deduplicated selected UIDs, matching reversed prices by UID', async () => { upstream.findShares.mockResolvedValue([ { ...share, uid: 'duplicate', apiTradeAvailableFlag: false }, ...Array.from({ length: 35 }, (_, i) => ({ ...share, uid: `s${String(i).padStart(2, '0')}`, isin: i ? undefined : 'SHARE', })), ]); upstream.getLastPrices.mockImplementation( (_token: string, uids: string[]) => Promise.resolve( uids.toReversed().map((uid, i) => ({ instrumentUid: uid, price: { units: String(i + 1) }, })), ), ); await read(false); await settled(); const response = await read(); expect(response.data).toHaveLength(30); expect(response.data?.[0].instrumentUid).toBe('s00'); expect(response.data?.[0].price?.value).toBe('30'); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); expect(upstream.getShare).toHaveBeenCalledTimes(30); expect(upstream.findShares).toHaveBeenCalledTimes(1); }); it('keeps confirmed empty separate from unknown queries and does not enrich an empty result', async () => { upstream.findShares.mockResolvedValue([]); expect((await read(false)).data).toBeNull(); await settled(); expect((await read()).data).toEqual([]); const other = (await get('stocks?query=GAZP&sync=observe').expect(200)) .body as StockSearchResourceDto; expect(other.data).toBeNull(); expect(other.meta.availability).toBe('missing'); expect(upstream.getShare).not.toHaveBeenCalled(); expect(upstream.getLastPrices).not.toHaveBeenCalled(); }); it('retains catalog identity on enrichment failures, rejects mismatched detail UIDs and never returns fake prices', async () => { upstream.getShare.mockResolvedValue({ ...share, uid: 'another' }); upstream.getLastPrices.mockRejectedValue(new Error('outage')); await read(false); await settled(); const result = await read(); expect(result.data?.[0]).toMatchObject({ instrumentUid: 'share', ticker: 'SBER', price: null, }); expect(result.meta.availability).toBe('partial'); expect( result.meta.dependencies?.filter((item) => item.sync.state === 'failed'), ).toHaveLength(2); await read(); expect(upstream.getShare).toHaveBeenCalledTimes(1); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); }); it('keeps bond percent separate from money and preserves exact nominal and ACI', async () => { await get('bonds?query=BOND').expect(200); await settled(); const result = (await get('bonds?query=BOND&sync=observe').expect(200)) .body as BondSearchResourceDto; expect(result.data?.[0]).toMatchObject({ pricePercent: '99.100000001', nominal: { currency: 'RUB', value: '800' }, aci: { currency: 'RUB', value: '12.000000001' }, }); expect(result.data?.[0]).not.toHaveProperty('price'); expect(upstream.findShares).not.toHaveBeenCalled(); }); it('refreshes only prices at the minute boundary and preserves old prices after failure', async () => { await read(false); await settled(); const before = await read(); await prisma.tbankInstrumentSource.updateMany({ where: { kind: 'last-price' }, data: { fetchedAt: new Date(Date.now() - 60_000) }, }); upstream.getLastPrices.mockRejectedValue(new Error('outage')); await read(); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); await read(false); await settled(); const failed = await read(); expect(failed.data).toEqual(before.data); expect(failed.meta.freshness).toBe('stale'); expect(upstream.getShare).toHaveBeenCalledTimes(1); expect(upstream.findShares).toHaveBeenCalledTimes(1); expect(upstream.getLastPrices).toHaveBeenCalledTimes(2); }); it('coalesces sources across different queries and users, with private handles and no private fields in shared rows', async () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) await delay(5); await get('stocks?query=OTHER', 'other').expect(200); for ( let i = 0; i < 100 && (await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3; i++ ) await delay(5); detail.resolve({ ...share, currency: 'rub', quantity: 'private', estimatedAmount: 'private', } as typeof share); await settled(); expect(upstream.getShare).toHaveBeenCalledTimes(1); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); const owner = await read(); const other = (await get('stocks?query=OTHER&sync=observe', 'other')) .body as StockSearchResourceDto; expect(owner.meta.dependencies?.at(-1)?.sync.id).not.toBe( other.meta.dependencies?.at(-1)?.sync.id, ); expect( JSON.stringify(await prisma.tbankInstrumentSource.findMany()), ).not.toMatch(/quantity|estimatedAmount|private|encryptedToken/); }); it.each(['delete', 'replace'])( 'discards delayed enrichment after connection %s', async (action) => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) await delay(5); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); if (action === 'replace') await prisma.tbankConnection.create({ data: { userId: 'owner', revision: 'new', encryptedToken: app.get(TbankTokenService).encrypt('new'), connectedAt: new Date(), }, }); detail.resolve(share); await settled(); expect( await prisma.tbankInstrumentSource.count({ where: { kind: 'detail' } }), ).toBe(0); expect((await read(true, 'other')).data?.[0].instrumentUid).toBe('share'); }, ); it('recovers abandoned enrichment only on an ordinary request, and fences the superseded attempt', async () => { await read(false); await settled(); await prisma.tbankInstrumentSource.deleteMany({ where: { kind: 'detail' }, }); const repository = app.get(CatalogDiscoveryRepository); const input = { instrumentType: 'share', uid: 'share', kind: 'detail', } as const; const old = await repository.reserve( { userId: 'owner', revision: 'owner-revision' }, input, 'old-process', new Set(), ); assert(old); await read(); expect(upstream.getShare).toHaveBeenCalledTimes(1); await read(false); await settled(); expect(upstream.getShare).toHaveBeenCalledTimes(2); expect( await repository.publishSource( { userId: 'owner', revision: 'owner-revision', id: old.work.id, attemptId: old.work.attemptId, }, input, { instrument: { ...share, name: 'Late' } }, new Date(), ), ).toBe(false); }); it('preserves fresh successful sources on failed force and isolates credential failures', async () => { await read(false); await settled(); const before = await read(); upstream.getShare.mockRejectedValue(new Error('outage')); upstream.getLastPrices.mockRejectedValue(new Error('outage')); await app .get(CatalogEnrichmentService) .requestMany('owner', 'owner-revision', 'share', ['share'], true); await settled(); const after = await read(); expect(after.data).toEqual(before.data); expect(after.meta.freshness).toBe('fresh'); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); expect( (await read(true, 'other')).meta.dependencies?.every( (item) => item.sync.errorCode === null, ), ).toBe(true); }); it('rejects every price in a batch if its initiating attempt is superseded during the RPC', async () => { const prices = deferred<{ instrumentUid: string; price: { units: string } }[]>(); upstream.findShares.mockResolvedValue([ share, { ...share, uid: 'second', isin: 'SECOND' }, ]); upstream.getLastPrices.mockReturnValueOnce(prices.promise); await read(false); for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) await delay(5); const initiating = await prisma.tbankSyncWork.findFirstOrThrow({ where: { userId: 'owner', scopeKey: { contains: 'last-price' } }, orderBy: { createdAt: 'asc' }, }); await prisma.tbankSyncWork.update({ where: { id: initiating.id }, data: { attemptId: 'superseding-attempt', state: 'failed' }, }); prices.resolve([ { instrumentUid: 'share', price: { units: '100' } }, { instrumentUid: 'second', price: { units: '200' } }, ]); await settled(); expect( await prisma.tbankInstrumentSource.count({ where: { kind: 'last-price' }, }), ).toBe(0); expect((await read()).data?.every((item) => item.price === null)).toBe( true, ); }); it('refreshes stale detail independently of still-fresh prices and discovery', async () => { await read(false); await settled(); await prisma.tbankInstrumentSource.updateMany({ where: { kind: 'detail' }, data: { fetchedAt: new Date(Date.now() - 86_400_000) }, }); await read(); expect(upstream.getShare).toHaveBeenCalledTimes(1); await read(false); await settled(); expect(upstream.getShare).toHaveBeenCalledTimes(2); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); expect(upstream.findShares).toHaveBeenCalledTimes(1); }); it('reads exact UID locally without waiting, batches twenty reads and keeps UID accessible when alias is ambiguous', async () => { const uid = '00000000-0000-4000-8000-000000000001'; const blocked = deferred(); upstream.getShare.mockReturnValueOnce(blocked.promise); upstream.findShares.mockResolvedValue([ { ...share, uid }, { ...share, uid: 'other-listing', isin: 'OTHER' }, ]); expect( ((await get(`stocks/${uid}?sync=observe`)).body as StockResourceDto).data, ).toBeNull(); expect(upstream.getShare).not.toHaveBeenCalled(); const reads = await Promise.all( Array.from({ length: 20 }, () => get(`stocks/${uid}`).expect(200)), ); expect( reads.every( (response) => (response.body as StockResourceDto).data === null, ), ).toBe(true); expect(upstream.getLastPrices).not.toHaveBeenCalled(); blocked.resolve({ ...share, uid }); await settled(); const first = (await get(`stocks/${uid}`).expect(200)) .body as StockResourceDto; expect(first.data?.instrumentUid).toBe(uid); await settled(); const exact = (await get(`stocks/${uid}?sync=observe`).expect(200)) .body as StockResourceDto; expect(exact.data?.slug).toBe(uid); await get('stocks/by-ticker/SBER?sync=observe').expect(409); expect(upstream.getShare).toHaveBeenCalledTimes(1); }); it('resolves exact ticker before ISIN selection and does not mistake search completeness for ticker coverage', async () => { const uid = '00000000-0000-4000-8000-000000000002'; upstream.findShares.mockResolvedValue([ { ...share, uid: 'preferred', ticker: 'SBERP' }, { ...share, uid, ticker: 'SBER', apiTradeAvailableFlag: false }, ]); await read(false); await settled(); expect( ( (await get('stocks/by-ticker/SBER?sync=observe')) .body as StockResourceDto ).data, ).toBeNull(); const cold = (await get('stocks/by-ticker/sber').expect(200)) .body as StockResourceDto; expect(cold.data).toBeNull(); await settled(); const resolved = ( await get('stocks/by-ticker/SBER?sync=observe', 'other').expect(200) ).body as StockResourceDto; expect(resolved.data).toMatchObject({ instrumentUid: uid, slug: 'SBER' }); expect(upstream.findShares).toHaveBeenCalledTimes(2); upstream.findShares.mockResolvedValue([]); await get('stocks/by-ticker/NONE').expect(200); await settled(); await get('stocks/by-ticker/NONE?sync=observe').expect(404); }); it('keeps known typed UID misses separate from unknown and from generic UID lookup', async () => { const uid = '00000000-0000-4000-8000-000000000003'; upstream.getBond.mockResolvedValue(null); const cold = (await get(`bonds/${uid}`).expect(200)) .body as StockResourceDto; expect(cold.data).toBeNull(); await settled(); await get(`bonds/${uid}?sync=observe`).expect(404); expect( await prisma.tbankInstrumentLookup.count({ where: { identifier: uid } }), ).toBe(0); expect(upstream.getLastPrices).not.toHaveBeenCalled(); }); it('loads company and fundamentals locally, shares successes and keeps old metrics on failed refresh', async () => { const uid = '00000000-0000-4000-8000-000000000011'; const company = deferred(); upstream.getAssetDescription.mockReturnValueOnce(company.promise); const cold = (await get(`stocks/${uid}/company`).expect(200)) .body as StockCompanyResourceDto; expect(cold.data).toBeNull(); for ( let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ ) await delay(5); const partial = ( await get(`stocks/${uid}/company?sync=observe`).expect(200) ).body as StockCompanyResourceDto; expect(partial.meta.availability).toBe('partial'); company.resolve('Synthetic company'); await settled(); const warm = (await get(`stocks/${uid}/company`, 'other').expect(200)) .body as StockCompanyResourceDto; expect(warm.data?.description).toBe('Synthetic company'); expect(warm.data?.metrics.map((item) => item.key)).toEqual([ 'peRatioTtm', 'netIncomeTtm', ]); expect(upstream.getFundamentals).toHaveBeenCalledTimes(1); expect(upstream.getAssetDescription).toHaveBeenCalledTimes(1); upstream.getFundamentals.mockRejectedValue(new Error('outage')); upstream.getAssetDescription.mockRejectedValue(new Error('outage')); await app .get(CatalogEnrichmentService) .requestMany('owner', 'owner-revision', 'share', [uid], true, [ 'description', 'fundamentals', ]); await settled(); const failed = (await get(`stocks/${uid}/company?sync=observe`).expect(200)) .body as StockCompanyResourceDto; expect(failed.data?.metrics).toEqual(warm.data?.metrics); expect(failed.data?.description).toBe(warm.data?.description); expect(failed.data?.observedAt).toBe(warm.data?.observedAt); expect(failed.data?.unavailable).toEqual(['description', 'fundamentals']); }); it('reads stock quotes without waiting for blocked prices and reuses quote components across users', async () => { const uid = '00000000-0000-4000-8000-000000000012'; const prices = deferred< { instrumentUid: string; price: { units: string }; time: { seconds: string }; }[] >(); upstream.getLastPrices.mockReturnValueOnce(prices.promise); upstream.getCandles.mockResolvedValue([ { time: { seconds: '1699833600' }, close: { units: '90' }, isComplete: true, }, ]); const cold = (await get(`stocks/${uid}/quote`).expect(200)) .body as StockQuoteResourceDto; expect(cold.meta.availability).toBe('missing'); for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) await delay(5); const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200)) .body as StockQuoteResourceDto; expect(pending.data?.price).toBeNull(); prices.resolve([ { instrumentUid: uid, price: { units: '99' }, time: { seconds: '1700000000' }, }, ]); await settled(); const warm = (await get(`stocks/${uid}/quote`, 'other').expect(200)) .body as StockQuoteResourceDto; expect(warm.data).toMatchObject({ price: { currency: 'RUB', value: '99' }, previousClose: { currency: 'RUB', value: '90' }, change: { currency: 'RUB', value: '9' }, changePercent: '10', }); expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); expect(upstream.getTradingStatus).toHaveBeenCalledTimes(1); expect(upstream.getCandles).toHaveBeenCalledTimes(1); expect( await prisma.tbankCandle.count({ where: { instrumentUid: uid } }), ).toBe(1); await app .get(CatalogEnrichmentService) .requestMany('owner', 'owner-revision', 'share', [uid], true, [ 'comparison', ]); await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(1); }); it('keeps bond quote money exact and coupon metrics available when optional MOEX/NCR fail', async () => { const uid = '00000000-0000-4000-8000-000000000013'; upstream.getBond.mockResolvedValue({ ...bond, uid, currency: 'rub', nominal: { currency: 'rub', units: '800' }, aciValue: { currency: 'rub', units: '12', nano: 1 }, riskLevel: 'RISK_LEVEL_LOW', }); upstream.getBondCoupons.mockResolvedValue([ { couponDate: { seconds: String(Math.floor(Date.now() / 1000) + 86400 * 7), }, payOneBond: { currency: 'rub', units: '30' }, couponPeriod: 90, }, ]); moex.bondYield.mockRejectedValue(new Error('MOEX outage')); ratings.rating.mockRejectedValue(new Error('NCR outage')); await get(`bonds/${uid}/metrics`).expect(200); await settled(); const metrics = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200)) .body as BondMetricsResourceDto; expect(metrics.meta.availability).toBe('partial'); expect(metrics.data).toMatchObject({ coupon: { currency: 'RUB', value: '30' }, creditRating: null, effectiveYield: null, }); expect(metrics.data?.couponYieldPercent).not.toBeNull(); expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'BOND', 'TQOB'); expect(ratings.rating).toHaveBeenCalledWith('BOND'); const quote = (await get(`bonds/${uid}/quote?sync=observe`).expect(200)) .body as BondQuoteResourceDto; expect(quote.data).toMatchObject({ pricePercent: '99.100000001', cleanPrice: { currency: 'RUB', value: '792.800000008' }, dirtyPrice: { currency: 'RUB', value: '804.800000009' }, }); expect( await prisma.tbankInstrumentSource.count({ where: { source: 'moex' } }), ).toBe(0); expect(upstream.getBond).toHaveBeenCalledTimes(1); }); it('serves saved candle envelopes without waiting for RPC and never runs observe work', async () => { const uid = '00000000-0000-4000-8000-000000000017'; upstream.getShare.mockResolvedValue({ ...share, uid, currency: 'rub', first_1dayCandleDate: { seconds: '1577836800' }, }); const blocked = deferred(); upstream.getCandles.mockReturnValueOnce(blocked.promise); const cold = (await get(`stocks/${uid}/candles?period=all`).expect(200)) .body as StockCandlesResourceDto; expect(cold.data).toBeNull(); await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200); blocked.resolve([]); await settled(); const warm = ( await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200) ).body as StockCandlesResourceDto; expect(warm.data).toMatchObject({ candles: [], currency: 'RUB', historyFrom: '2020-01-01T00:00:00.000Z', }); expect(warm.meta.availability).toBe('complete'); const count = upstream.getCandles.mock.calls.length; await get(`stocks/${uid}/candles?period=all`, 'other').expect(200); expect(upstream.getCandles).toHaveBeenCalledTimes(count); await get(`stocks/${uid}/candles?sync=invalid`).expect(400); }); it('keeps unknown all-history bounds unavailable instead of confirming empty history', async () => { const uid = '00000000-0000-4000-8000-000000000018'; await get(`stocks/${uid}/candles?period=all`).expect(200); await settled(); const response = await get( `stocks/${uid}/candles?period=all&sync=observe`, ).expect(503); expect(JSON.stringify(response.body)).toContain( 'STOCK_HISTORY_START_UNAVAILABLE', ); expect(upstream.getCandles).not.toHaveBeenCalled(); expect(await prisma.tbankCandleCoverage.count()).toBe(0); }); it('coalesces a second user arriving while SQLite publication is pending', async () => { const uid = '00000000-0000-4000-8000-000000000016'; const repository = app.get(CatalogDiscoveryRepository); const original = repository.publishSource.bind(repository); const entered = deferred(); const release = deferred(); const publish = jest .spyOn(repository, 'publishSource') .mockImplementationOnce(async (...args) => { entered.resolve(); await release.promise; return original(...args); }); const service = app.get(CatalogEnrichmentService); try { await service.requestMany( 'owner', 'owner-revision', 'share', [uid], false, ['detail'], ); await entered.promise; await service.requestMany( 'other', 'other-revision', 'share', [uid], false, ['detail'], ); release.resolve(); await settled(); expect(upstream.getShare).toHaveBeenCalledTimes(1); const work = await prisma.tbankSyncWork.findMany(); expect(work).toHaveLength(2); expect(work.every((row) => row.state === 'complete')).toBe(true); } finally { release.resolve(); publish.mockRestore(); } }); it('persists MOEX/NCR independently and retains their exact values after failed force', async () => { const uid = '00000000-0000-4000-8000-000000000015'; upstream.getBond.mockResolvedValue({ ...bond, uid }); const effectiveYield = { value: '13.123456789', pricePercent: '99.100000001', date: '2028-01-01', dateType: 'MATDATE', tradeAt: '2026-09-25T10:00:00.000Z', }; const creditRating = { agency: 'NCR', value: 'AAA.ru', status: 'active', date: '2026-09-25', sourceUrl: 'https://ratings.ru/ratings/issues/', observedAt: '2026-09-25T10:00:00.000Z', }; moex.bondYield.mockResolvedValue(effectiveYield); ratings.rating.mockResolvedValue(creditRating); await get(`bonds/${uid}/metrics`).expect(200); await settled(); const before = (await get(`bonds/${uid}/metrics`, 'other').expect(200)) .body as BondMetricsResourceDto; expect(before.data).toMatchObject({ effectiveYield, creditRating }); expect(moex.bondYield).toHaveBeenCalledTimes(1); expect(ratings.rating).toHaveBeenCalledTimes(1); const saved = await prisma.tbankInstrumentSource.findMany({ where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } }, }); expect(saved).toHaveLength(2); await prisma.$disconnect(); moex.bondYield.mockRejectedValue(new Error('MOEX outage')); ratings.rating.mockRejectedValue(new Error('NCR outage')); await app .get(CatalogEnrichmentService) .requestMany('owner', 'owner-revision', 'bond', [uid], true, [ 'yield', 'rating', ]); await settled(); const after = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200)) .body as BondMetricsResourceDto; expect(after.data).toMatchObject({ effectiveYield, creditRating }); expect( await prisma.tbankInstrumentSource.findMany({ where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } }, }), ).toEqual(saved); }); it('discards a company response from a deleted connection and does not disclose its failure to another user', async () => { const uid = '00000000-0000-4000-8000-000000000014'; const company = deferred(); upstream.getAssetDescription.mockReturnValueOnce(company.promise); await get(`stocks/${uid}/company`).expect(200); for ( let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ ) await delay(5); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); company.resolve('Late company'); await settled(); expect( await prisma.tbankInstrumentSource.count({ where: { kind: 'description' }, }), ).toBe(0); const other = ( await get(`stocks/${uid}/company?sync=observe`, 'other').expect(200) ).body as StockCompanyResourceDto; expect( other.meta.dependencies?.every((item) => item.sync.errorCode === null), ).toBe(true); }); it('enforces access and query validation without broker calls', async () => { await request(app.getHttpServer()) .get('/api/v1/stocks?query=SBER') .expect(401); await get('stocks?query=x').expect(400); await get('bonds?query=BOND&sync=force').expect(400); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); await get().expect(409); await prisma.tbankConnection.update({ where: { userId: 'other' }, data: { invalidToken: true }, }); await get('stocks?query=SBER', 'other').expect(400); expect(upstream.findShares).not.toHaveBeenCalled(); expect(upstream.findBonds).not.toHaveBeenCalled(); }); }); function deferred() { let resolve: (value: T) => void = () => { throw new Error('Uninitialized deferred'); }; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }