915 lines
34 KiB
TypeScript
915 lines
34 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('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 };
|
|
}
|