refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
This commit is contained in:
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
if (success) {
|
||||
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
|
||||
state: 'running',
|
||||
connection: { invalidToken: true },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
|
||||
if (
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
} else {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
@@ -103,7 +104,7 @@ export class TbankAccessService {
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
@@ -205,7 +206,7 @@ export class TbankAccessService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
await tx.tbankConnection.update({
|
||||
@@ -236,10 +237,10 @@ export class TbankAccessService {
|
||||
},
|
||||
});
|
||||
if (!connection) {
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
}
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { TbankAccountType } from './tbank-protocol';
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
||||
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
[TbankAccountType.TinkoffIis]: {
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
},
|
||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
ACCOUNT_TYPE_INVEST_FUND: {
|
||||
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
[TbankAccountType.InvestFund]: {
|
||||
type: 'MONEY_MARKET_FUND',
|
||||
name: 'Фонд денежного рынка',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum TbankErrorCode {
|
||||
InvalidToken = 'INVALID_TBANK_TOKEN',
|
||||
NotConnected = 'TBANK_NOT_CONNECTED',
|
||||
Unavailable = 'TBANK_UNAVAILABLE',
|
||||
}
|
||||
|
||||
// Error messages and persisted codes cross an open string boundary.
|
||||
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
|
||||
return value === expected;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Injectable()
|
||||
@@ -37,7 +38,7 @@ export class TbankMembershipService {
|
||||
.catch(() =>
|
||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||
userId,
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
}),
|
||||
)
|
||||
.finally(() => this.active.delete(attemptId));
|
||||
@@ -62,7 +63,7 @@ export class TbankMembershipService {
|
||||
} catch (error) {
|
||||
await this.access.failMembership(
|
||||
attempt,
|
||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import {
|
||||
InstrumentIdType,
|
||||
isTbankProtocolValue,
|
||||
TbankAccountStatus,
|
||||
TbankAccountType,
|
||||
TbankCandleInterval,
|
||||
TbankCouponType,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
TbankOperationType,
|
||||
} from './tbank-protocol';
|
||||
|
||||
it('uses the string enum values declared by the vendored protocol', () => {
|
||||
const directory = join(__dirname, 'proto/contracts');
|
||||
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
|
||||
for (const [name, values] of Object.entries({
|
||||
InstrumentIdType,
|
||||
InstrumentType: TbankInstrumentType,
|
||||
AccountStatus: TbankAccountStatus,
|
||||
AccountType: TbankAccountType,
|
||||
CandleInterval: TbankCandleInterval,
|
||||
CouponType: TbankCouponType,
|
||||
OperationState: TbankOperationState,
|
||||
OperationType: TbankOperationType,
|
||||
})) {
|
||||
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
|
||||
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
|
||||
const descriptor = definition as { type: { value: { name: string }[] } };
|
||||
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes supported wire values without accepting unknown ones', () => {
|
||||
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
|
||||
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
|
||||
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
|
||||
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// String wire values for the protocol subset used by this integration.
|
||||
// proto-loader decodes enums as strings; vendor contracts remain the source.
|
||||
export enum InstrumentIdType {
|
||||
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentType {
|
||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||
}
|
||||
|
||||
export enum TbankAccountStatus {
|
||||
Open = 'ACCOUNT_STATUS_OPEN',
|
||||
}
|
||||
|
||||
export enum TbankAccountType {
|
||||
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
|
||||
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
|
||||
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
|
||||
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
}
|
||||
|
||||
export enum TbankCandleInterval {
|
||||
Day = 'CANDLE_INTERVAL_DAY',
|
||||
}
|
||||
|
||||
export enum TbankOperationState {
|
||||
Executed = 'OPERATION_STATE_EXECUTED',
|
||||
}
|
||||
|
||||
export enum TbankCouponType {
|
||||
Floating = 'COUPON_TYPE_FLOATING',
|
||||
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
|
||||
}
|
||||
|
||||
export enum TbankOperationType {
|
||||
BondTax = 'OPERATION_TYPE_BOND_TAX',
|
||||
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
|
||||
Buy = 'OPERATION_TYPE_BUY',
|
||||
BuyCard = 'OPERATION_TYPE_BUY_CARD',
|
||||
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
|
||||
Coupon = 'OPERATION_TYPE_COUPON',
|
||||
DivExt = 'OPERATION_TYPE_DIV_EXT',
|
||||
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||
Input = 'OPERATION_TYPE_INPUT',
|
||||
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||
Output = 'OPERATION_TYPE_OUTPUT',
|
||||
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||
Sell = 'OPERATION_TYPE_SELL',
|
||||
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
|
||||
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
|
||||
Tax = 'OPERATION_TYPE_TAX',
|
||||
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
|
||||
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
|
||||
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
|
||||
}
|
||||
|
||||
// Provider responses may contain values newer than our supported protocol subset.
|
||||
export function isTbankProtocolValue(
|
||||
value: unknown,
|
||||
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
|
||||
): boolean {
|
||||
return value === expected;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@Injectable()
|
||||
@@ -26,8 +27,8 @@ export class TbankTokenService {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import { TbankOperationState } from './tbank-protocol';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
state: TbankOperationState.Executed,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
|
||||
@@ -29,6 +29,14 @@ import type {
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import {
|
||||
InstrumentIdType,
|
||||
TbankAccountStatus,
|
||||
TbankCandleInterval,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
} from './tbank-protocol';
|
||||
|
||||
const INITIAL_REQUEST_COUNT = 0;
|
||||
const MIN_RATE_DELAY_MS = 1;
|
||||
@@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = {
|
||||
from?: TbankTimestamp;
|
||||
instrumentId?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
state: TbankOperationState.Executed;
|
||||
to?: TbankTimestamp;
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
@@ -256,7 +264,7 @@ export class TbankClient {
|
||||
token,
|
||||
this.users,
|
||||
'getAccounts',
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ status: TbankAccountStatus.Open },
|
||||
).then((response) => response.accounts ?? []);
|
||||
}
|
||||
|
||||
@@ -279,14 +287,14 @@ export class TbankClient {
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||
}
|
||||
|
||||
getDividends(
|
||||
@@ -314,7 +322,7 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
instrumentKind: TbankInstrumentType.Share,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
@@ -326,7 +334,7 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
instrumentKind: TbankInstrumentType.Bond,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
@@ -337,7 +345,7 @@ export class TbankClient {
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -367,7 +375,7 @@ export class TbankClient {
|
||||
this.instruments,
|
||||
'shareBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -446,7 +454,7 @@ export class TbankClient {
|
||||
to: {
|
||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||
},
|
||||
interval: 'CANDLE_INTERVAL_DAY',
|
||||
interval: TbankCandleInterval.Day,
|
||||
limit: 2400,
|
||||
},
|
||||
).then((response) => response.candles ?? []);
|
||||
@@ -454,7 +462,7 @@ export class TbankClient {
|
||||
|
||||
private async getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType,
|
||||
id: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
try {
|
||||
@@ -484,7 +492,7 @@ export class TbankClient {
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
if (queueFull) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
@@ -498,7 +506,7 @@ export class TbankClient {
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull
|
||||
? { code: 'TBANK_UNAVAILABLE' }
|
||||
? { code: TbankErrorCode.Unavailable }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
@@ -534,7 +542,7 @@ export class TbankClient {
|
||||
[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');
|
||||
throw new Error(TbankErrorCode.InvalidToken);
|
||||
}
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
@@ -542,10 +550,10 @@ export class TbankClient {
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
|
||||
private request<Response>(
|
||||
|
||||
Reference in New Issue
Block a user