Files
backend/test/tbank-catalog-local.e2e-spec.ts
T
ksv741 6cd9c2dd28
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 11m54s
perf: group catalog source reads by identity
2026-09-28 16:37:01 +03:00

959 lines
36 KiB
TypeScript

import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import {
type BondMetricsResourceDto,
type BondQuoteResourceDto, type BondSearchResourceDto,
} from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import {
type StockCandlesResourceDto,
type StockCompanyResourceDto,
type StockQuoteResourceDto,
type StockResourceDto,
type StockSearchResourceDto,
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import { PrismaService } from '../src/prisma/prisma.service';
// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races.
describe('local stock/bond search (SQLite HTTP)', () => {
let app: INestApplication<Server>;
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('reads only requested source identities when batching UIDs, types and periods', async () => {
const repository = app.get(CatalogDiscoveryRepository);
const fetchedAt = new Date();
for (const uid of ['a', 'b']) {
await prisma.tbankInstrument.create({
data: { provider: 'tbank', uid, instrumentType: 'share', metadata: {}, fetchedAt },
});
}
const expected = [
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'share' },
{ instrumentUid: 'b', source: 'tbank', kind: 'detail', rangeKey: 'share' },
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
{ instrumentUid: 'b', source: 'moex', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'a', source: 'ncr', kind: 'rating', rangeKey: '' },
];
const distractors = [
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'bond' },
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
{ instrumentUid: 'a', source: 'moex', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'b', source: 'tbank', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'a', source: 'tbank', kind: 'rating', rangeKey: '' },
];
await prisma.tbankInstrumentSource.createMany({
data: [...expected, ...distractors].map((identity) => ({
...identity, provider: 'tbank', fetchedAt, payload: identity,
})),
});
const rows = await repository.readSources([
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
{ uid: 'b', instrumentType: 'share', kind: 'detail' },
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
{ uid: 'a', instrumentType: 'share', kind: 'dividends', rangeKey: '2026' },
{ uid: 'b', instrumentType: 'share', kind: 'dividends', rangeKey: '2027' },
{ uid: 'b', instrumentType: 'bond', kind: 'bond-events' },
{ uid: 'a', instrumentType: 'bond', kind: 'rating' },
{ uid: 'missing', instrumentType: 'share', kind: 'detail' },
]);
expect(rows.map((row) => row.payload)).toEqual(expect.arrayContaining(expected));
expect(rows).toHaveLength(expected.length);
expect(await repository.readSources([])).toEqual([]);
});
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<typeof share>();
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<typeof share>();
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<typeof share>();
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<typeof share>();
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<string>();
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<unknown[]>();
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<void>();
const release = deferred<void>();
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<string>();
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<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}