feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s

Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
This commit is contained in:
2026-09-26 21:29:15 +03:00
parent 0862d56c4e
commit d6e6c4ace5
116 changed files with 18321 additions and 5409 deletions
+903
View File
@@ -0,0 +1,903 @@
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<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('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 };
}