diff --git a/.ai/architecture.md b/.ai/architecture.md index 6adccfb..2dc1b5f 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими модулями providers, функции и типы. Внешний production import идёт через этот -API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и +API или файл Nest module. Чистые `integrations/tbank/decimal.ts`, +`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и `modules/investments/operations/domain/operations-sync-plan.ts` — отдельные -публичные входы для protobuf conversion и interval rules: импорт расчётов -не загружает DI/Prisma. +публичные входы для protobuf conversion, protocol/error vocabulary и interval +rules: импорт этих значений и расчётов не загружает DI/Prisma. Внутри модуля используй прямые локальные импорты. Tests могут импортировать внутренний owner для проверки поведения и mocks. Новый export требует текущего потребителя; controller и persistence остаются @@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж Исключения для индексов, default values, enum/type declarations и readonly properties заданы в ESLint config. Test fixtures/expected values остаются inline, остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; -semantic magic strings проверяются conventions/review без нового инструмента. +Machine codes и protocol values задаются строковыми enum у владельца. +`no-restricted-syntax` запрещает inline-коды и значения известных protocol families +в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum. +Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback. +Tests сохраняют независимые literal wire assertions; обычный текст и env keys +не считаются кодами. Новые словари требуют проверки владельца и расширения selectors. ## Logging diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index 98f5a1b..b100e4f 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с ## Domain values Finite business vocabulary принадлежит одному domain/module/entity owner. -Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. +Переиспользуй его symbols; для machine codes и protocol values используй +строковые `enum`, для структуры с разными payload — discriminated union. Сохраняй wire values; generated/external enums не переписывай вручную. Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и обычные labels не выноси в constants без semantic benefit. diff --git a/.ai/conventions/integrations.md b/.ai/conventions/integrations.md index d742ab0..9b5f4d5 100644 --- a/.ai/conventions/integrations.md +++ b/.ai/conventions/integrations.md @@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед Преобразование provider records происходит на boundary: например, `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper -остаётся отдельным чистым входом integration. Domain получает внутренние +остаётся отдельным чистым входом integration. `tbank-protocol.ts` и +`tbank-error-code.ts` также импортируются напрямую: их enums и guards +не должны загружать DI/Prisma через service exports. Domain получает внутренние значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый client или mapper для простого копирования полей. Даты/units/nano разбираются на границе клиента/его mapping, а не становятся моделью чистых расчётов. diff --git a/eslint.config.mjs b/eslint.config.mjs index d5955f1..1e01eab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,4 +107,47 @@ export default defineConfig( 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], }, }, + { + files: [ + 'src/**/*.ts', + ], + ignores: [ + '**/*.spec.ts', + 'test/**/*.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)', + message: 'Use the owning enum member for machine codes and protocol values.', + }, + { + selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)', + message: 'Use the owning enum member instead of a template machine code.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]', + message: 'Use an enum member instead of an inline code or protocol value.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value', + message: 'Use an enum member instead of constructing a code or protocol value.', + }, + { + selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]', + message: 'Use a computed enum member as the protocol mapping key.', + }, + { + selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]', + message: 'Compare codes with their enum members, not string literals.', + }, + { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }, + ], + '@typescript-eslint/no-unsafe-enum-comparison': 'error', + }, + }, ); diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index 81300b4..1c2ec64 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -7,11 +7,13 @@ import { getSchemaPath, } from '@nestjs/swagger'; +import { HttpErrorCode } from '../error-code'; + export class ProblemDetailsDto { @ApiProperty({ example: 401 }) declare status: number; - @ApiProperty({ example: 'UNAUTHORIZED' }) + @ApiProperty({ example: HttpErrorCode.Unauthorized }) declare code: string; // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. diff --git a/src/common/error-code.ts b/src/common/error-code.ts new file mode 100644 index 0000000..db48c8a --- /dev/null +++ b/src/common/error-code.ts @@ -0,0 +1,8 @@ +export enum HttpErrorCode { + BadRequest = 'BAD_REQUEST', + InternalServerError = 'INTERNAL_SERVER_ERROR', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED', + UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE', + ValidationError = 'VALIDATION_ERROR', +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index e228b30..2130736 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -9,6 +9,8 @@ import { ValidationError, } from '@nestjs/common'; +import { HttpErrorCode } from '../error-code'; + @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { @@ -25,7 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter { (payload as { message?: unknown })?.message, ); const code = isValidation - ? 'VALIDATION_ERROR' + ? HttpErrorCode.ValidationError : this.codeFor(status, payload); response.locals ??= {}; response.locals.publicErrorCode = code; @@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter { return ( // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { - 400: 'BAD_REQUEST', - 401: 'UNAUTHORIZED', - 404: 'NOT_FOUND', - 415: 'UNSUPPORTED_MEDIA_TYPE', - 500: 'INTERNAL_SERVER_ERROR', - }[status] ?? 'INTERNAL_SERVER_ERROR' + 400: HttpErrorCode.BadRequest, + 401: HttpErrorCode.Unauthorized, + 404: HttpErrorCode.NotFound, + 415: HttpErrorCode.UnsupportedMediaType, + 500: HttpErrorCode.InternalServerError, + }[status] ?? HttpErrorCode.InternalServerError ); } diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 5b5d561..61f1f0e 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,6 +1,8 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; +enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } + const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const FIRST_PAGE_OFFSET = 0; const EXPECTED_CURSOR_ROWS = 1; @@ -152,7 +154,7 @@ export class MoexClient { pageSize === EMPTY_PAGE_SIZE || current.length !== Math.min(pageSize, total - start) ) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } if (index + pageSize >= total) return rows; start = index + pageSize; @@ -172,22 +174,22 @@ export class MoexClient { } return body as IssResponse; } catch { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } } } function parseRows(block: IssBlock | undefined): Record[] { if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return block.data.map((values) => { if (!Array.isArray(values) || values.length !== columns.length) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return Object.fromEntries( // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 257b58d..a5f110e 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; +enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } + const EXPECTED_REGISTRY_TABLES = 1; const RATING_COLUMN = 2; @@ -41,7 +43,7 @@ export class NcrRatingsClient { if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); } - throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable }); }); this.cache.set('registry', pending); } diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 7fc6819..4e8933f 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -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 }, }); } } diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index ee3c4a9..9dfb08a 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -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(); diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 588c9cb..eed498a 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -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 && diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index eb456e2..816ae98 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -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: 'Фонд денежного рынка', }, diff --git a/src/integrations/tbank/tbank-error-code.ts b/src/integrations/tbank/tbank-error-code.ts new file mode 100644 index 0000000..f74ecb0 --- /dev/null +++ b/src/integrations/tbank/tbank-error-code.ts @@ -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; +} diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 784c3cc..625372d 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -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), ); } } diff --git a/src/integrations/tbank/tbank-protocol.spec.ts b/src/integrations/tbank/tbank-protocol.spec.ts new file mode 100644 index 0000000..e34be02 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.spec.ts @@ -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); +}); diff --git a/src/integrations/tbank/tbank-protocol.ts b/src/integrations/tbank/tbank-protocol.ts new file mode 100644 index 0000000..ebfbaf8 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.ts @@ -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; +} diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index e029e07..92e80b5 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -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; } diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 525ab85..56b6fb6 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -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 () => { diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 3cdcfb8..a3ff196 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -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 { - return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); + return this.getInstrument(token, InstrumentIdType.Figi, figi); } getInstrumentByUid( token: string, instrumentUid: string, ): Promise { - 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 { 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(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( diff --git a/src/modules/auth/application/auth-error-code.ts b/src/modules/auth/application/auth-error-code.ts new file mode 100644 index 0000000..0b3bcc7 --- /dev/null +++ b/src/modules/auth/application/auth-error-code.ts @@ -0,0 +1,4 @@ +export enum AuthErrorCode { + EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED', + InvalidCredentials = 'INVALID_CREDENTIALS', +} diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 19baedb..8410ab6 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -8,7 +8,10 @@ import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; import { Session, User } from '../../../../generated/prisma/client'; +import { HttpErrorCode } from '../../../common/error-code'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { PrismaService } from '../../../prisma/prisma.service'; +import { AuthErrorCode } from './auth-error-code'; import { AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; @@ -37,7 +40,7 @@ export class AuthService { where: { email }, }); if (existing) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } let user: User; @@ -51,7 +54,7 @@ export class AuthService { }); } catch (error) { if (isUniqueConstraintError(error)) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } throw error; } @@ -70,7 +73,7 @@ export class AuthService { user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user || !valid) { - throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials }); } return this.createSession(user); } @@ -84,10 +87,10 @@ export class AuthService { algorithms: ['HS256'], }); } catch { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (payload.type !== 'refresh') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, @@ -102,10 +105,10 @@ export class AuthService { if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); } - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (session.refreshTokenHash !== this.hash(token)) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return this.rotateSession(session, session.refreshTokenHash); } @@ -142,7 +145,7 @@ export class AuthService { async userById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -183,7 +186,7 @@ export class AuthService { data, }); if (updated.count !== EXPECTED_ROTATED_SESSIONS) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } } else { await this.prisma.session.update({ where: { id: session.id }, data }); @@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean { typeof error === 'object' && error !== null && 'code' in error && - (error as { code?: unknown }).code === 'P2002' + isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) ); } diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 96110de..ce4cfd9 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { HttpErrorCode } from '../../../common/error-code'; import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { validate(payload: AccessPayload): AccessPayload { if (payload.type !== 'access') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return payload; } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index c169f0e..8f78326 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -21,6 +21,7 @@ import { } from '@nestjs/swagger'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; import { CredentialsDto } from './auth.dto'; @@ -86,7 +87,7 @@ export class AuthController { ) { try { const token = this.refreshToken(request); - if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token)); } catch (error) { if (error instanceof UnauthorizedException) this.clearCookie(response); diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index 8bb0dd6..0fe28ac 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -22,24 +22,23 @@ import { const NEXT_MONTH_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; -export const BankErrorCode = { - InvalidInput: 'BANK_INVALID_INPUT', - VersionConflict: 'BANK_VERSION_CONFLICT', - NotFound: 'BANK_NOT_FOUND', - InvalidMoney: 'BANK_INVALID_MONEY', - InvalidDate: 'BANK_INVALID_DATE', - ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', - InvalidRefund: 'BANK_INVALID_REFUND', - RefundLimit: 'BANK_REFUND_LIMIT', - DependentRefunds: 'BANK_DEPENDENT_REFUNDS', - IntentConflict: 'BANK_INTENT_CONFLICT', - OperationDeleted: 'BANK_OPERATION_DELETED', - CardArchived: 'BANK_CARD_ARCHIVED', - OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', - WriteConflict: 'BANK_WRITE_CONFLICT', - DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', -} as const; -export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; +export enum BankErrorCode { + CardArchived = 'BANK_CARD_ARCHIVED', + DependentRefunds = 'BANK_DEPENDENT_REFUNDS', + DuplicateCategory = 'BANK_DUPLICATE_CATEGORY', + ImmutableOperation = 'BANK_IMMUTABLE_OPERATION', + IntentConflict = 'BANK_INTENT_CONFLICT', + InvalidDate = 'BANK_INVALID_DATE', + InvalidInput = 'BANK_INVALID_INPUT', + InvalidMoney = 'BANK_INVALID_MONEY', + InvalidRefund = 'BANK_INVALID_REFUND', + NotFound = 'BANK_NOT_FOUND', + OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT', + OperationDeleted = 'BANK_OPERATION_DELETED', + RefundLimit = 'BANK_REFUND_LIMIT', + VersionConflict = 'BANK_VERSION_CONFLICT', + WriteConflict = 'BANK_WRITE_CONFLICT', +} export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { throw new BadRequestException({ code }); diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 52a54e4..95a29ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { Prisma } from '../../../../../generated/prisma/client'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; import { BankErrorCode, @@ -44,15 +45,15 @@ export class BankCardsRepository { ? error.code : ''; const busy = - code === 'P2034' || - code === 'P2028' || + isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) || + isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { + if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) { continue; } if (busy) conflict(BankErrorCode.WriteConflict); - if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); + if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory); throw error; } } diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 0c8e551..480e37c 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -8,6 +8,7 @@ import { formatDecimal, subtract, } from '../../../common/decimal'; +import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { @@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { return entries.filter( (entry) => Boolean(entry.payment) && - ['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), + [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)), ); } @@ -184,7 +185,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { if (!entry.payment) continue; const total = values.get(entry.payment.currency) ?? zeroTotal(); const value = decimalFromString(entry.payment.value); - if (entry.type === 'OPERATION_TYPE_COUPON') { + if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { total.coupon = add(total.coupon, value); } else total.dividend = add(total.dividend, value); values.set(entry.payment.currency, total); diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 32f91699..b4522f6 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -8,6 +8,8 @@ import { utcDate, } from '../investment-calendar'; +enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } + const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1; @@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { } function invalidPeriod(): BadRequestException { - return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' }); + return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); } diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 9566d4e..1c8f778 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -9,6 +9,7 @@ import { formatDecimal } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -23,6 +24,7 @@ import { candleWindows, utcDay, } from '../domain/candle-coverage'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; @@ -71,8 +73,8 @@ function rangeFor( throw new ServiceUnavailableException({ code: input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + ? InstrumentErrorCode.StockHistoryStartUnavailable + : InstrumentErrorCode.BondHistoryStartUnavailable, }); } from.setTime(utcDay(new Date(history)).valueOf()); @@ -448,7 +450,7 @@ export class CandleService { const producer = this.queue.shift(); if (!producer) break; let success = false; - let code = 'TBANK_UNAVAILABLE'; + let code: string = TbankErrorCode.Unavailable; try { const result = await this.load(producer); if (result === null) { @@ -457,7 +459,7 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } if (error instanceof ServiceUnavailableException) { @@ -523,7 +525,7 @@ export class CandleService { } } catch { this.logger.event('error', 'candles.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.active.clear(); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 6105b3a..9286681 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -6,6 +6,7 @@ import { import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -14,6 +15,7 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CatalogDiscoveryRepository, catalogQuery, @@ -209,8 +211,8 @@ export class CatalogDiscoveryService { throw new ConflictException({ code: instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + ? InstrumentErrorCode.StockTickerAmbiguous + : InstrumentErrorCode.BondTickerAmbiguous, }); } const result = lookup.data @@ -357,11 +359,11 @@ export class CatalogDiscoveryService { ); } } 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', 'catalog-discovery.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -395,7 +397,7 @@ export class CatalogDiscoveryService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-discovery.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 1b8e892..cfc78a0 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -14,6 +14,7 @@ import { import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, type TbankBond, @@ -520,11 +521,11 @@ export class CatalogEnrichmentService { } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } else { this.logger.event('warn', 'catalog-enrichment.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -572,7 +573,7 @@ export class CatalogEnrichmentService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-enrichment.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 2928a9e..471dbe4 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -1,5 +1,7 @@ import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; +import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol'; + export const catalogSearchLimit = 30; export function selectCatalogInstruments( @@ -10,8 +12,8 @@ export function selectCatalogInstruments( const seen = new Set(); const kind = instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + ? TbankInstrumentType.Share + : TbankInstrumentType.Bond; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index ab09102..b96b7d9 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -7,6 +7,7 @@ import { multiply, } from '../../../../../common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; +import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { TbankAccessService, type TbankBond, @@ -241,10 +242,10 @@ export function bondMetrics( ) : null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified) ? next.couponType : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' + ? TbankCouponType.Floating : null; return { creditRating, diff --git a/src/modules/investments/instruments/domain/instrument-error-code.ts b/src/modules/investments/instruments/domain/instrument-error-code.ts new file mode 100644 index 0000000..3f5f103 --- /dev/null +++ b/src/modules/investments/instruments/domain/instrument-error-code.ts @@ -0,0 +1,6 @@ +export enum InstrumentErrorCode { + BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE', + BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS', + StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE', + StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS', +} diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 46fe512..43547bf 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Prisma } from '../../../../../generated/prisma/client'; -import type { - TbankBond, - TbankShare, - TbankSyncAttempt, -} from '../../../../integrations/tbank/tbank.api'; +import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; const UNCHANGED_REVISION_INCREMENT = 0; @@ -462,7 +459,7 @@ export class CatalogDiscoveryRepository { state: 'running', connection: { invalidToken: true }, }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); await tx.tbankSyncWork.updateMany({ where: { @@ -472,7 +469,7 @@ export class CatalogDiscoveryRepository { }, data: { state: success ? 'complete' : 'failed', - errorCode: success ? null : 'TBANK_UNAVAILABLE', + errorCode: success ? null : TbankErrorCode.Unavailable, }, }); }); diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index fdaad98..4207384 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; -import { - TbankAccessService, - TbankClient, -} from '../../../../integrations/tbank/tbank.api'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api'; import { accountHistory } from '../../investment-sync-plan'; import { type OperationInterval, @@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService { this.logger.event( 'error', 'tbank.operations.sync.persistence_failed', - { userId: attempt.userId, code: 'TBANK_UNAVAILABLE' }, + { userId: attempt.userId, code: TbankErrorCode.Unavailable }, ); } const next = pending ? await this.repository.pending(attempt) : null; @@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService { : {}), cursor, limit: operationsPageSize, - state: 'OPERATION_STATE_EXECUTED', + state: TbankOperationState.Executed, }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) @@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService { } catch (error) { await this.repository.fail( attempt, - error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken), ); return false; } diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 3a43f43..800dc29 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -17,6 +17,7 @@ import { ledgerRange, planInvestmentScreen, } from '../../investment-sync-plan'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { operationIntervalsOverlap, operationsSyncPriority, @@ -442,7 +443,7 @@ export class BrokerageOperationsService { } return cursor as LocalOperationsCursor; } catch { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } } diff --git a/src/modules/investments/operations/domain/operation-error-code.ts b/src/modules/investments/operations/domain/operation-error-code.ts new file mode 100644 index 0000000..b3c10d4 --- /dev/null +++ b/src/modules/investments/operations/domain/operation-error-code.ts @@ -0,0 +1,3 @@ +export enum OperationErrorCode { + InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR', +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 17782cb..5d2a752 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,8 @@ import { formatDecimal } from '../../../../common/decimal'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; -import { - TbankMoney, - TbankOperationItem, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api'; import { BrokerageOperationDto, OperationCategory, @@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0; const NANOSECONDS_PER_MILLISECOND = 1_000_000; const types: Record = { - OPERATION_TYPE_BUY: 'trade', - OPERATION_TYPE_BUY_CARD: 'trade', - OPERATION_TYPE_SELL: 'trade', - OPERATION_TYPE_SELL_CARD: 'trade', - OPERATION_TYPE_BUY_MARGIN: 'trade', - OPERATION_TYPE_SELL_MARGIN: 'trade', - OPERATION_TYPE_COUPON: 'income', - OPERATION_TYPE_DIVIDEND: 'income', - OPERATION_TYPE_DIVIDEND_TRANSFER: 'income', - OPERATION_TYPE_DIV_EXT: 'income', - OPERATION_TYPE_TRANS_IIS_BS: 'transfer', - OPERATION_TYPE_TRANS_BS_BS: 'transfer', - OPERATION_TYPE_BOND_TAX: 'tax', - OPERATION_TYPE_TAX: 'tax', - OPERATION_TYPE_DIVIDEND_TAX: 'tax', - OPERATION_TYPE_SERVICE_FEE: 'fee', - OPERATION_TYPE_MARGIN_FEE: 'fee', - OPERATION_TYPE_BROKER_FEE: 'fee', - OPERATION_TYPE_SUCCESS_FEE: 'fee', - OPERATION_TYPE_INPUT: 'transfer', - OPERATION_TYPE_OUTPUT: 'transfer', - OPERATION_TYPE_INPUT_SECURITIES: 'transfer', - OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer', + [TbankOperationType.Buy]: 'trade', + [TbankOperationType.BuyCard]: 'trade', + [TbankOperationType.Sell]: 'trade', + [TbankOperationType.SellCard]: 'trade', + [TbankOperationType.BuyMargin]: 'trade', + [TbankOperationType.SellMargin]: 'trade', + [TbankOperationType.Coupon]: 'income', + [TbankOperationType.Dividend]: 'income', + [TbankOperationType.DividendTransfer]: 'income', + [TbankOperationType.DivExt]: 'income', + [TbankOperationType.TransIisBs]: 'transfer', + [TbankOperationType.TransBsBs]: 'transfer', + [TbankOperationType.BondTax]: 'tax', + [TbankOperationType.Tax]: 'tax', + [TbankOperationType.DividendTax]: 'tax', + [TbankOperationType.ServiceFee]: 'fee', + [TbankOperationType.MarginFee]: 'fee', + [TbankOperationType.BrokerFee]: 'fee', + [TbankOperationType.SuccessFee]: 'fee', + [TbankOperationType.Input]: 'transfer', + [TbankOperationType.Output]: 'transfer', + [TbankOperationType.InputSecurities]: 'transfer', + [TbankOperationType.OutputSecurities]: 'transfer', }; export function operationCategory(type: string | undefined): OperationCategory { @@ -51,7 +49,7 @@ export function mapBrokerageOperation( return { id: operation.id ?? operation.cursor ?? '', occurredAt: occurredAt.toISOString(), - type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED', + type: operation.type ?? TbankOperationType.Unspecified, category: operationCategory(operation.type), description: operation.description?.trim() || operation.name?.trim() || '', figi: text(operation.figi), diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 5c08d06..7181714 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -12,12 +12,15 @@ import type { TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; import { PrismaService } from '../../../../prisma/prisma.service'; import { dateOnlyBounds, DateOnlyRange, moscowDate, } from '../../investment-calendar'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { type OperationInterval, operationIntervals, @@ -137,7 +140,7 @@ export class BrokerageOperationsRepository { ) .digest('base64url'); if (input.cursor && input.cursor.binding !== binding) { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } const scope = { userId: input.userId, @@ -615,7 +618,7 @@ export class BrokerageOperationsRepository { where: { id: attempt.id }, data: { state: 'failed', - errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', + errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable, }, }); if (invalidToken) { @@ -784,7 +787,7 @@ export class BrokerageOperationsRepository { } const rows = staged.filter((row) => { const state = (row.payload as TbankOperationItem).state; - return !state || state === 'OPERATION_STATE_EXECUTED'; + return !state || isTbankProtocolValue(state, TbankOperationState.Executed); }); const scope = { userId: attempt.userId, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index e6930dc..8da8363 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -17,6 +17,7 @@ import { RequestContextService } from '../../../../common/logging/request-contex import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, type MetadataIdentity, @@ -387,7 +388,7 @@ export class PortfolioService { } catch (error) { if ( error instanceof Error && - error.message === 'INVALID_TBANK_TOKEN' + isTbankErrorCode(error.message, TbankErrorCode.InvalidToken) ) { await this.access.rejectMembershipToken(attempt); } else await this.repository.fail(attempt); @@ -412,7 +413,7 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); if (!result.data) { - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } return result.data; } diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 22f3b5a..682e9de 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; -import { - tbankSourceNeedsRefresh, - tbankSourceTtlMs, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../../prisma/prisma.service'; const EXPECTED_UPDATED_ACCOUNTS = 1; @@ -152,7 +150,7 @@ export class PortfolioRepository { state: 'running', connection: { invalidToken: false }, }, - data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, }); return result.count === EXPECTED_UPDATED_ACCOUNTS; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index 9bd15ad..cbde0b4 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { HttpErrorCode } from '../../../common/error-code'; import { supportedTbankAccounts, TbankClient, @@ -29,7 +30,7 @@ export class TbankConnectionService { async put(userId: string, value: string) { const token = value.trim(); - if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); + if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError }); const accounts = await this.tokens.translateInvalidToken(() => this.tbank.validateToken(token), ); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index dcd94e7..96f0682 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, TbankAccessService, @@ -325,7 +326,7 @@ export class TbankSyncService { await this.repository.progress(attempt, children, true); } catch { await this.repository - .progress(attempt, children, true, 'TBANK_UNAVAILABLE') + .progress(attempt, children, true, TbankErrorCode.Unavailable) .catch(() => undefined); } } diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index dc30d92..1f61e3f 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -31,6 +31,8 @@ import { UpcomingEventsQueryDto, } from './upcoming-events.dto'; +enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } + const FIRST_BATCH_OFFSET = 0; const DEFAULT_EVENT_WINDOW_DAYS = 30; @@ -267,7 +269,7 @@ function dateWindow(query: UpcomingEventsQueryDto) { end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); if (from > to) { - throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + throw new BadRequestException({ code: EventsErrorCode.InvalidPeriod }); } return { from, to }; } diff --git a/src/prisma/prisma-error-code.ts b/src/prisma/prisma-error-code.ts new file mode 100644 index 0000000..20843e3 --- /dev/null +++ b/src/prisma/prisma-error-code.ts @@ -0,0 +1,9 @@ +export enum PrismaErrorCode { + TransactionFailure = 'P2028', + UniqueConstraint = 'P2002', + WriteConflict = 'P2034', +} + +export function isPrismaErrorCode(value: unknown, expected: PrismaErrorCode): boolean { + return value === expected; +} diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index c186465..c574386 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -61,3 +61,27 @@ test('numeric literals require names, including previously ignored numbers and b assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); } }); + +// Single-use codes must be caught too; fixture literals remain independent wire assertions. +test('machine codes require enum members without banning ordinary text or Prisma selects', async () => { + const eslint = new ESLint(); + const filePath = 'src/main.ts'; + for (const code of ['export function check(code: string) { return code === "one-off-code"; }', 'export const result = `MOEX_UNAVAILABLE`;', 'export const result = { code: "one-off-code" };', 'export const result = { "errorCode": "one-off-code" };', 'export const result = { code: `dynamic-${Date.now()}` };', 'export const result = "MOEX_UNAVAILABLE";', 'export const result = ["INSTRUMENT_ID_TYPE_FIGI"];', 'export const result = { OPERATION_TYPE_COUPON: "income" };']) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + for (const code of [ + 'enum Code { Unavailable = "MOEX_UNAVAILABLE" } export const result = { code: Code.Unavailable };', + 'export const select = { code: true, errorCode: true };', + 'export const label = "Please try again"; export const key = "JWT_SECRET";', + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(!result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + const [fixture] = await eslint.lintText('export const result = { code: "MOEX_UNAVAILABLE" };', { filePath: 'src/integrations/moex/moex.client.spec.ts' }); + assert.ok(!fixture.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax')); + const [comparison] = await eslint.lintText('enum Code { Ready = "ready" } export function check(code: Code) { return code === "ready"; }', { filePath }); + assert.ok(comparison.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-unsafe-enum-comparison')); +});