chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1');
});
});
describe('decimal formatting', () => {
it.each([
[120000n, 4, '12'],
[-120030n, 4, '-12.003'],
[1n, 4, '0.0001'],
[0n, 4, '0'],
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
expect(formatDecimal({ value, scale })).toBe(expected);
});
it('preserves a long fraction with a trailing zero run', () => {
const significant = '1'.repeat(10_000);
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal';
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function decimal(value: ProtoDecimal): Decimal {
return {
@@ -1,24 +1,26 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type {
Prisma,
TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { tbankSourceTtlMs } from './tbank-source.policy';
export type MetadataIdentity = {
instrumentUid?: string | null;
figi?: string | null;
figi?: null | string;
instrumentUid?: null | string;
};
export type MetadataLookup = {
type: 'uid' | 'figi';
data: null | TbankInstrument;
id: string;
data: TbankInstrument | null;
type: 'figi' | 'uid';
};
const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
@@ -36,15 +38,16 @@ export class InstrumentMetadataRepository {
async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim()
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
: []),
...(identity.figi?.trim()
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []),
...(identity.instrumentUid?.trim() ?
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
[]),
...(identity.figi?.trim() ?
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
[]),
]);
if (!identifiers.length)
if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) {
@@ -59,7 +62,7 @@ export class InstrumentMetadataRepository {
let offset = 0;
offset < values.length;
offset += metadataReadBatchSize
)
) {
lookups.push(
...(await tx.tbankInstrumentLookup.findMany({
where: {
@@ -71,6 +74,7 @@ export class InstrumentMetadataRepository {
},
})),
);
}
}
const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
@@ -80,7 +84,7 @@ export class InstrumentMetadataRepository {
let offset = 0;
offset < uids.length;
offset += metadataReadBatchSize
)
) {
instruments.push(
...(await tx.tbankInstrument.findMany({
where: {
@@ -89,6 +93,7 @@ export class InstrumentMetadataRepository {
},
})),
);
}
const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
);
@@ -103,7 +108,7 @@ export class InstrumentMetadataRepository {
}
async reserve(
attemptOwner: { userId: string; revision: string },
attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity,
runId: string,
active: ReadonlySet<string>,
@@ -125,8 +130,9 @@ export class InstrumentMetadataRepository {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
@@ -183,12 +189,13 @@ export class InstrumentMetadataRepository {
['uid', uid],
['figi', figi],
] as const) {
if (id && uid && !aliases.has(`${type}:${id}`))
if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, {
type,
id,
data: { ...result.data, uid },
});
}
}
}
for (const result of aliases.values()) {
@@ -196,8 +203,9 @@ export class InstrumentMetadataRepository {
const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid)
if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) {
const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({
@@ -220,9 +228,9 @@ export class InstrumentMetadataRepository {
update: {
...data,
dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload)
? 0
: 1,
increment: isDeepStrictEqual(previous?.metadata, payload) ?
0 :
1,
},
},
});
@@ -243,9 +251,9 @@ export class InstrumentMetadataRepository {
matchedUids,
fetchedAt: now,
dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? 0
: 1,
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
0 :
1,
},
},
});
@@ -266,7 +274,7 @@ export class InstrumentMetadataRepository {
errorCode: success ? null : 'TBANK_UNAVAILABLE',
},
});
if (success)
if (success) {
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
@@ -275,6 +283,7 @@ export class InstrumentMetadataRepository {
},
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
});
}
}
}
@@ -283,7 +292,7 @@ function selectMetadata(
lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>,
) {
const resolve = (type: string, id: string | null | undefined) => {
const resolve = (type: string, id: null | string | undefined) => {
const lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined;
@@ -307,6 +316,7 @@ function selectMetadata(
complete,
fresh:
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt,
@@ -1,23 +1,24 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { TbankClient, type TbankInstrument } from './tbank.client';
import {
InstrumentMetadataRepository,
metadataKey,
type MetadataIdentity,
type MetadataLookup,
} from './instrument-metadata.repository';
export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository';
type Producer = {
identity: MetadataIdentity;
attempt: TbankSyncAttempt;
identity: MetadataIdentity;
observers: TbankSyncAttempt[];
};
@Injectable()
@@ -49,8 +50,9 @@ export class InstrumentMetadataService {
const connection = await this.access.local(userId);
if (revision && connection.revision !== revision) return null;
const data = (await this.savedMany([identity])).get(metadataKey(identity));
if (!observe && !data?.fresh)
if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity);
}
return {
...data,
work: await this.repository.work(userId, connection.revision, identity),
@@ -119,7 +121,7 @@ export class InstrumentMetadataService {
let useful = false;
let uidFailed = false;
if (identity.instrumentUid) {
let data: TbankInstrument | null | undefined;
let data: null | TbankInstrument | undefined;
try {
data = await this.tbank.getInstrumentByUid(
token,
@@ -130,29 +132,37 @@ export class InstrumentMetadataService {
!identity.figi ||
(error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN')
)
) {
throw error;
}
uidFailed = true;
}
if (data?.uid && data.uid !== identity.instrumentUid)
if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch');
if (data !== undefined)
}
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi)
if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata');
}
}
if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi(
token,
identity.figi,
);
if (data?.figi && data.figi !== identity.figi)
if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch');
if (data && !data.name?.trim() && !data.ticker?.trim())
}
if (data && !data.name?.trim() && !data.ticker?.trim()) {
throw new Error('Incomplete metadata');
if (data === undefined || (uidFailed && !data))
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
if (data === undefined || (uidFailed && !data)) {
throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup.
@@ -160,8 +170,9 @@ export class InstrumentMetadataService {
uidFailed &&
data?.uid === identity.instrumentUid &&
identity.instrumentUid
)
) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
}
if (
!results.length ||
@@ -170,8 +181,9 @@ export class InstrumentMetadataService {
(result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(),
))
)
) {
throw new Error('Incomplete metadata');
}
success = await this.repository.publish(
attempt,
results,
@@ -179,12 +191,13 @@ export class InstrumentMetadataService {
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(attempt);
else
} else {
this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE',
});
}
}
// Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained.
+25 -15
View File
@@ -1,4 +1,3 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import {
BadRequestException,
ConflictException,
@@ -6,16 +5,19 @@ import {
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string;
id: string;
revision: string;
userId: string;
};
@Injectable()
@@ -48,8 +50,9 @@ export class TbankAccessService {
connection.membershipAt,
new Date(),
)
)
) {
return null;
}
const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
@@ -58,8 +61,9 @@ export class TbankAccessService {
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId,
@@ -100,7 +104,7 @@ export class TbankAccessService {
return result.count === 1;
}
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
...attempt,
@@ -119,8 +123,9 @@ export class TbankAccessService {
completeWork = false,
): Promise<boolean> {
const supported = supportedTbankAccounts(accounts);
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below.
@@ -146,6 +151,7 @@ export class TbankAccessService {
const changed =
previous.length !== next.length ||
previous.some((account, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
const incoming = next[index];
return (
account.accountId !== incoming.accountId ||
@@ -181,11 +187,12 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? 1 : 0 },
},
});
if (completeWork)
if (completeWork) {
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } },
});
}
return true;
});
}
@@ -224,17 +231,20 @@ export class TbankAccessService {
accounts: { orderBy: { upstreamOrder: 'asc' } },
},
});
if (!connection)
if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
}
if (connection.invalidToken) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
if (
connection.membershipAt &&
accountId &&
accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId)
)
) {
throw new NotFoundException();
}
return connection;
}
}
@@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = {
id: string;
name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
};
export function supportedTbankAccounts(
@@ -39,7 +39,7 @@ export function supportedTbankAccounts(
});
}
function openedOn(seconds: string | number | undefined): string | null {
function openedOn(seconds: number | string | undefined): null | string {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null;
+23 -23
View File
@@ -6,23 +6,23 @@ import type {
} from './tbank.client';
export type TbankShare = TbankInstrument & {
uid?: string;
figi?: string;
apiTradeAvailableFlag?: boolean;
assetUid?: string;
classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean;
currency?: string;
shareType?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
figi?: string;
first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
};
export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal;
isComplete?: boolean;
time?: TbankTimestamp;
};
export type TbankLastPrice = {
instrumentUid?: string;
@@ -32,38 +32,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = {
assetUid?: string;
currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number;
priceToBookTtm?: number;
revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number;
totalDebtMrq?: number;
netDebtToEbitda?: number;
};
export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney;
maturityDate?: TbankTimestamp;
amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp;
couponQuantityPerYear?: number;
floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string;
subordinatedFlag?: boolean;
};
export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp;
payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
};
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
TbankAccessService,
@@ -8,6 +8,7 @@ describe('request-driven source freshness', () => {
'%s expires at its boundary without changing source time',
(source) => {
const fetchedAt = new Date('2026-09-01Z');
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
const ttl = tbankSourceTtlMs[source];
expect(
tbankSourceNeedsRefresh(
@@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh(
return (
force ||
!fetchedAt ||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
);
}
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation';
import { TbankCrypto } from './tbank.crypto';
@@ -25,8 +26,9 @@ export class TbankTokenService {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
throw error;
}
}
+11 -11
View File
@@ -1,9 +1,12 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export {
type TbankBond,
type TbankCandle,
@@ -12,8 +15,15 @@ export {
type TbankLastPrice,
type TbankShare,
} from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export {
TbankClient,
type TbankDividend,
@@ -25,13 +35,3 @@ export {
type TbankPositions,
type TbankTimestamp,
} from './tbank.client';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceTtlMs,
tbankSourceNeedsRefresh,
} from './tbank-source.policy';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
+16 -18
View File
@@ -1,5 +1,6 @@
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -57,6 +58,7 @@ describe('TbankClient operations', () => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({});
(client as unknown as { call: jest.Mock }).call = call;
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
expect(call).toHaveBeenCalledWith('token', undefined, method, {
instrumentId: 'uid',
@@ -111,11 +113,10 @@ describe('TbankClient operations', () => {
ok: true,
});
const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
await jest.advanceTimersByTimeAsync(1000);
await rejected;
await Promise.all([
expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
jest.advanceTimersByTimeAsync(1000),
]);
expect(logger.event).toHaveBeenCalledWith(
'debug',
@@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient;
let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock };
let queue: { run: jest.Mock; size: number };
let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
};
@@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
]);
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
@@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying an unavailable upstream after three RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
]);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0);
@@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => {
async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
+71 -62
View File
@@ -1,4 +1,3 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import {
ChannelCredentials,
Client,
@@ -17,8 +16,6 @@ import {
import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type {
TbankBond,
@@ -29,6 +26,10 @@ import type {
TbankShare,
} from './tbank-market.types';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation';
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
@@ -42,78 +43,78 @@ type Unary<Request, Response> = (
request: Request,
metadata: Metadata,
options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void,
callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>;
export type TbankDecimal = { units?: string | number; nano?: string | number };
export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = {
id: string;
type: string;
name: string;
openedDate?: { seconds?: string | number };
openedDate?: { seconds?: number | string };
type: string;
};
export type TbankPortfolio = {
positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[];
};
export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = {
uid?: string;
brand?: { logoName?: null | string };
figi?: string;
instrumentType?: string;
ticker?: string;
name?: string;
isin?: string;
brand?: { logoName?: string | null };
name?: string;
ticker?: string;
uid?: string;
};
export type TbankDividend = {
dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string;
lastBuyDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
recordDate?: TbankTimestamp;
};
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
export type TbankTimestamp = { nanos?: number; seconds?: number | string };
export type TbankOperationItem = {
state?: string;
accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp;
type?: string;
description?: string;
figi?: string;
instrumentUid?: string;
id?: string;
instrumentType?: string;
ticker?: string;
instrumentUid?: string;
name?: string;
payment?: TbankMoney;
price?: TbankMoney;
commission?: TbankMoney;
accruedInt?: TbankMoney;
quantity?: string | number;
quantityDone?: string | number;
quantity?: number | string;
quantityDone?: number | string;
state?: string;
ticker?: string;
type?: string;
};
export type TbankOperationsByCursorRequest = {
accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number;
state: 'OPERATION_STATE_EXECUTED';
to?: TbankTimestamp;
};
export type TbankOperationsByCursorResponse = {
hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[];
nextCursor?: string;
};
type TbankNamespace = {
tinkoff: {
@@ -197,12 +198,14 @@ export class TbankClient {
loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath
? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl();
const credentials = caPath ?
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
ChannelCredentials.createSsl(readFileSync(caPath)) :
ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) =>
new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new (
address: string,
credentials: ChannelCredentials,
@@ -268,14 +271,14 @@ export class TbankClient {
getInstrumentByFigi(
token: string,
figi: string,
): Promise<TbankInstrument | null> {
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<TbankInstrument | null> {
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
}
@@ -320,7 +323,8 @@ export class TbankClient {
},
).then((response) => response.instruments ?? []);
}
getBond(token: string, uid: string): Promise<TbankBond | null> {
getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>(
token,
this.sparseInstruments,
@@ -331,6 +335,7 @@ export class TbankClient {
},
).then((response) => response.instrument ?? null);
}
getBondCoupons(
token: string,
uid: string,
@@ -349,7 +354,7 @@ export class TbankClient {
).then((response) => response.events ?? []);
}
getShare(token: string, uid: string): Promise<TbankShare | null> {
getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>(
token,
this.instruments,
@@ -361,7 +366,7 @@ export class TbankClient {
).then((response) => response.instrument ?? null);
}
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>(
token,
this.instruments,
@@ -375,7 +380,7 @@ export class TbankClient {
getFundamentals(
token: string,
assetUid: string,
): Promise<TbankFundamentals | null> {
): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>(
token,
this.sparseInstruments,
@@ -399,13 +404,13 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>(
token,
this.marketData,
@@ -442,9 +447,9 @@ export class TbankClient {
private async getInstrument(
token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
id: string,
): Promise<TbankInstrument | null> {
): Promise<null | TbankInstrument> {
try {
const response = await this.call<{ instrument?: TbankInstrument }>(
token,
@@ -471,8 +476,9 @@ export class TbankClient {
const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull)
if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
@@ -484,15 +490,15 @@ export class TbankClient {
return response;
} catch (error) {
const grpcCode = (error as ServiceError).code;
const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' }
: { grpcCode };
const retryable = queueFull
? metadataCall
: [
status.UNAVAILABLE,
const failureContext = queueFull ?
{ code: 'TBANK_UNAVAILABLE' } :
{ grpcCode };
const retryable = queueFull ?
metadataCall :
[
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', {
@@ -504,9 +510,9 @@ export class TbankClient {
await new Promise((resolve) =>
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
metadataCall ?
attempts * METADATA_RETRY_DELAY_MS :
RPC_RETRY_DELAY_MS,
),
);
continue;
@@ -518,15 +524,17 @@ export class TbankClient {
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
)
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN');
}
if (
grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall)
)
) {
throw new NotFoundException();
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
}
@@ -542,6 +550,7 @@ export class TbankClient {
return new Promise((resolve, reject) => {
const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`);
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
client[method](
request as never,
metadata,
+2 -1
View File
@@ -11,8 +11,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64');
if (decoded.length !== KEY_SIZE_BYTES)
if (decoded.length !== KEY_SIZE_BYTES) {
throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded);
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { TbankMembershipService } from './tbank-membership.service';
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client';