From 31b0e1557978019a9293e5fd3efeee319194cddd Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 13 Oct 2026 18:16:46 +0300 Subject: [PATCH 01/22] feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83) feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) --- .../logging/structured-logger.service.ts | 4 + src/integrations/tbank/tbank.client.spec.ts | 65 ++++++++++- src/integrations/tbank/tbank.client.ts | 49 ++++++++- .../income/income-analytics.dto.ts | 30 +---- .../income/income-analytics.service.spec.ts | 34 +----- .../income/income-analytics.service.ts | 104 ++---------------- .../investments/income/income-period.spec.ts | 13 +-- .../investments/income/income-period.ts | 45 -------- .../investments/investment-sync-plan.spec.ts | 17 ++- .../investments/investment-sync-plan.ts | 25 +---- .../brokerage-operations.service.ts | 15 ++- .../upcoming-events.service.ts | 3 +- test/openapi-contract.e2e-spec.ts | 20 +++- test/tbank-income-local.e2e-spec.ts | 80 +++++++++----- 14 files changed, 230 insertions(+), 274 deletions(-) diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index f37dd47..095e896 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -8,6 +8,7 @@ const allowedFields = new Set([ 'asOf', 'attempts', 'brokerageAccountId', + 'channelState', 'clientRequestId', 'code', 'count', @@ -15,12 +16,15 @@ const allowedFields = new Set([ 'environment', 'found', 'grpcCode', + 'headersReceived', 'method', 'operation', + 'queueWaitMs', 'requestId', 'route', 'service', 'status', + 'trackingId', 'userId', ]); diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 1948672..a35da59 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,5 +1,6 @@ -import { status } from '@grpc/grpc-js'; +import { Metadata, status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { EventEmitter } from 'node:events'; import { TbankOperationState } from './tbank-protocol'; import { TbankClient } from './tbank.client'; @@ -136,6 +137,68 @@ describe('TbankClient operations', () => { ); jest.useRealTimers(); }); + + it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => { + const logger = { event: jest.fn() }; + const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { + logger, + queue: { size: 0, run: (operation: () => Promise) => operation() }, + }); + const channel = { getConnectivityState: () => 1 }; + const service = { + getChannel: () => channel, + getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => { + const call = new EventEmitter(); + void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' })); + return call; + }, + }; + const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise }).call.bind(client); + jest.useFakeTimers(); + try { + const failed = invoke('secret', service, 'getAccounts', {}); + await Promise.all([ + expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(1000), + ]); + expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({ + operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED, + queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false, + })); + expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); + } finally { + jest.useRealTimers(); + } + }); + + it('records response headers and a validated upstream tracking ID', async () => { + const logger = { event: jest.fn() }; + const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { + logger, + queue: { size: 0, run: (operation: () => Promise) => operation() }, + }); + const headers = new Metadata(); + headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000'); + headers.set('authorization', 'Bearer secret'); + const service = { + getChannel: () => ({ getConnectivityState: () => 2 }), + getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => { + const call = new EventEmitter(); + void Promise.resolve().then(() => { + call.emit('metadata', headers); + callback(null, { accounts: [] }); + }); + return call; + }, + }; + const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise }).call.bind(client); + await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] }); + expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({ + channelState: 'READY', headersReceived: true, + trackingId: '123e4567-e89b-12d3-a456-426614174000', + })); + expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); + }); }); describe('TbankClient metadata retries', () => { diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index c2df304..c6100b9 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -2,6 +2,7 @@ import { ChannelCredentials, Client, ClientUnaryCall, + connectivityState, loadPackageDefinition, Metadata, ServiceError, @@ -60,6 +61,12 @@ type Unary = ( callback: (error: null | ServiceError, response: Response) => void, ) => ClientUnaryCall; type TbankClientService = Client & Record>; +type RpcDiagnostics = { + channelState?: string; + headersReceived?: boolean; + queueWaitMs?: number; + trackingId?: string; +}; export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankAccount = { @@ -511,15 +518,20 @@ export class TbankClient { for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; + const diagnostics: RpcDiagnostics = {}; try { if (queueFull) { throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } - const response = await this.queue.run(() => - this.request(token, client, method, request), - ); + const response = await this.queue.run(() => { + diagnostics.queueWaitMs = Math.round(performance.now() - startedAt); + return this.request( + token, client, method, request, diagnostics, + ); + }); this.logger.event('debug', 'tbank.rpc.completed', { operation: method, + ...diagnostics, attempts, durationMs: Math.round(performance.now() - startedAt), }); @@ -537,6 +549,7 @@ export class TbankClient { if (retryable && attempts < RPC_MAX_ATTEMPTS) { this.logger.event('warn', 'tbank.rpc.retry', { operation: method, + ...diagnostics, ...failureContext, attempts, durationMs: Math.round(performance.now() - startedAt), @@ -546,6 +559,7 @@ export class TbankClient { } this.logger.event('error', 'tbank.rpc.failed', { operation: method, + ...diagnostics, ...failureContext, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts, durationMs: Math.round(performance.now() - startedAt), @@ -573,17 +587,42 @@ export class TbankClient { client: TbankClientService, method: string, request: object, + diagnostics?: RpcDiagnostics, ): Promise { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); + // A channel state is a snapshot, not proof that this particular call reached the server. + const channel = diagnostics && client.getChannel(); + if (diagnostics) diagnostics.headersReceived = false; // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. - client[method]( + const call = client[method]( request as never, metadata, { deadline: new Date(Date.now() + requestDeadlineMs) }, - (error, response) => (error ? reject(error) : resolve(response)), + (error, response) => { + if (diagnostics && channel) { + diagnostics.channelState = connectivityState[ + channel.getConnectivityState(false) + ]; + } + if (error) reject(error); + else resolve(response); + }, ); + if (diagnostics) { + call.on('metadata', (headers: Metadata) => { + diagnostics.headersReceived = true; + const trackingId = headers.get('x-tracking-id')[0]; + // Only a UUID may enter logs; never forward arbitrary upstream metadata. + if ( + typeof trackingId === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId) + ) { + diagnostics.trackingId = trackingId; + } + }); + } }); } } diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index a398d59..a0735f4 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -67,28 +67,6 @@ export class IncomeSummaryDto { declare calculatedAt: string; } -export class IncomeComparisonCurrencyDto { - @ApiProperty() - declare currency: string; - - @ApiProperty({ type: IncomeCurrencySummaryDto }) - declare selected: IncomeCurrencySummaryDto; - - @ApiProperty({ type: IncomeCurrencySummaryDto }) - declare compared: IncomeCurrencySummaryDto; - - @ApiProperty({ type: IncomeCurrencySummaryDto }) - declare difference: IncomeCurrencySummaryDto; -} - -export class IncomeComparisonDto { - @ApiProperty({ type: IncomePeriodDto }) - declare period: IncomePeriodDto; - - @ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true }) - declare totalsByCurrency: IncomeComparisonCurrencyDto[]; -} - export class IncomeBucketDto { @ApiProperty({ type: IncomePeriodDto }) declare period: IncomePeriodDto; @@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto { } export class IncomeAnalyticsDto extends IncomeSummaryDto { - @ApiProperty({ type: IncomeComparisonDto, nullable: true }) - declare previous: IncomeComparisonDto | null; - - @ApiProperty({ type: IncomeComparisonDto, nullable: true }) - declare yearComparison: IncomeComparisonDto | null; - @ApiProperty({ enum: ['day', 'week', 'month'] }) declare bucketGranularity: 'day' | 'month' | 'week'; @@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto { declare meta: InvestmentResourceMetaDto; } -const incomeDependencyRoles = ['selected', 'previous', 'year'] as const; +const incomeDependencyRoles = ['selected'] as const; export class IncomeDependencyDto extends InvestmentDependencyDto { @ApiProperty({ enum: incomeDependencyRoles }) diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index c136846..320befc 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => { beforeEach(() => jest.clearAllMocks()); - it('labels each comparison dependency independently of its resource key', async () => { + it('labels the selected dependency independently of its resource key', async () => { seed([]); const response = await service.analytics( 'user', @@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => { ); expect(response.meta.dependencies).toEqual([ expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), - expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }), - expect.objectContaining({ role: 'year', resourceKey: 'period/2' }), ]); - }); - - it('labels an unavailable leap-day comparison as year', async () => { - operations.collectRanges.mockImplementation( - (_user: string, _account: string, build: () => unknown[]) => - build().map((_, index) => ({ items: [], meta: completeMeta(index) })), - ); - const response = await service.analytics( - 'user', - 'account', - '2024-02-29', - '2024-02-29', - ); - expect(response.meta.dependencies).toEqual([ - expect.objectContaining({ role: 'selected' }), - expect.objectContaining({ role: 'previous' }), - expect.objectContaining({ - role: 'year', - availability: 'temporarily-unavailable', - unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', - }), - ]); - expect(response.data?.yearComparison).toBeNull(); + expect( + response.meta.dependencies.some((dependency) => dependency.role !== 'selected'), + ).toBe(false); }); function seed(items: BrokerageOperationDto[]) { @@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => { build: (opened?: Date) => unknown[], ) => { build(undefined); - return [items, [], []].map((items, index) => ({ + return [items].map((items, index) => ({ items, meta: completeMeta(index), })); @@ -247,8 +225,6 @@ describe('IncomeAnalyticsService', () => { entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'), entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'), ], - [], - [], ].map((items, index) => ({ items, meta: completeMeta(index) })); }, ); diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index dffd297..e654c62 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -6,7 +6,6 @@ import { compare, decimalFromString, formatDecimal, - subtract, } from '../../../common/decimal'; import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; @@ -18,7 +17,6 @@ import { import { IncomeAnalyticsEntryDto, IncomeAnalyticsResourceDto, - IncomeComparisonDto, IncomeCurrencySummaryDto, IncomeDependencyDto, IncomeInstrumentDto, @@ -47,14 +45,11 @@ export class IncomeAnalyticsService { accountId: string, observe = false, ): Promise { - let plan = planIncomePeriod(undefined, undefined); + const plan = planIncomePeriod(undefined, undefined); const [selected] = await this.operations.collectRanges( userId, accountId, - (openedAt) => { - plan = planIncomePeriod(undefined, undefined, openedAt); - return [plan.selected]; - }, + () => [plan.selected], observe, ); if (selected.items === null) return { data: null, meta: selected.meta }; @@ -77,44 +72,17 @@ export class IncomeAnalyticsService { to: string | undefined, observe = false, ): Promise { - let plan = planIncomePeriod(from, to); - const periods = await this.operations.collectRanges( + const plan = planIncomePeriod(from, to); + const [current] = await this.operations.collectRanges( userId, accountId, - (openedAt) => { - plan = planIncomePeriod(from, to, openedAt); - return [ - plan.selected, - plan.previous, - ...(plan.yearAgo ? [plan.yearAgo] : []), - ]; - }, + () => [plan.selected], observe, ); - const [current, prior, year] = periods; - // Preserve the legacy order while consumers migrate to explicit roles. const dependencies: IncomeDependencyDto[] = [ { ...current.meta, role: 'selected' }, - { ...prior.meta, role: 'previous' }, - { - ...(plan.yearAgo && year - ? year.meta - : investmentResourceMeta({ - resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier - dataRevision: 'unavailable', - availability: 'missing', - fetchedAt: null, - freshForMs: 0, - work: null, - unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', - })), - role: 'year', - }, ]; - const states = periods.map((period) => period.meta.sync); - const work = - states.find((state) => state.state === 'running') ?? - states.find((state) => state.state === 'failed'); + const work = current.meta.sync; const meta = { ...investmentResourceMeta({ resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, @@ -127,7 +95,9 @@ export class IncomeAnalyticsService { ? new Date(current.meta.lastSuccessfulAt) : null, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, - work: work?.id ? { ...work, id: work.id } : null, + work: work.id + ? { id: work.id, state: work.state, errorCode: work.errorCode } + : null, coverage: current.meta.coverage, dependencies, }), @@ -147,14 +117,6 @@ export class IncomeAnalyticsService { selected, current.meta.lastSuccessfulAt, ), - previous: - prior.items === null - ? null - : comparison(selected, plan.previous, qualifying(prior.items)), - yearComparison: - plan.yearAgo && year?.items !== null && year - ? comparison(selected, plan.yearAgo, qualifying(year.items)) - : null, bucketGranularity: plan.granularity, buckets: buckets(plan.selected, plan.granularity, selected), instruments: instruments(selected), @@ -198,38 +160,6 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { .map(([currency, total]) => presentTotal(currency, total)); } -function comparison( - selectedEntries: BrokerageOperationDto[], - comparedPeriod: DateOnlyRange, - comparedEntries: BrokerageOperationDto[], -): IncomeComparisonDto { - const selected = new Map( - totals(selectedEntries).map((total) => [total.currency, total]), - ); - const compared = new Map( - totals(comparedEntries).map((total) => [total.currency, total]), - ); - const currencies = [ - ...new Set([...selected.keys(), ...compared.keys()]), - // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. - ].sort(); - return { - period: comparedPeriod, - totalsByCurrency: currencies.map((currency) => { - const current = - selected.get(currency) ?? presentTotal(currency, zeroTotal()); - const prior = - compared.get(currency) ?? presentTotal(currency, zeroTotal()); - return { - currency, - selected: current, - compared: prior, - difference: difference(current, prior), - }; - }), - }; -} - function buckets( range: DateOnlyRange, granularity: IncomeBucketGranularity, @@ -293,22 +223,6 @@ function instrumentKey(entry: BrokerageOperationDto): string { return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; } -function difference( - left: IncomeCurrencySummaryDto, - right: IncomeCurrencySummaryDto, -): IncomeCurrencySummaryDto { - return presentTotal(left.currency, { - coupon: subtract( - decimalFromString(left.coupon.value), - decimalFromString(right.coupon.value), - ), - dividend: subtract( - decimalFromString(left.dividend.value), - decimalFromString(right.dividend.value), - ), - }); -} - function zeroTotal(): Total { return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; } diff --git a/src/modules/investments/income/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts index 31afc16..2d4e5bf 100644 --- a/src/modules/investments/income/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -23,16 +23,14 @@ describe('income period planning', () => { } }); - it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => { - const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31'); - expect(fullMonth.previous).toEqual({ - from: '2026-02-01', - to: '2026-02-28', - }); - expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull(); + it('keeps empty buckets and skips removed comparison ranges', () => { expect( rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'), ).toHaveLength(7); + expect(planIncomePeriod('2026-03-01', '2026-03-31')).toEqual({ + selected: { from: '2026-03-01', to: '2026-03-31' }, + granularity: 'day', + }); }); it('accepts multi-year periods and creates monthly buckets for the entire range', () => { @@ -43,7 +41,6 @@ describe('income period planning', () => { expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' }); expect(plan.granularity).toBe('month'); expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72); - expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' }); }); it('does not send the unfinished Moscow day as a future instant', () => { diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index b4522f6..cf4a298 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -14,45 +14,28 @@ const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; -const PREVIOUS_DAY_OFFSET = -1; -const PREVIOUS_YEAR_OFFSET = 1; -const DATE_COMPONENT_DIGITS = 2; const DAILY_BUCKET_MAX_DAYS = 45; const WEEKLY_BUCKET_MAX_DAYS = 180; const DAYS_PER_WEEK = 7; const ISO_MONTH_LENGTH = 7; -const ISO_YEAR_LENGTH = 4; export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export function planIncomePeriod( from: string | undefined, to: string | undefined, - openedDate?: Date, ): { granularity: IncomeBucketGranularity; - previous: DateOnlyRange; selected: DateOnlyRange; - yearAgo: DateOnlyRange | null; } { if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); const today = moscowDate(new Date()); const selected = from && to ? { from, to } : { from: firstOfMonth(today), to: today }; assertRange(selected, today); - const previous = isFullMonth(selected) - ? previousMonth(selected.from) - : shiftDays(selected, -rangeDays(selected)); - const candidate = yearAgo(selected); - const yearAgoRange = - candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) - ? candidate - : null; return { selected, - previous, - yearAgo: yearAgoRange, granularity: rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ? 'day' @@ -136,34 +119,6 @@ function endOfMonth(value: string): string { return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } -function isFullMonth(range: DateOnlyRange): boolean { - return ( - range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to) - ); -} - -function previousMonth(from: string): DateOnlyRange { - const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET); - return { from: firstOfMonth(last), to: last }; -} - -function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange { - return { from: addDays(range.from, days), to: addDays(range.to, days) }; -} - -function yearAgo(range: DateOnlyRange): DateOnlyRange | null { - const shift = (value: string) => { - const [year, month, day] = dateParts(value); - const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; - return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result - ? result - : null; - }; - const from = shift(range.from); - const to = shift(range.to); - return from && to ? { from, to } : null; -} - function invalidPeriod(): BadRequestException { return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); } diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 58d0915..8cb4dd1 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -17,7 +17,7 @@ describe('screen dependency and history planning', () => { }, ], }; - it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => { + it('prioritizes discovery, the selected period, then history; Moscow dates are converted once', () => { const plan = planInvestmentScreen( { screen: 'income', @@ -32,6 +32,17 @@ describe('screen dependency and history planning', () => { const selected = plan.sources.filter( (source) => source.kind === 'operations' && source.priority === 'screen', ); + // The single selected range is chunked into month-aligned intervals. + expect(selected).toHaveLength(2); + expect( + selected.every( + (source) => + source.from != null && + source.from.toISOString() >= '2024-01-31T21:00:00.000Z' && + source.to != null && + source.to.toISOString() <= '2024-02-29T21:00:00.000Z', + ), + ).toBe(true); expect( selected.some( (source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z', @@ -42,9 +53,7 @@ describe('screen dependency and history planning', () => { (source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z', ), ).toBe(true); - expect(plan.unavailable).toEqual([ - { resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier - ]); + expect(plan.unavailable).toEqual([]); const firstHistory = plan.sources.findIndex( (source) => source.priority === 'history', ); diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 9d04c51..53a4560 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -111,13 +111,6 @@ export function planInvestmentScreen( priority: 'screen' as const, })), ); - const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) - ? new Date( - // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. - `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) - : undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) @@ -142,23 +135,9 @@ export function planInvestmentScreen( const period = planIncomePeriod( screen.screen === 'income' ? screen.from : undefined, screen.screen === 'income' ? screen.to : undefined, - knownOpening, ); - for (const range of [ - period.selected, - ...(screen.screen === 'income' - ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] - : []), - ]) { - const bounds = dateOnlyBounds(range, now); - operations(bounds.from, bounds.to); - } - if (screen.screen === 'income' && !period.yearAgo) { - unavailable.push({ - resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier - reason: 'calendar_or_account_boundary', - }); - } + const bounds = dateOnlyBounds(period.selected, now); + operations(bounds.from, bounds.to); if (screen.screen === 'account') { const range = ledgerRange( screen.from, diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 5b63076..193cfd0 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -451,7 +451,11 @@ export class BrokerageOperationsService { key: `${period.from}/${period.to}`, movingEnd: false, })); - return periods.map((period, index) => { + const rows = saved?.rows ?? []; + const instruments = await this.metadata.savedMany( + rows.map((row) => row.payload as TbankOperationItem), + ); + return periods.map((period) => { const targets = (saved?.connection.accounts ?? []).map((account) => ({ accountId: account.accountId, ...period, @@ -487,8 +491,7 @@ export class BrokerageOperationsService { null; const confirmed = confirmedOperationRanges(plan.intervals, sources); const meta = investmentResourceMeta({ - // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. - resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, + resourceKey: `income/${accountId}/selected/${period.key}`, dataRevision: saved?.binding ?? 'missing', availability: !known ? 'missing' @@ -542,7 +545,7 @@ export class BrokerageOperationsService { return { meta, items: complete - ? (saved?.rows ?? []) + ? rows .filter((row) => plan.intervals.some( (interval) => @@ -556,6 +559,10 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), + logoName: + instruments + .get(metadataKey(row.payload as TbankOperationItem)) + ?.data?.brand?.logoName?.trim() || null, ...(accountId === 'all' ? { account: { diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 5393afc..a3f905b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { add, + type Decimal, decimalFromString, formatDecimal, isZero, @@ -280,7 +281,7 @@ function dateWindow(query: UpcomingEventsQueryDto) { } function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { - const totals = new Map>(); + const totals = new Map(); for (const event of events) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index c16b1c9..d95b945 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -77,7 +77,7 @@ describe('OpenAPI contract (integration)', () => { } }); - it('exposes required semantic income dependency roles', () => { + it('exposes only the selected income dependency role and no comparison data', () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({ properties: { @@ -100,9 +100,25 @@ describe('OpenAPI contract (integration)', () => { expect(dependency.required).toContain('role'); expect(dependency).toMatchObject({ properties: { - role: { type: 'string', enum: ['selected', 'previous', 'year'] }, + role: { type: 'string', enum: ['selected'] }, }, }); + const analytics = schemas?.IncomeAnalyticsDto; + assert(analytics && !('$ref' in analytics)); + expect(analytics.required).toEqual( + expect.arrayContaining([ + 'accountId', + 'period', + 'totalsByCurrency', + 'calculatedAt', + 'bucketGranularity', + 'buckets', + 'instruments', + 'entries', + ]), + ); + expect(analytics.properties).not.toHaveProperty('previous'); + expect(analytics.properties).not.toHaveProperty('yearComparison'); }); it('generates the runtime contract without server startup or deployment configuration', async () => { diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index cbba85d..2e16feb 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -124,7 +124,7 @@ describe('local income completeness and exact money (SQLite)', () => { } } }); - it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { + it('returns exact selected sums; reopen and observe need no RPC', async () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); @@ -140,22 +140,55 @@ describe('local income completeness and exact money (SQLite)', () => { .filter((row) => row.id === 'same') .map((row) => row.account?.id), ).toEqual(['a', 'b']); - expect(result.data.previous).toBeNull(); - expect(result.data.yearComparison).toBeNull(); expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', - 'previous', - 'year', ]); - const previous = result.meta.dependencies.find(({ role }) => role === 'previous'); - expect(previous?.availability).toBe('temporarily-unavailable'); - expect(previous?.completeness).toBe('incomplete'); expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); await prisma.$disconnect(); } expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); + it('uses saved instrument logos for income entries without changing their financial data', async () => { + const before = await read(); + const base = before.data; + assert(base); + const fetchedAt = new Date(); + await prisma.tbankOperation.update({ + where: { + userId_revision_accountId_brokerOperationId: { + userId: 'income-owner', revision, accountId: 'a', brokerOperationId: 'same', + }, + }, + data: { + payload: { + id: 'same', figi: 'income-figi', type: 'OPERATION_TYPE_COUPON', + date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, + payment: { units: '10', nano: 1, currency: 'rub' }, + }, + }, + }); + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', uid: 'income-uid', figi: 'income-figi', + instrumentType: 'bond', fetchedAt, + metadata: { uid: 'income-uid', figi: 'income-figi', name: 'Bond', brand: { logoName: 'bond.png' } }, + }, + }); + await prisma.tbankInstrumentLookup.create({ + data: { + provider: 'tbank', identifierType: 'figi', identifier: 'income-figi', + matchedUids: ['income-uid'], fetchedAt, + }, + }); + const after = await read(); + assert(after.data); + expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png'); + expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull(); + expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency); + expect(after.data.calculatedAt).toBe(base.calculatedAt); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); it('requires every account and separates missing, partial and confirmed empty periods', async () => { await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'b' }, @@ -176,15 +209,7 @@ describe('local income completeness and exact money (SQLite)', () => { ).toEqual([]); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); - it('completes previous/yearAgo independently and counts corrected identities once', async () => { - for (const account of ['a', 'b']) { - await coverage(account, new Date('2024-11-30T21:00:00Z'), from); - await coverage( - account, - new Date('2023-12-31T21:00:00Z'), - new Date('2024-01-31T21:00:00Z'), - ); - } + it('counts corrected identities once for the selected period', async () => { await prisma.tbankOperation.updateMany({ where: { accountId: 'a', brokerOperationId: 'same' }, data: { @@ -197,14 +222,12 @@ describe('local income completeness and exact money (SQLite)', () => { }, }); const result = await read(); - assert(result.data?.previous && result.data.yearComparison); + assert(result.data); expect(result.data.entries).toHaveLength(6); expect( - result.data.previous.totalsByCurrency[0].difference.total.value, - ).toBe('25.000000002'); - expect( - result.data.yearComparison.totalsByCurrency[0].compared.total.value, - ).toBe('0'); + result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB') + ?.total, + ).toEqual({ currency: 'RUB', value: '25.000000002' }); }); it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => { const before = await read(); @@ -276,12 +299,13 @@ describe('local income completeness and exact money (SQLite)', () => { const response = await get('all', undefined, false).expect(200); expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull(); }); - it('distinguishes unavailable calendar comparison without scheduling it', async () => { + it('plans only the selected period for a leap day without scheduling it', async () => { const result = await read('a', 'from=2024-02-29&to=2024-02-29'); - expect( - result.meta.dependencies.find(({ role }) => role === 'year') - ?.unavailableReason, - ).toBe('CALENDAR_OR_ACCOUNT_HISTORY'); + expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ + 'selected', + ]); + expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable'); + expect(result.data).toBeNull(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); }); -- 2.54.0 From 9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 13 Oct 2026 21:54:51 +0300 Subject: [PATCH 02/22] refactor: extract shared public instrument projection (#69) (#84) --- .../application/catalog-discovery.service.ts | 37 +---------- .../application/catalog-enrichment.service.ts | 34 +--------- .../application/public-instrument.spec.ts | 64 +++++++++++++++++++ .../application/public-instrument.ts | 38 +++++++++++ 4 files changed, 104 insertions(+), 69 deletions(-) create mode 100644 src/modules/investments/instruments/application/public-instrument.spec.ts create mode 100644 src/modules/investments/instruments/application/public-instrument.ts diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index fb3c389..93901be 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -9,9 +9,7 @@ import { StructuredLogger } from '../../../../common/logging/structured-logger.s import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, - type TbankBond, TbankClient, - type TbankShare, tbankSourceTtlMs, type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; @@ -28,6 +26,7 @@ import { catalogSearchLimit, selectCatalogInstruments, } from './catalog-selection'; +import { publicInstrument } from './public-instrument'; const UNAMBIGUOUS_MATCH_COUNT = 1; const NO_MATCHES = 0; @@ -495,37 +494,3 @@ export class CatalogDiscoveryService { } } } - -function publicInstrument(value: TbankBond | TbankShare) { - const bond = value as TbankBond; - const share = value as TbankShare; - return { - uid: value.uid, - figi: value.figi, - assetUid: value.assetUid, - name: value.name, - ticker: value.ticker, - isin: value.isin, - classCode: value.classCode, - realExchange: value.realExchange, - apiTradeAvailableFlag: value.apiTradeAvailableFlag, - currency: value.currency, - shareType: share.shareType, - sector: value.sector, - lot: value.lot, - brand: value.brand ? { logoName: value.brand.logoName } : undefined, - first_1dayCandleDate: value.first_1dayCandleDate, - nominal: bond.nominal, - initialNominal: bond.initialNominal, - aciValue: bond.aciValue, - maturityDate: bond.maturityDate, - callDate: bond.callDate, - couponQuantityPerYear: bond.couponQuantityPerYear, - floatingCouponFlag: bond.floatingCouponFlag, - perpetualFlag: bond.perpetualFlag, - amortizationFlag: bond.amortizationFlag, - subordinatedFlag: bond.subordinatedFlag, - bondType: bond.bondType, - riskLevel: bond.riskLevel, - }; -} diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 0c40573..3acb074 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -38,6 +38,7 @@ import { import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { catalogSearchLimit } from './catalog-selection'; import { previousClose, timestamp } from './instrument-market'; +import { publicInstrument } from './public-instrument'; const MISSING_DATA_REVISION = 0; const FIRST_QUEUE_INDEX = 0; @@ -651,39 +652,6 @@ export type CatalogPayload = { tradingStatus?: null | string; }; -function publicInstrument(value: TbankBond | TbankShare) { - const bond = value as TbankBond; - const share = value as TbankShare; - return { - uid: value.uid, - figi: value.figi, - assetUid: value.assetUid, - name: value.name, - ticker: value.ticker, - isin: value.isin, - classCode: value.classCode, - realExchange: value.realExchange, - apiTradeAvailableFlag: value.apiTradeAvailableFlag, - currency: value.currency, - shareType: share.shareType, - sector: value.sector, - lot: value.lot, - brand: value.brand ? { logoName: value.brand.logoName } : undefined, - first_1dayCandleDate: value.first_1dayCandleDate, - nominal: bond.nominal, - initialNominal: bond.initialNominal, - aciValue: bond.aciValue, - maturityDate: bond.maturityDate, - callDate: bond.callDate, - couponQuantityPerYear: bond.couponQuantityPerYear, - floatingCouponFlag: bond.floatingCouponFlag, - perpetualFlag: bond.perpetualFlag, - amortizationFlag: bond.amortizationFlag, - subordinatedFlag: bond.subordinatedFlag, - bondType: bond.bondType, - riskLevel: bond.riskLevel, - }; -} function sourcePolicy(kind: CatalogSource['kind']) { if ( [ diff --git a/src/modules/investments/instruments/application/public-instrument.spec.ts b/src/modules/investments/instruments/application/public-instrument.spec.ts new file mode 100644 index 0000000..063ea43 --- /dev/null +++ b/src/modules/investments/instruments/application/public-instrument.spec.ts @@ -0,0 +1,64 @@ +import type { + TbankBond, + TbankShare, +} from '../../../../integrations/tbank/tbank.api'; + +import { publicInstrument } from './public-instrument'; + +const share = { + uid: 'share-uid', + figi: 'FIGI1', + assetUid: 'asset-1', + name: 'Share', + ticker: 'SHAR', + isin: 'US000', + classCode: 'TQBR', + realExchange: 'REAL_EXCHANGE_MOEX', + apiTradeAvailableFlag: true, + currency: 'rub', + shareType: 'SHARE_TYPE_COMMON', + sector: 'Finance', + lot: 1, + brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' }, + first_1dayCandleDate: '2020-01-01T00:00:00Z', + privateField: 'must-not-leak', +} as unknown as TbankShare; + +const bond = { + uid: 'bond-uid', + figi: 'FIGI2', + assetUid: 'asset-2', + name: 'Bond', + ticker: 'BOND', + classCode: 'TQOB', + currency: 'rub', + lot: 1, + nominal: { value: '1000', currency: 'rub' }, + initialNominal: { value: '1000', currency: 'rub' }, + aciValue: { value: '10', currency: 'rub' }, + maturityDate: '2030-01-01T00:00:00Z', + couponQuantityPerYear: 2, + bondType: 'BOND_TYPE_EXPIRING', + brand: null, + privateField: 'must-not-leak', +} as unknown as TbankBond; + +describe('publicInstrument projection', () => { + it('keeps approved share fields, trims brand, drops private extras', () => { + const result = publicInstrument(share); + expect(result.uid).toBe('share-uid'); + expect(result.shareType).toBe('SHARE_TYPE_COMMON'); + expect(result.brand).toEqual({ logoName: 'logo.png' }); + expect(result).not.toHaveProperty('privateField'); + expect(result).not.toHaveProperty('brand.title'); + }); + + it('projects bonds with missing optional fields and null brand', () => { + const result = publicInstrument(bond); + expect(result.nominal).toEqual({ value: '1000', currency: 'rub' }); + expect(result.shareType).toBeUndefined(); + expect(result.brand).toBeUndefined(); + expect(result.isin).toBeUndefined(); + expect(result).not.toHaveProperty('privateField'); + }); +}); diff --git a/src/modules/investments/instruments/application/public-instrument.ts b/src/modules/investments/instruments/application/public-instrument.ts new file mode 100644 index 0000000..c35a23b --- /dev/null +++ b/src/modules/investments/instruments/application/public-instrument.ts @@ -0,0 +1,38 @@ +import type { + TbankBond, + TbankShare, +} from '../../../../integrations/tbank/tbank.api'; + +export function publicInstrument(value: TbankBond | TbankShare) { + const bond = value as TbankBond; + const share = value as TbankShare; + return { + uid: value.uid, + figi: value.figi, + assetUid: value.assetUid, + name: value.name, + ticker: value.ticker, + isin: value.isin, + classCode: value.classCode, + realExchange: value.realExchange, + apiTradeAvailableFlag: value.apiTradeAvailableFlag, + currency: value.currency, + shareType: share.shareType, + sector: value.sector, + lot: value.lot, + brand: value.brand ? { logoName: value.brand.logoName } : undefined, + first_1dayCandleDate: value.first_1dayCandleDate, + nominal: bond.nominal, + initialNominal: bond.initialNominal, + aciValue: bond.aciValue, + maturityDate: bond.maturityDate, + callDate: bond.callDate, + couponQuantityPerYear: bond.couponQuantityPerYear, + floatingCouponFlag: bond.floatingCouponFlag, + perpetualFlag: bond.perpetualFlag, + amortizationFlag: bond.amortizationFlag, + subordinatedFlag: bond.subordinatedFlag, + bondType: bond.bondType, + riskLevel: bond.riskLevel, + }; +} -- 2.54.0 From 203cc72cb2d1aac34f39c5ba528820b511666f17 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:12:51 +0300 Subject: [PATCH 03/22] chore: align SQLite driver version (WealthLedger/backend#67) Closes #67 --- package-lock.json | 28 ++++++++-------------------- package.json | 5 ++--- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fa4121..5aac605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/client": "7.10.0", "bcrypt": "6.0.0", - "better-sqlite3": "13.0.3", + "better-sqlite3": "12.11.1", "cheerio": "1.2.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", @@ -3769,20 +3769,6 @@ "better-sqlite3": "^12.6.0" } }, - "node_modules/@prisma/adapter-better-sqlite3/node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -6282,15 +6268,17 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.0.0" + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" }, "engines": { - "node": ">=22" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { diff --git a/package.json b/package.json index 5da73b0..95ca29e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/client": "7.10.0", "bcrypt": "6.0.0", - "better-sqlite3": "13.0.3", + "better-sqlite3": "12.11.1", "cheerio": "1.2.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", @@ -144,7 +144,6 @@ "better-sqlite3@12.11.1": true, "fsevents@2.3.3": true, "protobufjs@7.6.5": true, - "unrs-resolver@1.12.2": true, - "better-sqlite3@13.0.3": true + "unrs-resolver@1.12.2": true } } -- 2.54.0 From 1033d2114e99232a90c5d5c321749a9c6e3fbff0 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:34:37 +0300 Subject: [PATCH 04/22] test: slim sqlite runner regression workload (WealthLedger/backend#72) Closes #72 --- README.md | 5 +++-- test/sqlite-regression.cjs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ea8c573..3d77af0 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,9 @@ breaking changes относительно предыдущего выпуска. настраивается отдельно. История migrations применяется штатным `migrate deploy`. Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет -три последовательных и два параллельных полных e2e, проверяет уникальность DB, -сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. +три последовательных и два параллельных запуска двух обычных e2e suites, +проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration +и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`. Логи остаются в каталоге из вывода. Для одной suite: `node test/sqlite.cjs app.e2e-spec.ts`. diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index 13e1298..2b1ad0c 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -18,7 +18,7 @@ writeFileSync(sentinel, 'fictional user database: must never be opened'); const before = readFileSync(sentinel); const databases = new Set(); async function check(name, overrides = {}, signal) { - const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], { + const child = spawn(process.execPath, ['test/sqlite.cjs'], { cwd, env: { ...process.env, -- 2.54.0 From 285dd08472d9acb66fab8d46d511d9ce607b110f Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:48:37 +0300 Subject: [PATCH 05/22] perf: batch bank operation page relations (WealthLedger/backend#68) Closes #68 --- .../application/bank-operations.service.ts | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 870748b..e562780 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -91,6 +91,101 @@ export class BankOperationsService { }; } + private async pageViews(tx: Tx, rows: BankCardOperation[]) { + const userId = rows[0]?.userId; + if (!userId) return []; + const cardIds = new Set( + rows.flatMap((row) => + [row.cardId, row.targetCardId].filter( + (id): id is string => id !== null, + ), + ), + ); + const sourceIds = new Set( + rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])), + ); + const [cards, sources] = await Promise.all([ + tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }), + tx.bankCardOperation.findMany({ + where: { userId, id: { in: [...sourceIds] } }, + }), + ]); + const categoryIds = new Set( + rows + .map( + (row) => + sources.find((source) => source.id === row.originalExpenseId) + ?.categoryId ?? row.categoryId, + ) + .filter((id): id is string => id !== null), + ); + const expenseIds = new Set( + rows.flatMap((row) => { + const source = sources.find((item) => item.id === row.originalExpenseId); + return row.kind === BankOperationKind.Expense || source + ? [source?.id ?? row.id] + : []; + }), + ); + const [categories, refunds] = await Promise.all([ + tx.expenseCategory.findMany({ + where: { userId, id: { in: [...categoryIds] } }, + }), + tx.bankCardOperation.findMany({ + where: { + userId, + originalExpenseId: { in: [...expenseIds] }, + deletedAt: null, + }, + }), + ]); + const cardsById = new Map(cards.map((card) => [card.id, card])); + const sourcesById = new Map(sources.map((source) => [source.id, source])); + const categoriesById = new Map(categories.map((category) => [category.id, category])); + return rows.map((row) => { + const card = cardsById.get(row.cardId) as { name: string }; + const target = row.targetCardId ? cardsById.get(row.targetCardId) : null; + const source = row.originalExpenseId + ? sourcesById.get(row.originalExpenseId) + : null; + const categoryId = source?.categoryId ?? row.categoryId; + const relatedRefunds = + row.kind === BankOperationKind.Expense || source + ? refunds.filter( + (refund) => + refund.originalExpenseId === (source?.id ?? row.id) && + (!source || refund.id !== row.id), + ) + : []; + return { + id: row.id, + kind: row.kind as BankOperationDto['kind'], + cardId: row.cardId, + cardName: card.name, + targetCardId: row.targetCardId, + targetCardName: target?.name ?? null, + categoryId, + categoryName: categoriesById.get(categoryId ?? '')?.name ?? null, + originalExpenseId: row.originalExpenseId, + amount: rubMoney(row.amountKopecks), + availableRefund: + row.kind === BankOperationKind.Expense || source + ? rubMoney( + (source?.amountKopecks ?? row.amountKopecks) - + relatedRefunds.reduce( + (sum, refund) => sum + refund.amountKopecks, + ZERO_KOPECKS, + ), + ) + : null, + date: row.date, + description: row.description, + version: row.version, + createdAt: row.createdAt.toISOString(), + }; + }); + } + private async validateOperation( tx: Tx, userId: string, @@ -359,9 +454,7 @@ export class BankOperationsService { take: limit, }); return { - items: await Promise.all( - rows.map((row) => this.operationView(tx, row)), - ), + items: await this.pageViews(tx, rows), total, page, hasNext: page * limit < total, -- 2.54.0 From bbb5afff842720374feecfcd74eea83283e0b2ae Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 09:02:53 +0300 Subject: [PATCH 06/22] ci: validate edited PR titles (WealthLedger/backend#74) Closes #74 --- .gitea/workflows/verify.yml | 6 ++++-- README.md | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index c5fd66b..d1d3318 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -4,7 +4,7 @@ on: push: branches: [develop, main] pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited] schedule: - cron: '47 5 * * *' @@ -40,6 +40,7 @@ jobs: needs: commits if: >- !cancelled() && + gitea.event.action != 'edited' && (needs.commits.result == 'success' || needs.commits.result == 'skipped') timeout-minutes: 15 runs-on: ubuntu-latest @@ -67,7 +68,8 @@ jobs: verify: needs: [commits, security] if: >- - !cancelled() && needs.security.result == 'success' && + !cancelled() && gitea.event.action != 'edited' && + needs.security.result == 'success' && gitea.event_name != 'schedule' timeout-minutes: 45 runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3d77af0..3d730e0 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` последовательно на PR; на push в `develop`/`main` — `security → verify`. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. -Изменение заголовка PR тоже повторяет проверки Conventional Commits. +Изменение заголовка PR повторяет только проверку Conventional Commits; `security` +и `verify` на metadata-only событии не запускаются. Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. Job `verify` запускает `npm ci`, генерацию Prisma client, -- 2.54.0 From da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 14:04:07 +0300 Subject: [PATCH 07/22] perf: start bond search prices with details (WealthLedger/backend#71) (#89) --- .../application/catalog-discovery.service.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 93901be..f1c62c4 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -304,16 +304,18 @@ export class CatalogDiscoveryService { input.query, input.instrumentType, ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); - const details = await Promise.all( - instruments.map((item) => - input.instrumentType === 'share' - ? this.tbank.getShare(token, item.uid) - : this.tbank.getBond(token, item.uid), + const [details, prices] = await Promise.all([ + Promise.all( + instruments.map((item) => + input.instrumentType === 'share' + ? this.tbank.getShare(token, item.uid) + : this.tbank.getBond(token, item.uid), + ), ), - ); - const prices = instruments.length - ? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) - : []; + instruments.length + ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) + : [], + ]); const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); -- 2.54.0 From b90cedd580321fb560a4ad0d9f9f5ba03303248e Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 17:01:53 +0300 Subject: [PATCH 08/22] chore: unify verification preparation (WealthLedger/backend#66) --- .gitea/workflows/image.yml | 3 +-- .gitea/workflows/verify.yml | 4 +--- Dockerfile | 3 ++- README.md | 13 +++++++++---- package.json | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml index 15ed222..3e9dc7a 100644 --- a/.gitea/workflows/image.yml +++ b/.gitea/workflows/image.yml @@ -28,9 +28,8 @@ jobs: docker version docker buildx version - run: npm ci - - run: npx --no-install prisma generate - run: npm run verify - - run: npm run test:e2e + - run: node test/sqlite.cjs --all - name: Login with dedicated Nexus writer env: REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }} diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index d1d3318..2280f3c 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -83,12 +83,10 @@ jobs: with: node-version-file: .nvmrc - run: npm ci - - name: Generate Prisma client before typechecks - run: npx --no-install prisma generate - name: Run all backend gates run: npm run verify - name: Run isolated SQLite and runtime OpenAPI suites - run: npm run test:e2e + run: node test/sqlite.cjs --all - name: Analyze develop with SonarQube if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop' # v2.3.0: same Docker-based scanner used for SonarQube 9.9. diff --git a/Dockerfile b/Dockerfile index 56f6250..9dd0866 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ EXPOSE 3000 CMD ["npm", "run", "start:dev"] FROM dependencies AS build -RUN npm run build && npm prune --omit=dev --omit=optional +# The client is already generated at the shared dependencies stage (same revision). +RUN npx --no-install nest build && npm prune --omit=dev --omit=optional FROM base AS production RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \ diff --git a/README.md b/README.md index 3d730e0..64221dc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ cp .env.example .env nvm use --silent npm ci npx prisma migrate deploy +npx prisma generate npm run start:dev ``` @@ -60,7 +61,7 @@ Compose topology принадлежит workspace, эксплуатационн npm run verify ``` -Он запускает zero-warning lint, +Он запускает генерацию Prisma client, zero-warning lint, application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная записывающая команда. `npm run test:cov` также доступен отдельно; общего числового threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites @@ -84,8 +85,9 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и и `verify` на metadata-only событии не запускаются. Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. -Job `verify` запускает `npm ci`, генерацию Prisma client, -`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout +Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client +внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all` +на уже собранных артефактах. Используется только checkout backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. @@ -253,7 +255,10 @@ breaking changes относительно предыдущего выпуска. проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`. Логи остаются в каталоге из вывода. Для одной suite: -`node test/sqlite.cjs app.e2e-spec.ts`. +`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в +`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед +адресным запуском этой suite выполните `npm run build`, иначе child/dist может +расходиться с parent/src. | Риск | Проверка реальной DB | | --- | --- | diff --git a/package.json b/package.json index 95ca29e..ddcfd8c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", - "start:debug": "nest start --debug --watch --path tsconfig.dev.json", + "start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json", "start:prod": "node dist/src/main.js", "lint": "npm run lint:check", "lint:check": "eslint . --max-warnings=0", @@ -31,7 +31,7 @@ "test:container": "node test/container-smoke.cjs", "test:durability": "node test/bank-cards-durability.cjs", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", - "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", + "verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", "commitlint": "commitlint", -- 2.54.0 From 14bcb9da5ff081710d672d140175d348a03a2fe5 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 18:00:20 +0300 Subject: [PATCH 09/22] fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58) --- .../brokerage-accounts.service.ts | 3 +- .../income/income-analytics.service.ts | 1 + .../brokerage-operations.service.ts | 2 + .../tbank-sync/tbank-sync.repository.ts | 5 +- test/tbank-income-local.e2e-spec.ts | 4 +- test/tbank-local-reads.e2e-spec.ts | 69 +++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 9a003ca..8ad93bd 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { PortfolioService } from '../portfolio/portfolio.api'; -import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; +import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto'; @Injectable() @@ -61,6 +61,7 @@ export class BrokerageAccountsService { : null, freshForMs: 3_600_000, work: null, + lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']), dependencies: saved.accounts.map((account) => account.meta), }), sync: hourly diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index e654c62..0e4f4bd 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -98,6 +98,7 @@ export class IncomeAnalyticsService { work: work.id ? { id: work.id, state: work.state, errorCode: work.errorCode } : null, + lastAttempt: current.meta.lastAttempt, coverage: current.meta.coverage, dependencies, }), diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 193cfd0..2a1ef46 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -435,6 +435,7 @@ export class BrokerageOperationsService { saved = await this.repository.readPage(input); } const now = new Date(); + const hourly = await this.hourlySync.hourly(userId); const membership = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), @@ -503,6 +504,7 @@ export class BrokerageOperationsService { fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, + lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId), dependencies: [membership], coverage: { requested: targets.map(serialize), diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index cebd86f..b10fbcc 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -43,16 +43,17 @@ type HourlyAttempt = { export function hourlyAttempt( hourly: HourlyAttempt | null, - section: HourlySection['section'], + section: HourlySection['section'] | readonly HourlySection['section'][], accountId?: string, ): InvestmentAttemptDto { if (!hourly) { return { state: 'none', startedAt: null, finishedAt: null, problems: [] }; } + const sections = Array.isArray(section) ? section : [section]; const problems = hourly.sections .filter( (entry) => - entry.section === section && + sections.includes(entry.section) && (accountId === undefined || entry.accountId === accountId), ) .map((entry) => ({ diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 2e16feb..121ebab 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -266,7 +266,9 @@ describe('local income completeness and exact money (SQLite)', () => { await repo.fail(fence, false); const after = await read(); expect(after.data).toEqual(before.data); - expect(after.meta.lastAttempt.state).toBe('failed'); + // Without an hourly walk the durable attempt is absent; the failed + // source work stays a technical observation, not the hourly attempt. + expect(after.meta.lastAttempt.state).toBe('none'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index a90401a..bdea5a7 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -15,6 +15,7 @@ import { } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -325,6 +326,74 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); + it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => { + await put().expect(200); + await seedMembership(); + await requestPortfolio('account'); + await settled(); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const startedAt = new Date('2026-10-10T10:00:00.000Z'); + const finishedAt = new Date('2026-10-10T10:05:00.000Z'); + await prisma.tbankSyncWork.create({ + data: { + id: 'hourly-attempt', + userId: 'owner', + revision: connection.revision, + scopeKey: 'hourly', + intent: {}, + state: 'complete', + attemptId: 'hourly-attempt', + processRunId: 'test', + priority: 0, + progress: { + result: 'partial', + sections: [ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }, + createdAt: startedAt, + updatedAt: finishedAt, + }, + }); + const accounts = await get('brokerage-accounts').expect(200); + const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta; + expect(accountsMeta.lastAttempt.state).toBe('partial'); + expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(accountsMeta.lastAttempt.problems).toEqual([ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + const income = await get( + 'brokerage-accounts/account/income-analytics?sync=observe', + ).expect(200); + const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; + expect(incomeMeta.lastAttempt.state).toBe('partial'); + expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(incomeMeta.lastAttempt.problems).toEqual([ + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + }); + it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => { await put().expect(200); const connection = await prisma.tbankConnection.update({ -- 2.54.0 From 6975853656698b61781ca056f35c0d68e47ea32a Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 22:43:08 +0300 Subject: [PATCH 10/22] fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54) --- .../tbank-sync/tbank-sync.repository.ts | 14 +++- test/tbank-hourly-lifecycle.e2e-spec.ts | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index b10fbcc..dbeab81 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -183,7 +183,8 @@ export class TbankSyncRepository { // The timer only proposes work; this reservation is the durable fence. The // attempt start (createdAt) is the basis of the next hourly term, including - // failed and interrupted attempts. + // failed and interrupted attempts. After downtime the next tick is due at + // once, so no catch-up queue forms. reserveHourly(userId: string, runId: string, hourMs: number) { return this.prisma.$transaction(async (tx) => { const connection = await tx.tbankConnection.findFirst({ @@ -201,7 +202,16 @@ export class TbankSyncRepository { }); const now = new Date(); if (previous) { - const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs; + // Terms are hourMs steps from the attempt start; a walk still running + // at a term consumes it. A dead process's attempt has no known end, so + // it consumes only its own term. + const started = previous.createdAt.valueOf(); + const ended = + previous.state === 'running' ? started : previous.updatedAt.valueOf(); + const dueAt = + started + + Math.max(hourMs, Math.ceil((ended - started) / hourMs) * hourMs); + const due = now.valueOf() >= dueAt; if (previous.state === 'running') { if (previous.processRunId === runId) return null; // A dead process cannot finish its attempt; close it from its diff --git a/test/tbank-hourly-lifecycle.e2e-spec.ts b/test/tbank-hourly-lifecycle.e2e-spec.ts index 08a1509..b9bdae5 100644 --- a/test/tbank-hourly-lifecycle.e2e-spec.ts +++ b/test/tbank-hourly-lifecycle.e2e-spec.ts @@ -385,6 +385,83 @@ describe('hourly attempt lifecycle (SQLite)', () => { expect(next?.state).not.toBe('running'); }); + describe('hourly term eligibility', () => { + const at = (h: number, m = 0) => new Date(Date.UTC(2026, 0, 1, h, m)); + const seed = ( + state: 'complete' | 'failed' | 'running', + start: Date, + end: Date, + processRunId = 'other-process', + ) => + prisma.tbankSyncWork.create({ + data: { + id: randomUUID(), + userId: USER, + revision: REVISION, + scopeKey: 'hourly', + attemptId: randomUUID(), + processRunId, + priority: 0, + intent: {}, + progress: {}, + state, + createdAt: start, + updatedAt: end, + }, + }); + const reserve = async (when: Date) => { + jest.setSystemTime(when); + return repository.reserveHourly(USER, 'this-process', 3_600_000); + }; + + it.each([ + ['short attempt', 'complete', at(10), at(10, 5), at(10, 59), at(11)], + ['failed attempt', 'failed', at(10), at(10, 5), at(10, 59), at(11)], + ['walk past one term', 'complete', at(10), at(11, 10), at(11, 59), at(12)], + ['walk past two terms', 'complete', at(10), at(12, 10), at(12, 59), at(13)], + ['walk ending on a term', 'complete', at(10), at(11), at(10, 59), at(11)], + ] as const)('%s', async (_name, state, start, end, early, due) => { + await seed(state, start, end); + expect(await reserve(early)).toBeNull(); + const reserved = await reserve(due); + expect(reserved?.work.state).toBe('running'); + expect(reserved?.work.createdAt).toEqual(due); + }); + + it('starts a new revision at once', async () => { + await seed('complete', at(10), at(10, 5)); + await prisma.tbankConnection.deleteMany({ where: { userId: USER } }); + await prisma.tbankConnection.create({ + data: { + userId: USER, + revision: 'next-revision', + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: at(10, 10), + }, + }); + expect((await reserve(at(10, 10)))?.work.revision).toBe('next-revision'); + }); + + it('closes a dead process attempt without early retry and runs one catch-up after downtime', async () => { + await seed('running', at(10), at(10, 1), 'dead-process'); + expect(await reserve(at(10, 30))).toBeNull(); + expect((await hourlyRow())?.errorCode).toBe('INTERRUPTED'); + expect(await reserve(at(10, 59))).toBeNull(); + expect((await reserve(at(14, 30)))?.work.createdAt).toEqual(at(14, 30)); + expect(await reserve(at(14, 31))).toBeNull(); + expect( + await prisma.tbankSyncWork.count({ + where: { userId: USER, scopeKey: 'hourly' }, + }), + ).toBe(1); + }); + + it('does not reserve while this process still runs the attempt', async () => { + await seed('running', at(10), at(10, 1), 'this-process'); + expect(await reserve(at(12, 30))).toBeNull(); + }); + }); + it('does not overlap a running attempt with a second walk', async () => { operations.request.mockImplementation(async () => { const row = await child('running', null, 'operations'); -- 2.54.0 From 0d7839cf79669768bba55f420ed43304545ac8d9 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 05:41:21 +0300 Subject: [PATCH 11/22] fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94) --- src/common/logging/structured-logger.service.ts | 1 + src/integrations/tbank/tbank.client.spec.ts | 2 ++ src/integrations/tbank/tbank.client.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 095e896..a05baaa 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -18,6 +18,7 @@ const allowedFields = new Set([ 'grpcCode', 'headersReceived', 'method', + 'networkMs', 'operation', 'queueWaitMs', 'requestId', diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index a35da59..4f73c6b 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -165,6 +165,8 @@ describe('TbankClient operations', () => { operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED, queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false, })); + const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }]; + expect(typeof fields.networkMs).toBe('number'); expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); } finally { jest.useRealTimers(); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index c6100b9..e3be72a 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -64,6 +64,7 @@ type TbankClientService = Client & Record>; type RpcDiagnostics = { channelState?: string; headersReceived?: boolean; + networkMs?: number; queueWaitMs?: number; trackingId?: string; }; @@ -524,10 +525,13 @@ export class TbankClient { throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } const response = await this.queue.run(() => { - diagnostics.queueWaitMs = Math.round(performance.now() - startedAt); + const sentAt = performance.now(); + diagnostics.queueWaitMs = Math.round(sentAt - startedAt); return this.request( token, client, method, request, diagnostics, - ); + ).finally(() => { + diagnostics.networkMs = Math.round(performance.now() - sentAt); + }); }); this.logger.event('debug', 'tbank.rpc.completed', { operation: method, -- 2.54.0 From 2ad3517d87bffa8af9285d72fd6f78b96b3eed9f Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 06:00:39 +0300 Subject: [PATCH 12/22] fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95) --- .../instruments/stocks/presentation/stock.dto.ts | 2 +- src/openapi.ts | 13 ++++++++++++- test/openapi-contract.e2e-spec.ts | 7 +++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index c4d47f4..14b0e60 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -93,7 +93,7 @@ export class StockScreenerItemDto { @ApiProperty({ nullable: true, type: String }) declare currency: null | string; @ApiProperty({ nullable: true, type: String }) declare realExchange: null | string; @ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string; - @ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number; + @ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number; } export class StockScreenerPageDto { diff --git a/src/openapi.ts b/src/openapi.ts index 46e13fb..3ea3fda 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -3,9 +3,20 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; export const API_PREFIX = 'api/v1'; +// Nest Swagger cannot express additionalProperties on a class, so closed request +// bodies are restored here after generation; the runtime allowlist stays in the controller. +const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto']; + export function createOpenApiDocument(app: INestApplication) { - return SwaggerModule.createDocument( + const document = SwaggerModule.createDocument( app, new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(), ); + const schemas = document.components?.schemas ?? {}; + for (const [name, schema] of Object.entries(schemas)) { + if (CLOSED_SCHEMAS.includes(name) && 'properties' in schema) { + schema.additionalProperties = false; + } + } + return document; } diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index d95b945..f9eef12 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -122,6 +122,13 @@ describe('OpenAPI contract (integration)', () => { }); it('generates the runtime contract without server startup or deployment configuration', async () => { + const schemas = createOpenApiDocument(app).components?.schemas; + expect(schemas?.StockScreenerCriteriaDto).toMatchObject({ + additionalProperties: false, + }); + expect(schemas?.StockScreenerItemDto).toMatchObject({ + properties: { lot: { type: 'integer', minimum: 1, nullable: true } }, + }); expect( createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto, ).toMatchObject({ -- 2.54.0 From c789e33c41281f2fc3de8851f21ae897a81e2b5d Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 06:15:47 +0300 Subject: [PATCH 13/22] chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96) --- package-lock.json | 3 ++- package.json | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5aac605..9b08ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "globals": "17.12.0", "jest": "30.5.1", "release-it": "21.0.2", - "source-map-support": "0.5.21", "supertest": "7.2.2", "ts-jest": "29.4.12", "ts-loader": "9.6.2", @@ -13624,6 +13623,7 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13635,6 +13635,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index ddcfd8c..4f195a1 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "globals": "17.12.0", "jest": "30.5.1", "release-it": "21.0.2", - "source-map-support": "0.5.21", "supertest": "7.2.2", "ts-jest": "29.4.12", "ts-loader": "9.6.2", -- 2.54.0 From 96d4de4195b105d64ac6ffaf8d3d8aab4142ece4 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 10:46:58 +0300 Subject: [PATCH 14/22] refactor: remove unreachable catalog producer and sync orchestration (WealthLedger/backend#62) (#98) --- eslint-suppressions.json | 8 +- .../application/catalog-discovery.service.ts | 206 +----------- .../investments/tbank-sync/tbank-sync.dto.ts | 27 -- .../tbank-sync/tbank-sync.service.ts | 293 +--------------- test/tbank-catalog-discovery.e2e-spec.ts | 316 ++++-------------- 5 files changed, 75 insertions(+), 775 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index aa4eafc..4506661 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -54,9 +54,6 @@ } }, "src/modules/investments/instruments/application/catalog-discovery.service.ts": { - "sonarjs/cognitive-complexity": { - "count": 1 - }, "sonarjs/no-nested-conditional": { "count": 1 } @@ -142,11 +139,8 @@ } }, "src/modules/investments/tbank-sync/tbank-sync.service.ts": { - "sonarjs/cognitive-complexity": { - "count": 1 - }, "sonarjs/no-nested-conditional": { - "count": 7 + "count": 6 } }, "test/bank-cards-durability.cjs": { diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index f1c62c4..8202b42 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -3,15 +3,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -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, tbankSourceTtlMs, - type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { InstrumentErrorCode } from '../domain/instrument-error-code'; @@ -31,42 +27,22 @@ import { publicInstrument } from './public-instrument'; const UNAMBIGUOUS_MATCH_COUNT = 1; const NO_MATCHES = 0; const FIRST_RESULT_OFFSET = 0; -const MISSING_DATA_REVISION = '0'; const tickerAmbiguityLimit = 2; -type Producer = { - attempt: TbankSyncAttempt; - force: boolean; - input: CatalogQuery; - observers: TbankSyncAttempt[]; -}; @Injectable() export class CatalogDiscoveryService { - private readonly runId = randomUUID(); - private readonly active = new Set(); - private readonly producers = new Map(); - private readonly queue: Producer[] = []; - private reservations: Promise = Promise.resolve(); - private running = false; - private readonly completions = new Map< - string, - { done: Promise; resolve: () => void; workId: string } - >(); - constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, private readonly tbank: TbankClient, - private readonly logger: StructuredLogger, private readonly enrichment: CatalogEnrichmentService, ) {} - async read(userId: string, input: CatalogQuery, observe = false) { + async read(userId: string, input: CatalogQuery) { input = catalogQuery(input); const connection = await this.access.local(userId); const saved = await this.repository.read(input); - if (!observe) await this.request(userId, connection.revision, input); return { data: saved.data, meta: investmentResourceMeta({ @@ -82,7 +58,7 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { if (!observe) return this.onlineSearch(userId, catalogQuery(input)); - const discovery = await this.read(userId, input, observe); + const discovery = await this.read(userId, input); const sources = discovery.data ? await this.enrichment.readMany( userId, @@ -182,11 +158,11 @@ export class CatalogDiscoveryService { instrument?.ticker && /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker) ) { - const alias = await this.read( - userId, - { instrumentType, query: instrument.ticker, exactTicker: true }, - observe, - ); + const alias = await this.read(userId, { + instrumentType, + query: instrument.ticker, + exactTicker: true, + }); if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); } @@ -247,11 +223,11 @@ export class CatalogDiscoveryService { const result = await this.detail(userId, instrumentType, matches[0].uid); return { ...result, data: result.data ? { ...result.data, slug: query } : null }; } - const lookup = await this.read( - userId, - { instrumentType, query: ticker, exactTicker: true }, - observe, - ); + const lookup = await this.read(userId, { + instrumentType, + query: ticker, + exactTicker: true, + }); if (lookup.data?.length === NO_MATCHES) throw new NotFoundException(); if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ @@ -337,162 +313,4 @@ export class CatalogDiscoveryService { }), }; } - - request( - userId: string, - revision: string, - query: CatalogQuery, - force = false, - ) { - const input = catalogQuery(query); - const reserved = this.reservations.then(async () => { - const saved = await this.repository.read(input); - if ( - !force && - saved.dataRevision !== MISSING_DATA_REVISION - ) return null; - const reservation = await this.repository.reserve( - { userId, revision }, - input, - this.runId, - this.active, - ); - if (!reservation) return null; - const { id, attemptId } = reservation.work; - if (!reservation.launch) { - const current = this.producers.get(catalogScope(input)); - if (current) current.force ||= force; - return id; - } - const attempt = { userId, revision, id, attemptId }; - this.active.add(attemptId); - let resolve = () => {}; - const done = new Promise((finish) => { - resolve = finish; - }); - this.completions.set(attemptId, { workId: id, done, resolve }); - const key = catalogScope(input); - const producer = this.producers.get(key); - if (producer) { - producer.observers.push(attempt); - producer.force ||= force; - } else { - const queued = { input, attempt, observers: [], force }; - this.producers.set(key, queued); - this.queue.push(queued); - } - if (!this.running) { - this.running = true; - setImmediate(() => { - void this.drain(); - }); - } - return id; - }); - this.reservations = reserved.catch(() => undefined); - return reserved; - } - - async refresh(userId: string, revision: string, query: CatalogQuery) { - const id = await this.request(userId, revision, query, true); - if (id) { - await Promise.all( - [...this.completions.values()] - .filter((entry) => entry.workId === id) - .map((entry) => entry.done), - ); - } - const saved = await this.repository.read(catalogQuery(query)); - const sources = await this.enrichment.readMany( - userId, - query.instrumentType, - saved.data?.map((item) => item.uid) ?? [], - true, - ); - return [ - ...(id ? [id] : []), - ...sources.flatMap((source) => - source.meta.sync.id ? [source.meta.sync.id] : [], - ), - ]; - } - - private async drain() { - try { - while (this.queue.length) { - const producer = this.queue.shift(); - if (!producer) break; - const { input, attempt } = producer; - let success = false; - try { - const token = await this.access.tokenForAttempt(attempt); - if (token) { - const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); - success = await this.repository.publish( - attempt, - input, - selectCatalogInstruments( - input.exactTicker - ? found.filter( - (item) => - item.ticker?.trim().toUpperCase() === input.query, - ) - : found, - input.query, - input.instrumentType, - ).slice( - FIRST_RESULT_OFFSET, - input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, - ), - new Date(), - ); - } - } catch (error) { - if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { - await this.access.rejectMembershipToken(attempt); - } else { - this.logger.event('warn', 'catalog-discovery.failed', { - code: TbankErrorCode.Unavailable, - }); - } - } - if (success) { - const saved = await this.repository.read(input); - for (const observer of [attempt, ...producer.observers]) { - if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) { - await this.enrichment.requestMany( - observer.userId, - observer.revision, - input.instrumentType, - saved.data?.map((item) => item.uid) ?? [], - producer.force, - input.exactTicker ? ['detail'] : ['last-price', 'detail'], - ); - } - } - } - this.producers.delete(catalogScope(input)); - for (const observer of [attempt, ...producer.observers]) { - await this.repository.finish(observer, success); - this.active.delete(observer.attemptId); - this.completions.get(observer.attemptId)?.resolve(); - this.completions.delete(observer.attemptId); - } - } - } catch { - for (const completion of this.completions.values()) completion.resolve(); - this.completions.clear(); - this.active.clear(); - this.producers.clear(); - this.queue.length = 0; - this.logger.event('error', 'catalog-discovery.persistence-failed', { - code: TbankErrorCode.Unavailable, - }); - } finally { - this.running = false; - } - } } diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 2eb0741..cfb5d78 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -209,13 +209,6 @@ export const investmentScreenSchema = { }, }; -export class TbankSyncAcceptedDto { - @ApiProperty({ format: 'uuid' }) declare id: string; - @ApiProperty({ type: Object, ...investmentScreenSchema }) - declare scope: InvestmentScreenDto; - - @ApiProperty({ format: 'date-time' }) declare now: string; -} export class TbankSyncStatusDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) @@ -229,26 +222,6 @@ export class TbankSyncStatusDto { null | string; } -export class HourlySyncSectionDto { - @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] }) - declare section: 'events' | 'membership' | 'operations' | 'portfolio'; - - @ApiPropertyOptional({ - type: String, - nullable: false, - description: 'Account identifier of the problematic section, when scoped.', - }) - declare accountId?: string; - - @ApiProperty({ - type: String, - nullable: true, - description: - 'Safe error code of the failed section. No tokens or upstream payloads.', - }) - declare errorCode: null | string; -} - export class HourlySyncStatusDto { @ApiProperty({ enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'], diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 7c59e86..bdc37a8 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -2,314 +2,27 @@ import { ConflictException, Injectable, NotFoundException, - ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; -import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; -import { - InstrumentMetadataService, - TbankAccessService, - TbankMembershipService, - type TbankSyncAttempt, -} from '../../../integrations/tbank/tbank.api'; -import { - CandleService, - CatalogDiscoveryService, - CatalogEnrichmentService, -} from '../instruments/instruments.api'; -import { planInvestmentScreen } from '../investment-sync-plan'; -import { - BrokerageOperationsSyncService, - type OperationInterval, -} from '../operations/operations.api'; -import { PortfolioService } from '../portfolio/portfolio.api'; -import { normalizeScreen } from './tbank-screen'; -import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; +import { TbankAccessService } from '../../../integrations/tbank/tbank.api'; +import { BrokerageOperationsSyncService } from '../operations/operations.api'; +import { TbankSyncStatusDto } from './tbank-sync.dto'; import { type ScreenIntent, TbankSyncRepository, } from './tbank-sync.repository'; -const FOREGROUND_CANDLE_PRIORITY = 0; - @Injectable() export class TbankSyncService { private readonly runId = randomUUID(); private readonly startedAt = new Date(); - private reservations: Promise = Promise.resolve(); constructor( private readonly repository: TbankSyncRepository, private readonly access: TbankAccessService, - private readonly membership: TbankMembershipService, - private readonly portfolio: PortfolioService, private readonly operations: BrokerageOperationsSyncService, - private readonly metadata: InstrumentMetadataService, - private readonly discovery: CatalogDiscoveryService, - private readonly sources: CatalogEnrichmentService, - private readonly candles: CandleService, ) {} - async request(userId: string, input: InvestmentScreenDto) { - const scope = normalizeScreen(input); - const connection = await this.access.local(userId, scope.accountId); - const reservation = this.reservations.then(() => - this.repository.reserve(userId, connection.revision, scope, this.runId), - ); - this.reservations = reservation.catch(() => undefined); - const reserved = await reservation; - if (!reserved) throw new ServiceUnavailableException(); - const intent = reserved.work.intent as unknown as ScreenIntent; - if (reserved.launch) { - const attempt = { - userId, - revision: connection.revision, - id: reserved.work.id, - attemptId: reserved.work.attemptId, - }; - setImmediate(() => { - void this.dispatch(attempt, intent); - }); - } - return { id: reserved.work.id, ...intent }; - } - - private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) { - const children: string[] = []; - const add = async (ids: (null | string | undefined)[]) => { - children.push(...ids.filter((id): id is string => Boolean(id))); - await this.repository.progress(attempt, children, false); - }; - try { - const { userId, revision } = attempt; - const { scope } = intent; - const now = new Date(intent.now); - const member = await this.membership.request(userId, revision, true); - await add([member?.id]); - await member?.done; - const current = await this.access.local(userId, scope.accountId); - if (current.revision !== revision) return; - const accountScope = scope.accountId ?? 'all'; - const selectedAccounts = current.accounts.filter( - (account) => - accountScope === 'all' || account.accountId === accountScope, - ); - if ( - ['account', 'accounts', 'bond', 'events', 'stock'].includes( - scope.screen, - ) - ) { - for (const account of scope.screen === 'stock' || - scope.screen === 'bond' - ? current.accounts - : selectedAccounts) { - const work = await this.portfolio.request( - userId, - revision, - account.accountId, - true, - ); - await add([work?.id]); - await work?.done; - } - } - const saved = await this.portfolio.read( - userId, - scope.screen === 'stock' || scope.screen === 'bond' - ? 'all' - : accountScope, - ); - const positions = saved.data?.positions ?? []; - const instruments = [ - ...new Map( - positions.flatMap((position) => - position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') - ? [ - [ - position.instrumentUid, - { - uid: position.instrumentUid, - type: position.type, - historyFrom: null, - }, - ] as const, - ] - : [], - ), - ).values(), - ]; - const plan = planInvestmentScreen( - { - ...scope, - candleRange: scope.candleRange - ? { - from: new Date(scope.candleRange.from), - to: new Date(scope.candleRange.to), - } - : undefined, - }, - { - accounts: selectedAccounts, - instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' - ? [] - : instruments, - membershipKnown: Boolean(current.membershipAt), - }, - now, - true, - ); - const intervals: OperationInterval[] = plan.sources.flatMap((source) => - source.kind === 'operations' && - source.accountId && - source.from && - source.to - ? [ - { - accountId: source.accountId, - from: source.from, - to: source.to, - ...(source.instrumentUid - ? { instrumentUid: source.instrumentUid } - : {}), - }, - ] - : [], - ); - if (intervals.length) { - await add([ - await this.operations.request( - userId, - intervals, - true, - 'screen', - revision, - ), - ]); - } - for (const source of plan.sources) { - const uid = source.instrumentUid; - const type = source.instrumentType === 'BOND' ? 'bond' : 'share'; - if (source.kind === 'catalog') { - await add( - await this.discovery.refresh(userId, revision, { - instrumentType: type, - query: source.query ?? '', - }), - ); - } - if (!uid) continue; - if (source.kind === 'metadata') { - await add([ - await this.metadata.request(userId, revision, { - instrumentUid: uid, - }), - ]); - } - if (source.kind === 'quote') { - await add( - await this.sources.requestMany( - userId, - revision, - type, - [uid], - true, - [ - type === 'bond' ? 'bond-current' : 'detail', - 'last-price', - 'trading-status', - 'comparison', - ], - ), - ); - } - if (source.kind === 'company') { - await add( - await this.sources.requestMany( - userId, - revision, - type, - [uid], - true, - ['description', 'fundamentals'], - ), - ); - } - if (source.kind === 'metrics') { - await add( - await this.sources.requestMany( - userId, - revision, - type, - [uid], - true, - ['coupons', 'yield', 'rating'], - ), - ); - } - if (source.kind === 'calendar' && source.from && source.to) { - await add( - await this.sources.requestMany( - userId, - revision, - type, - [uid], - true, - [ - type === 'share' - ? 'dividends' - : scope.screen === 'bond' - ? 'coupons' - : 'bond-events', - ], - { - from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), - to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), - ticker: - positions.find((position) => position.instrumentUid === uid) - ?.ticker ?? undefined, - }, - ), - ); - } - } - if ( - (scope.screen === 'stock' || scope.screen === 'bond') && - scope.instrumentUid - ) { - await add([ - await this.candles.request( - userId, - revision, - { - uid: scope.instrumentUid, - instrumentType: scope.screen === 'bond' ? 'bond' : 'share', - period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { - range: { - from: new Date(scope.candleRange.from), - to: new Date(scope.candleRange.to), - }, - } - : {}), - }, - now, - true, - FOREGROUND_CANDLE_PRIORITY, - ), - ]); - } - await this.repository.progress(attempt, children, true); - } catch { - await this.repository - .progress(attempt, children, true, TbankErrorCode.Unavailable) - .catch(() => undefined); - } - } - async status(userId: string, id: string): Promise { const connection = await this.access .local(userId) diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index cab7432..3dc6f2b 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -1,6 +1,5 @@ import { ConfigService } from '@nestjs/config'; import assert from 'node:assert/strict'; -import { setTimeout as delay } from 'node:timers/promises'; import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; @@ -12,7 +11,7 @@ import { } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; import { PrismaService } from '../src/prisma/prisma.service'; -// Real transactions prove query coverage, private work ownership and late-publication fences. +// Real transactions prove query normalization, confirmed-empty identity and cross-user isolation of shared reads. describe('shared catalog discovery (SQLite)', () => { const prisma = new PrismaService( new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), @@ -30,7 +29,6 @@ describe('shared catalog discovery (SQLite)', () => { repository, access, upstream as never, - { event: jest.fn() } as never, { requestMany: jest.fn().mockResolvedValue([]) } as never, ); let service: CatalogDiscoveryService; @@ -51,14 +49,42 @@ describe('shared catalog discovery (SQLite)', () => { connectedAt: new Date(), }, }); - async function settled() { - for (let i = 0; i < 300; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { - return; - } - await delay(5); + // Writes a finished discovery directly, so the read path is tested without any producer. + async function seedCatalog( + query: string, + instruments: Array<{ payload: CatalogInstrument; uid: string }>, + ) { + for (const item of instruments) { + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: item.uid, + instrumentType: 'share', + metadata: {}, + fetchedAt: new Date(), + }, + }); + await prisma.tbankInstrumentSource.create({ + data: { + provider: 'tbank', + instrumentUid: item.uid, + source: 'tbank', + kind: 'catalog', + rangeKey: 'share', + payload: item.payload, + fetchedAt: new Date(), + }, + }); } - throw new Error('Catalog work did not settle'); + await prisma.tbankCatalogDiscovery.create({ + data: { + provider: 'tbank', + instrumentType: 'share', + query, + instrumentUids: instruments.map((item) => item.uid), + fetchedAt: new Date(), + }, + }); } beforeAll(async () => { await prisma.$connect(); @@ -72,265 +98,41 @@ describe('shared catalog discovery (SQLite)', () => { beforeEach(async () => { await prisma.tbankConnection.deleteMany(); await prisma.tbankCatalogDiscovery.deleteMany(); + await prisma.tbankInstrumentSource.deleteMany(); await prisma.tbankInstrument.deleteMany(); await seed('owner'); await seed('other'); jest.resetAllMocks(); - upstream.findShares.mockResolvedValue([instrument]); - upstream.findBonds.mockResolvedValue([ - { ...instrument, uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, - ]); service = createService(); }); - it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => { + it('treats an unknown query as unavailable and a seeded query as available for another user', async () => { const cold = await service.read('owner', { ...query, query: ' sber ' }); expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('initial-loading'); - await settled(); - await prisma.$disconnect(); - service = createService(); - const decrypt = jest.spyOn(tokens, 'decrypt'); + expect(cold.meta.availability).toBe('temporarily-unavailable'); + await seedCatalog('SBER', [{ uid: 'uid', payload: instrument }]); const warm = await service.read('other', query); expect(warm.data).toEqual([instrument]); expect(warm.meta.availability).toBe('available'); - expect(warm.meta.completeness).toBe('complete'); - expect(warm.meta.sync.id).toBeNull(); - expect(decrypt).not.toHaveBeenCalled(); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - expect( - (await service.read('other', { ...query, query: 'GAZP' }, true)).data, - ).toBeNull(); - upstream.findShares.mockResolvedValue([]); - await service.read('other', { ...query, query: 'GAZP' }); - await settled(); - expect( - (await service.read('owner', { ...query, query: 'GAZP' })).data, - ).toEqual([]); - expect((await service.read('owner', query)).data).toEqual([instrument]); - expect(upstream.findShares).toHaveBeenCalledTimes(2); - decrypt.mockRestore(); - await service.read('owner', { ...query, instrumentType: 'bond' }); - await settled(); - expect( - (await service.read('other', { ...query, instrumentType: 'bond' })) - .data?.[0].uid, - ).toBe('bond'); - }); - it('keeps selection ordering, deduplicates before limiting, and retains identity without enrichment or private fields', async () => { - upstream.findShares.mockResolvedValue([ - { ...instrument, uid: 'unavailable', apiTradeAvailableFlag: false }, - { - ...instrument, - uid: 'preferred', - apiTradeAvailableFlag: true, - classCode: 'TQBR', - quantity: '999', - estimatedAmount: 'secret', - }, - ...Array.from({ length: 40 }, (_, i) => ({ - ...instrument, - uid: `other-${String(i).padStart(2, '0')}`, - isin: undefined, - ticker: 'OTHER', - })), - { - ...instrument, - uid: 'wrong-kind', - instrumentKind: 'INSTRUMENT_TYPE_BOND', - }, - ]); - await service.read('owner', query); - await settled(); - const saved = await service.read('other', query); - expect(saved.data).toHaveLength(30); - expect(saved.data?.[0].uid).toBe('preferred'); - expect( - saved.data?.some((row) => - ['unavailable', 'wrong-kind'].includes(row.uid), - ), - ).toBe(false); - expect( - JSON.stringify(await prisma.tbankInstrumentSource.findMany()), - ).not.toMatch( - /quantity|estimatedAmount|secret|owner-revision|synthetic-owner/, - ); - expect(await prisma.tbankInstrumentLookup.count()).toBe(0); - }); - it('does not overwrite detailed metadata or its timestamp while refreshing discovery', async () => { - const detailed = { ...instrument, sector: 'financial', lot: 10 }; - const fetchedAt = new Date('2026-01-01'); - await prisma.tbankInstrument.create({ - data: { - provider: 'tbank', - uid: 'uid', - instrumentType: 'share', - metadata: detailed, - fetchedAt, - }, - }); - await service.read('owner', query); - await settled(); - const row = await prisma.tbankInstrument.findUniqueOrThrow({ - where: { provider_uid: { provider: 'tbank', uid: 'uid' } }, - }); - expect(row.metadata).toEqual(detailed); - expect(row.fetchedAt).toEqual(fetchedAt); - }); - it('coalesces twenty concurrent requests and forces with private handles and one upstream call', async () => { - const blocked = deferred(); - upstream.findShares.mockReturnValue(blocked.promise); - const reads = await Promise.all( - Array.from({ length: 20 }, (_, i) => - service.read(i % 2 ? 'owner' : 'other', query), - ), - ); - await service.request('owner', 'owner-revision', query, true); - await delay(10); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - expect(new Set(reads.map((read) => read.meta.sync.id)).size).toBe(2); - blocked.resolve([instrument]); - await settled(); - expect( - await prisma.tbankSyncWork.count({ where: { state: 'complete' } }), - ).toBe(2); - }); - it.each(['delete', 'replace'])( - 'discards late %s publication and does not borrow observer credentials', - async (action) => { - const blocked = deferred(); - upstream.findShares.mockReturnValueOnce(blocked.promise); - await service.read('owner', query); - await service.read('other', query); - await delay(10); - await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') { - await prisma.tbankConnection.create({ - data: { - userId: 'owner', - revision: 'replacement', - encryptedToken: tokens.encrypt('replacement'), - connectedAt: new Date(), - }, - }); - } - blocked.resolve([instrument]); - await settled(); - expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); - expect(await prisma.tbankInstrument.count()).toBe(0); - expect( - (await service.read('other', query, true)).meta.sync.errorCode, - ).toBe('TBANK_UNAVAILABLE'); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - await service.read('other', query); - await settled(); - expect((await service.read('other', query, true)).data).toEqual([ - instrument, - ]); - }, - ); - it('preserves a successful result and its timestamp after a failed force without observe retry', async () => { - await service.read('owner', query); - await settled(); - const before = await repository.read(query); - upstream.findShares.mockRejectedValue(new Error('outage')); - await service.request('owner', 'owner-revision', query, true); - await settled(); - for (let i = 0; i < 5; i++) await service.read('owner', query, true); - expect(await repository.read(query)).toEqual(before); - expect((await service.read('owner', query, true)).meta.sync.state).toBe( - 'failed', - ); - expect(upstream.findShares).toHaveBeenCalledTimes(2); - expect((await service.read('other', query)).meta.sync.errorCode).toBeNull(); - }); - it('isolates invalid credentials from shared empty lookup and another user', async () => { - const blocked = deferred(); - upstream.findShares.mockReturnValueOnce(blocked.promise); - await service.read('owner', query); - await service.read('other', query); - blocked.reject(new Error('INVALID_TBANK_TOKEN')); - await settled(); - expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); - expect( - ( - await prisma.tbankConnection.findUniqueOrThrow({ - where: { userId: 'owner' }, - }) - ).invalidToken, - ).toBe(true); - expect((await service.read('other', query, true)).meta.sync.errorCode).toBe( - 'TBANK_UNAVAILABLE', - ); - await service.read('other', query); - await settled(); - expect((await service.read('other', query, true)).data).toEqual([ - instrument, - ]); - }); - it('recovers abandoned work only on the next ordinary request and rejects superseded attempts', async () => { - const abandoned = await repository.reserve( - { userId: 'owner', revision: 'owner-revision' }, - query, - 'old-process', - new Set(), - ); - assert(abandoned); - service = createService(); - await service.read('owner', query, true); - await delay(10); expect(upstream.findShares).not.toHaveBeenCalled(); - await service.read('owner', query); - await settled(); - expect( - await repository.publish( - { - userId: 'owner', - revision: 'owner-revision', - id: abandoned.work.id, - attemptId: abandoned.work.attemptId, - }, - query, - [], - new Date(), - ), - ).toBe(false); - expect((await service.read('owner', query, true)).data).toEqual([ - instrument, - ]); }); - it('keeps known discovery stable between refresh requests', async () => { - await service.read('owner', query); - await settled(); - const now = Date.now(); - const clock = jest.spyOn(Date, 'now').mockReturnValue(now); - try { - await prisma.tbankCatalogDiscovery.updateMany({ - data: { fetchedAt: new Date(now - 86_399_000) }, - }); - await service.read('other', query); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - await prisma.tbankCatalogDiscovery.updateMany({ - data: { fetchedAt: new Date(now - 86_400_000) }, - }); - expect((await service.read('other', query, true)).meta.freshness).toBe('fresh'); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - await service.read('other', query); - expect(upstream.findShares).toHaveBeenCalledTimes(1); - } finally { - clock.mockRestore(); - } + it('keeps a confirmed empty result distinct from an unknown query', async () => { + await seedCatalog('GAZP', []); + const empty = await service.read('owner', { ...query, query: 'GAZP' }); + expect(empty.data).toEqual([]); + expect(empty.meta.availability).toBe('available'); + const unknown = await service.read('owner', { ...query, query: 'LKOH' }); + expect(unknown.data).toBeNull(); + }); + it('keeps the stored selection order and does not expose private fields', async () => { + const first = { ...instrument, uid: 'first', name: 'First' }; + const second = { ...instrument, uid: 'second', name: 'Second' }; + await seedCatalog('SBER', [ + { uid: 'second', payload: second }, + { uid: 'first', payload: first }, + ]); + const result = await service.read('owner', query); + assert(result.data); + expect(result.data.map((item) => item.uid)).toEqual(['second', 'first']); + expect(result.data[0]).not.toHaveProperty('accountId'); }); }); -function deferred() { - let resolve: (value: T) => void = () => { - throw new Error('Uninitialized deferred'); - }; - let reject: (error: Error) => void = () => { - throw new Error('Uninitialized deferred'); - }; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -- 2.54.0 From a93f2d55e7a31515ed1cc056cd57101f2b315405 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 11:24:38 +0300 Subject: [PATCH 15/22] chore: make OpenAPI export atomic and add read-only check (WealthLedger/backend#3) (#97) --- README.md | 5 +++- package.json | 1 + scripts/generate-openapi.cjs | 50 ++++++++++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 64221dc..622b183 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,10 @@ nvm use --silent npm run openapi:generate ``` -Команда собирает backend и записывает `openapi.json` в корень репозитория. +Команда собирает backend и записывает `openapi.json` в корень репозитория +атомарно: через временный файл и rename, без частичной записи при сбое. +`npm run openapi:check` только сравнивает runtime schema с файлом и завершается +с ошибкой при расхождении, не перезаписывая его. Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger diff --git a/package.json b/package.json index 4f195a1..c01afbc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "prisma generate && nest build", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs", + "openapi:check": "npm run build && node scripts/generate-openapi.cjs --check", "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", diff --git a/scripts/generate-openapi.cjs b/scripts/generate-openapi.cjs index ad8b646..a24a8ba 100644 --- a/scripts/generate-openapi.cjs +++ b/scripts/generate-openapi.cjs @@ -1,5 +1,10 @@ const { randomBytes } = require('node:crypto'); -const { writeFile } = require('node:fs/promises'); +const { readFile, rename, rm, writeFile } = require('node:fs/promises'); +const { resolve } = require('node:path'); + +// Relative to the working directory, as before: the contract test runs the script in a temporary directory. +const target = resolve(process.cwd(), 'openapi.json'); +const checkOnly = process.argv.includes('--check'); // Schema generation needs no deployment configuration or real credentials. Object.assign(process.env, { @@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core'); const { AppModule } = require('../dist/src/app.module.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); -async function generate() { +async function render() { const app = await NestFactory.create(AppModule, { preview: true, logger: false, }); try { app.setGlobalPrefix(API_PREFIX); - await writeFile( - 'openapi.json', - JSON.stringify(createOpenApiDocument(app), null, 2) + '\n', - ); + return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n'; } finally { await app.close(); } } -generate().catch((error) => { - console.error(error); - process.exitCode = 1; -}); +// Read-only: compares the runtime document with the committed artifact and never writes. +async function check(content) { + const current = await readFile(target, 'utf8').catch(() => null); + if (current !== content) { + console.error( + 'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.', + ); + process.exitCode = 1; + return; + } + console.log('openapi.json matches the runtime schema.'); +} + +// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output. +async function update(content) { + const temp = `${target}.${process.pid}.tmp`; + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input. + await writeFile(temp, content); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input. + await rename(temp, target); + } finally { + await rm(temp, { force: true }); + } +} + +render() + .then((content) => (checkOnly ? check(content) : update(content))) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); -- 2.54.0 From 9e4f9d9cab12aa714449b99280894a521c3514b6 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 11:50:47 +0300 Subject: [PATCH 16/22] test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65) (#100) --- test/all-accounts.e2e-spec.ts | 3 +++ test/app.e2e-spec.ts | 6 +++++- test/bank-cards.e2e-spec.ts | 16 +++++++++++++++- test/bonds.e2e-spec.ts | 3 +++ test/stocks.e2e-spec.ts | 3 +++ test/tbank-connection.e2e-spec.ts | 3 +++ test/tbank-income-local.e2e-spec.ts | 3 +++ test/tbank-ledger-local.e2e-spec.ts | 3 +++ test/tbank-local-reads.e2e-spec.ts | 3 +++ test/tbank-read-baseline.e2e-spec.ts | 3 +++ 10 files changed, 44 insertions(+), 2 deletions(-) diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index a238662..c94be41 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -9,6 +9,7 @@ import { AppModule } from '../src/app.module'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; describe('All accounts HTTP boundary', () => { @@ -31,6 +32,8 @@ describe('All accounts HTTP boundary', () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0193285..9083f05 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -10,6 +10,7 @@ import { AppModule } from '../src/app.module'; import { loggingOptions } from '../src/common/logging/logging.module'; import { configureApp } from '../src/main'; import { AuthService } from '../src/modules/auth/application/auth.service'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; type Authentication = { accessToken: string; user: { email: string } }; @@ -33,7 +34,10 @@ describe('Authentication (e2e)', () => { }, assignResponse: true, }), - }).compile(); + }) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) + .compile(); app = moduleFixture.createNestApplication(); configureApp(app); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 97effb8..11d74e6 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -16,6 +16,7 @@ import { BankSummaryDto, ExpenseCategoryDto, } from '../src/modules/banking/bank-cards/presentation/bank-card.dto'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; jest.setTimeout(60000); @@ -77,7 +78,10 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { const start = async () => { const module = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) + .compile(); app = module.createNestApplication(); configureApp(app); await app.listen(0, '127.0.0.1'); @@ -369,6 +373,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { expect((await getCard(own.id)).balance.value).toBe('1000'); }); it('renames default category without role loss; duplicates and stale rename conflict', async () => { + // Defaults are seeded by the first write of the user; this case must not depend on earlier cases. + await createCard(); const category = (await categories()).find((c) => c.isDefault); assert.ok(category); await auth('patch', '/categories/' + category.id) @@ -725,11 +731,19 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { expect(await getCard(card.id)).toEqual(before); expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id); await auth('post', '/operations').send(deletedBody).expect(410); + // The rename is this case's own prerequisite, not an effect of an earlier case. + const defaultCategory = (await categories()).find((c) => c.isDefault); + assert.ok(defaultCategory); + await auth('patch', '/categories/' + defaultCategory.id) + .send({ version: defaultCategory.version, name: 'Остальное' }) + .expect(200); expect((await categories()).find((c) => c.isDefault)?.name).toBe( 'Остальное', ); }); it('enforces actual FK and unique constraints', async () => { + // Seeds the owner's default categories so the unique-name check has a real row to collide with. + await createCard(); await expect( db.expenseCategory.create({ data: { diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 8e53c35..0b464d8 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -15,6 +15,7 @@ import { configureApp } from '../src/main'; import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { @@ -38,6 +39,8 @@ describe('Bond HTTP contract', () => { .useValue(bonds) .overrideProvider(BondIncomeService) .useValue(bonds) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 4e05cf5..abf3fe7 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -16,6 +16,7 @@ import { StockCatalogService } from '../src/modules/investments/instruments/stoc import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { @@ -41,6 +42,8 @@ describe('Stock HTTP contract', () => { .useValue(stocks) .overrideProvider(StockScreenerService) .useValue(screener) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/tbank-connection.e2e-spec.ts b/test/tbank-connection.e2e-spec.ts index f85298d..b06d5bc 100644 --- a/test/tbank-connection.e2e-spec.ts +++ b/test/tbank-connection.e2e-spec.ts @@ -10,6 +10,7 @@ import { TbankAccessService } from '../src/integrations/tbank/tbank-access.servi import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { @@ -31,6 +32,8 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 121ebab..c571d1d 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -15,6 +15,7 @@ import { } from '../src/modules/investments/income/income-analytics.dto'; import { moscowDate } from '../src/modules/investments/investment-calendar'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; // Financial proof uses real persisted rows/coverage and the configured HTTP boundary. @@ -59,6 +60,8 @@ describe('local income completeness and exact money (SQLite)', () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 58725dc..04813bd 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -15,6 +15,7 @@ import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; const uid = '11111111-1111-4111-8111-111111111111'; @@ -45,6 +46,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index bdea5a7..61e35a7 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -18,6 +18,7 @@ import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokera import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { @@ -102,6 +103,8 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); configureApp(app); diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 24f76ed..66df523 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -21,6 +21,7 @@ import { TbankOperationsByCursorRequest, } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-')); @@ -215,6 +216,8 @@ describe('T-Bank persisted read budget', () => { }) .overrideProvider(NcrRatingsClient) .useValue({ rating: () => counted('ncrRating', null) }) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) .compile(); app = module.createNestApplication(); app.use((_req: Request, res: Response, next: NextFunction) => { -- 2.54.0 From 7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 12:23:14 +0300 Subject: [PATCH 17/22] test: replace late portfolio sleep with started and done barriers (WealthLedger/backend#63) (#101) --- test/tbank-local-reads.e2e-spec.ts | 23 ++++++-- test/tbank-portfolio-publish.e2e-spec.ts | 73 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 test/tbank-portfolio-publish.e2e-spec.ts diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 61e35a7..bb18ca4 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -3,6 +3,7 @@ import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; @@ -573,16 +574,30 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await put().expect(200); await seedMembership(); const blocked = deferred(); - upstream.getPortfolio.mockReturnValueOnce(blocked.promise); - await get('brokerage-accounts/account/portfolio').expect(200); + const started = deferred(); + upstream.getPortfolio.mockImplementationOnce(() => { + started.resolve(); + return blocked.promise; + }); + // Starts the real portfolio producer, the same path the hourly walk uses. + const late = await portfolioService.request( + 'owner', + (await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } })).revision, + 'account', + true, + ); + assert(late); + // The late upstream call must have begun before the connection is replaced. + await started.promise; await put().expect(200); await seedMembership(); blocked.resolve({ positions: [] }); - await delay(20); + // Barrier: the fenced producer's completion, not a timer. + await late.done; + expect(await prisma.tbankPortfolio.count()).toBe(0); const replaced = await get( 'brokerage-accounts/account/portfolio?sync=observe', ).expect(200); expect((replaced.body as PortfolioResourceDto).data).toBeNull(); - expect(await prisma.tbankPortfolio.count()).toBe(0); }); }); diff --git a/test/tbank-portfolio-publish.e2e-spec.ts b/test/tbank-portfolio-publish.e2e-spec.ts new file mode 100644 index 0000000..4c877e7 --- /dev/null +++ b/test/tbank-portfolio-publish.e2e-spec.ts @@ -0,0 +1,73 @@ +import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; + +import { PortfolioRepository } from '../src/modules/investments/portfolio/infrastructure/portfolio.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; + +// Real SQLite transaction: a running attempt of the current revision publishes its snapshot. +describe('portfolio publication (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const repository = new PortfolioRepository(prisma); + const snapshot = { + lastSuccessfulAt: '2026-01-01T00:00:00.000Z', + isStale: false, + totalsByCurrency: [], + positions: [], + cashBalances: [], + structure: {}, + } as never; + const accountId = 'account'; + + beforeAll(async () => { + await prisma.$connect(); + await prisma.user.create({ + data: { id: 'owner', email: 'owner@example.invalid', passwordHash: 'synthetic' }, + }); + }); + afterAll(async () => prisma.$disconnect()); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'current', + encryptedToken: 'synthetic', + connectedAt: new Date(), + accounts: { + create: { + accountId, + type: 'brokerage', + name: 'Synthetic', + upstreamOrder: 0, + }, + }, + }, + }); + }); + + it('publishes the same snapshot for the running attempt of the current revision', async () => { + await prisma.tbankSyncWork.create({ + data: { + id: 'live', + userId: 'owner', + revision: 'current', + scopeKey: `portfolio:${accountId}`, + intent: {}, + state: 'running', + attemptId: 'live-attempt', + processRunId: 'run', + priority: 1, + progress: {}, + }, + }); + const published = await repository.publish( + { id: 'live', attemptId: 'live-attempt', userId: 'owner', revision: 'current' }, + accountId, + snapshot, + ); + assert.equal(published, true); + expect(await prisma.tbankPortfolio.count()).toBe(1); + }); +}); -- 2.54.0 From ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 14:46:09 +0300 Subject: [PATCH 18/22] test: replace metadata sleeps with upstream start barriers (WealthLedger/backend#63) (#102) --- test/tbank-metadata-persistence.e2e-spec.ts | 27 +++++++++++++++------ test/tbank-operations-sync.e2e-spec.ts | 8 ++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index d30fe86..8c179c8 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -129,13 +129,17 @@ describe('durable shared instrument metadata (SQLite)', () => { }); it('coalesces twenty requests across users while giving each user a private handle', async () => { const blocked = deferred(); - upstream.getInstrumentByUid.mockReturnValue(blocked.promise); + const started = deferred(); + upstream.getInstrumentByUid.mockImplementation(() => { + started.resolve(); + return blocked.promise; + }); const responses = await Promise.all( Array.from({ length: 20 }, (_, i) => service.read(i % 2 ? 'owner' : 'other', identity), ), ); - await delay(10); + await started.promise; expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); const ids = new Set(responses.map((response) => response?.work?.id)); expect(ids.size).toBe(2); @@ -151,9 +155,13 @@ describe('durable shared instrument metadata (SQLite)', () => { 'discards a producer after %s, including shared freshness, without borrowing another user token', async (action) => { const blocked = deferred(); - upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise); + const started = deferred(); + upstream.getInstrumentByUid.mockImplementationOnce(() => { + started.resolve(); + return blocked.promise; + }); await service.read('owner', identity); - await delay(10); + await started.promise; await service.read('other', identity); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); if (action === 'replace') { @@ -187,9 +195,13 @@ describe('durable shared instrument metadata (SQLite)', () => { ); it('does not turn initiating credentials failure into a shared negative lookup or a foreign credentials error', async () => { const blocked = deferred(); - upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise); + const started = deferred(); + upstream.getInstrumentByUid.mockImplementationOnce(() => { + started.resolve(); + return blocked.promise; + }); await service.read('owner', identity); - await delay(10); + await started.promise; await service.read('other', identity); blocked.reject(new Error('INVALID_TBANK_TOKEN')); await settled(); @@ -226,7 +238,6 @@ describe('durable shared instrument metadata (SQLite)', () => { expect(failed?.fetchedAt).toEqual(old); if (!failedHandle) throw new Error('Expected forced refresh work'); expect((await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: failedHandle } })).state).toBe('failed'); - await delay(20); expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1); }); it('coalesces forced refresh and retains freshness/timestamp when it fails before the TTL', async () => { @@ -285,7 +296,7 @@ describe('durable shared instrument metadata (SQLite)', () => { await prisma.$disconnect(); service = createService(); await service.read('owner', identity, true); - await delay(10); + // Observation starts no work, so a no-call assertion holds without waiting. expect(upstream.getInstrumentByUid).not.toHaveBeenCalled(); await service.read('owner', identity); await settled(); diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index 0fd66e7..aad2971 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -151,8 +151,12 @@ describe('Request-driven operations synchronization (SQLite)', () => { it('joins overlapping account and instrument refresh into one atomic publication group', async () => { const gate = deferred(); + const started = deferred(); upstream.getOperationsByCursor - .mockReturnValueOnce(gate.promise) + .mockImplementationOnce(() => { + started.resolve(); + return gate.promise; + }) .mockResolvedValue({ hasNext: false, items: [{ ...operation(), instrumentUid: 'uid' }], @@ -164,7 +168,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { true, ), ); - await delay(5); + await started.promise; const whole = requiredHandle( await service.request('owner', [january], true), ); -- 2.54.0 From 702302126b81335959be2e66b4b5ad38e5806b70 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 15:40:03 +0300 Subject: [PATCH 19/22] test: await portfolio publication done instead of polling ticks (WealthLedger/backend#63) (#103) --- .../application/portfolio.service.spec.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 2b12f43..008c160 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,4 +1,4 @@ -import { setImmediate as immediate } from 'node:timers/promises'; +import assert from 'node:assert/strict'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); @@ -97,14 +97,9 @@ describe('persistent portfolio projection', () => { ], work: [], }); - await service.request('user', 'private', 'a'); - for ( - let index = 0; - index < 10 && !repository.publish.mock.calls.length; - index += 1 - ) { - await immediate(); - } + const handle = await service.request('user', 'private', 'a'); + assert(handle); + await handle.done; const snapshot = ( repository.publish.mock.calls as [unknown, string, PortfolioDto][] )[0][2]; @@ -147,10 +142,9 @@ describe('persistent portfolio projection', () => { { units: '99' }, { currency: 'EUR', units: '1' }, ] }); - await service.request('user', 'private', 'a'); - for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) { - await immediate(); - } + const handle = await service.request('user', 'private', 'a'); + assert(handle); + await handle.done; const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; expect(snapshot.cashBalances).toEqual([ { currency: 'RUB', value: '0' }, -- 2.54.0 From e03fad622bfec0fa7e42d536f65e361d319a1ac9 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 16 Oct 2026 10:00:47 +0300 Subject: [PATCH 20/22] fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109) --- src/common/dto-validation.spec.ts | 57 ++++++++++++++++++- .../auth/presentation/auth.controller.ts | 6 +- src/modules/auth/presentation/auth.dto.ts | 38 +++++++++---- test/app.e2e-spec.ts | 41 +++++++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 942fd3d..a15f985 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; -import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; +import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, UpdateBankCardDto, @@ -11,7 +11,7 @@ describe('DTO validation', () => { forbidUnknownValues: true, transform: true, whitelist: true }); it('requires credentials and normalizes a valid email', async () => { - const metadata = { type: 'body' as const, metatype: CredentialsDto }; + const metadata = { type: 'body' as const, metatype: RegisterDto }; await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( BadRequestException, ); @@ -29,6 +29,59 @@ describe('DTO validation', () => { }); }); + // Test intent: register accepts 8..72 printable ASCII characters only (bcrypt + // reads 72 bytes, so non-ASCII could be silently truncated); login only caps + // bytes so accounts created under the older policy can still sign in. + describe('password policy', () => { + const register = { type: 'body' as const, metatype: RegisterDto }; + const login = { type: 'body' as const, metatype: LoginDto }; + const body = (password: string) => ({ email: 'user@example.com', password }); + const accepts = (value: unknown, metadata: typeof login | typeof register) => + pipe.transform(value, metadata).then( + () => true, + (error: unknown) => { + if (!(error instanceof BadRequestException)) throw error; + return false; + }, + ); + + it.each([ + ['7 chars', 'a'.repeat(7), false], + ['8 chars', 'a'.repeat(8), true], + ['72 chars', 'a'.repeat(72), true], + ['73 chars', 'a'.repeat(73), false], + ['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true], + ['Cyrillic, 8+ chars', 'пароль-пароль', false], + ['emoji', 'passw😀rd', false], + ['tab', 'pass\tword1', false], + ['newline', 'password1\n', false], + ['DEL', 'password\x7f1', false], + ['non-string', 12345678, false], + ])('register: %s', async (_, password, accepted) => { + await expect(accepts(body(password as string), register)).resolves.toBe(accepted); + }); + + it('register keeps raw whitespace untouched', async () => { + const password = ' spaced pass '; + await expect(pipe.transform(body(password), register)).resolves.toEqual( + body(password), + ); + }); + + it.each([ + ['legacy Cyrillic password', 'пароль-пароль', true], + ['short legacy password', 'short', true], + ['72 bytes', 'a'.repeat(72), true], + ['73 bytes', 'a'.repeat(73), false], + ['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true], + ['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false], + ['empty', '', false], + ['non-string', 12345678, false], + ])('login: %s', async (_, password, accepted) => { + await expect(accepts(body(password as string), login)).resolves.toBe(accepted); + }); + }); + it('requires and validates nested money in a new card', async () => { const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const card = { diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index cf50136..1fdbed1 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -24,7 +24,7 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; import { AccessPayload, AuthenticationResult } from '../application/auth.types'; -import { CredentialsDto } from './auth.dto'; +import { LoginDto, RegisterDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; @@ -56,7 +56,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async register( - @Body() credentials: CredentialsDto, + @Body() credentials: RegisterDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( @@ -69,7 +69,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async login( - @Body() credentials: CredentialsDto, + @Body() credentials: LoginDto, @Res({ passthrough: true }) response: Response, ) { return this.respond( diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index c04bee2..f42aa15 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { + IsNotEmpty, IsString, - MinLength, + Matches, Validate, ValidatorConstraint, } from 'class-validator'; @@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0; const MAX_EMAIL_LENGTH = 254; const ASCII_SPACE = 32; const ASCII_DELETE = 127; -const MIN_PASSWORD_LENGTH = 12; +// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes. +const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_BYTES = 72; +const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`; @ValidatorConstraint({ name: 'email' }) class EmailConstraint { @@ -40,8 +43,9 @@ class EmailConstraint { } } -@ValidatorConstraint({ name: 'passwordBytes' }) -class PasswordBytesConstraint { +// Login stays lenient: accounts created under an older policy must still sign in. +@ValidatorConstraint({ name: 'passwordMaxBytes' }) +class PasswordMaxBytesConstraint { validate(value: unknown): boolean { return ( typeof value === 'string' && @@ -50,11 +54,11 @@ class PasswordBytesConstraint { } defaultMessage(): string { - return 'must not exceed 72 UTF-8 bytes'; + return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`; } } -export class CredentialsDto { +class EmailDto { @ApiProperty({ example: 'user@example.com' }) @IsString() @Transform(({ value }: { value: unknown }) => @@ -62,14 +66,28 @@ export class CredentialsDto { ) @Validate(EmailConstraint) declare email: string; +} +export class RegisterDto extends EmailDto { @ApiProperty({ - minLength: MIN_PASSWORD_LENGTH, - maxLength: MAX_PASSWORD_BYTES, + description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`, + pattern: PASSWORD_PATTERN, example: 'a secure password', }) @IsString() - @MinLength(MIN_PASSWORD_LENGTH) - @Validate(PasswordBytesConstraint) + @Matches(new RegExp(PASSWORD_PATTERN), { + message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`, + }) + declare password: string; +} + +export class LoginDto extends EmailDto { + @ApiProperty({ + description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`, + example: 'a secure password', + }) + @IsString() + @IsNotEmpty() + @Validate(PasswordMaxBytesConstraint) declare password: string; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 9083f05..8eb0745 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; +import { randomUUID } from 'node:crypto'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -265,6 +267,45 @@ describe('Authentication (e2e)', () => { expect(await app.get(PrismaService).user.count()).toBe(0); }); + it('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => { + const validation = (detail: string) => ({ + status: 400, + code: 'VALIDATION_ERROR', + errors: [{ pointer: '#/password', detail }], + }); + for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) { + const credentials = { email: `user${index}@example.com`, password }; + await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200); + await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200); + } + for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) { + await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'rejected@example.com', password }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual(validation('must be 8-72 printable ASCII characters')), + ); + } + expect(await app.get(PrismaService).user.count()).toBe(2); + + // An account created under the previous policy (Cyrillic, 12+ chars) must still log in. + const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' }; + const prisma = app.get(PrismaService); + await prisma.user.create({ + data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) }, + }); + await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200); + await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email: legacy.email, password: 'a'.repeat(73) }) + .expect(400) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')), + ); + }); + it('guards T-Bank connection and never returns a token', async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') -- 2.54.0 From 8a6f60d9651fc7c474dd9b3690314505e18c4fa0 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 16 Oct 2026 23:23:12 +0300 Subject: [PATCH 21/22] fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) (#110) --- .../tbank/tbank-access.service.ts | 21 ++--- .../application/catalog-discovery.service.ts | 64 +++++++------- .../application/catalog-enrichment.service.ts | 27 +++--- .../stock-screener.service.spec.ts | 6 +- .../application/stock-screener.service.ts | 10 ++- test/bonds.e2e-spec.ts | 44 ++++++++++ test/online-upstream.ts | 87 +++++++++++++++++++ test/stocks.e2e-spec.ts | 45 ++++++++++ 8 files changed, 240 insertions(+), 64 deletions(-) create mode 100644 test/online-upstream.ts diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 7d77079..06e22d2 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -121,19 +121,11 @@ export class TbankAccessService { return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; } - async onlineToken(userId: string) { - const connection = await this.prisma.tbankConnection.findUnique({ - where: { userId }, - select: { encryptedToken: true, invalidToken: true }, - }); - if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected }); - if (connection.invalidToken) { - throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); - } - return this.tokens.decrypt(connection.encryptedToken); - } - - async current(userId: string): Promise<{ revision: string; token: string }> { + // Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller. + async online( + userId: string, + run: (connection: { revision: string; token: string }) => Promise, + ): Promise { const connection = await this.prisma.tbankConnection.findUnique({ where: { userId }, select: { encryptedToken: true, invalidToken: true, revision: true }, @@ -142,7 +134,8 @@ export class TbankAccessService { if (connection.invalidToken) { throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } - return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) }; + const token = this.tokens.decrypt(connection.encryptedToken); + return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token })); } async publishMembership( diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 8202b42..bce90ce 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -122,11 +122,11 @@ export class CatalogDiscoveryService { observe = false, ) { if (!observe) { - const token = await this.access.onlineToken(userId); - const instrument = + const instrument = await this.access.online(userId, ({ token }) => instrumentType === 'share' - ? await this.tbank.getShare(token, uid) - : await this.tbank.getBond(token, uid); + ? this.tbank.getShare(token, uid) + : this.tbank.getBond(token, uid), + ); if (!instrument) throw new NotFoundException(); return { data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, @@ -201,11 +201,11 @@ export class CatalogDiscoveryService { ) { if (!observe) { const query = ticker.trim().toUpperCase(); - const token = await this.access.onlineToken(userId); - const found = + const found = await this.access.online(userId, ({ token }) => instrumentType === 'share' - ? await this.tbank.findShares(token, query) - : await this.tbank.findBonds(token, query); + ? this.tbank.findShares(token, query) + : this.tbank.findBonds(token, query), + ); const matches = selectCatalogInstruments( found.filter((item) => item.ticker?.trim().toUpperCase() === query), query, @@ -268,30 +268,32 @@ export class CatalogDiscoveryService { } private async onlineSearch(userId: string, input: CatalogQuery) { - const token = await this.access.onlineToken(userId); - const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); - const instruments = selectCatalogInstruments( - input.exactTicker - ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) - : found, - input.query, - input.instrumentType, - ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); - const [details, prices] = await Promise.all([ - Promise.all( - instruments.map((item) => - input.instrumentType === 'share' - ? this.tbank.getShare(token, item.uid) - : this.tbank.getBond(token, item.uid), + const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => { + const found = + input.instrumentType === 'share' + ? await this.tbank.findShares(token, input.query) + : await this.tbank.findBonds(token, input.query); + const instruments = selectCatalogInstruments( + input.exactTicker + ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) + : found, + input.query, + input.instrumentType, + ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); + const [details, prices] = await Promise.all([ + Promise.all( + instruments.map((item) => + input.instrumentType === 'share' + ? this.tbank.getShare(token, item.uid) + : this.tbank.getBond(token, item.uid), + ), ), - ), - instruments.length - ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) - : [], - ]); + instruments.length + ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) + : [], + ]); + return { details, instruments, prices }; + }); const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 3acb074..1bfa0ae 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -231,7 +231,6 @@ export class CatalogEnrichmentService { return reservation; } - // eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy. async section( userId: string, instrumentType: 'bond' | 'share', @@ -240,18 +239,20 @@ export class CatalogEnrichmentService { observe = false, ) { if (section === 'company' && !observe) { - const token = await this.access.onlineToken(userId); - const instrument = - instrumentType === 'share' - ? await this.tbank.getShare(token, uid) - : await this.tbank.getBond(token, uid); - if (!instrument) throw new NotFoundException(); - const [description, fundamentals] = instrument.assetUid - ? await Promise.all([ - this.tbank.getAssetDescription(token, instrument.assetUid), - this.tbank.getFundamentals(token, instrument.assetUid), - ]) - : [null, null]; + const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => { + const instrument = + instrumentType === 'share' + ? await this.tbank.getShare(token, uid) + : await this.tbank.getBond(token, uid); + if (!instrument) throw new NotFoundException(); + const [description, fundamentals] = instrument.assetUid + ? await Promise.all([ + this.tbank.getAssetDescription(token, instrument.assetUid), + this.tbank.getFundamentals(token, instrument.assetUid), + ]) + : [null, null]; + return { description, fundamentals, instrument }; + }); const meta = investmentResourceMeta({ resourceKey: `${instrumentType}/company/${uid}`, dataRevision: `online:${Date.now()}`, diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts index f933873..34b3323 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts @@ -22,14 +22,16 @@ function share(index: number, overrides: Partial = {}): TbankShare { } describe('StockScreenerService', () => { - const access = { current: jest.fn(), local: jest.fn() }; + const access = { online: jest.fn(), local: jest.fn() }; const client = { getAllShares: jest.fn() }; const logger = { event: jest.fn() }; let service: StockScreenerService; beforeEach(() => { jest.clearAllMocks(); - access.current.mockResolvedValue({ revision, token }); + access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise) => + run({ revision, token }), + ); access.local.mockResolvedValue({ revision }); client.getAllShares.mockResolvedValue( Array.from({ length: pageSize + 1 }, (_, index) => share(index)), diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts index a566bb6..e069ff2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts @@ -48,8 +48,8 @@ type StockScreenerPage = { }; type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string }; type TbankAccess = { - current(userId: string): Promise<{ revision: string; token: string }>; local(userId: string): Promise<{ revision: string }>; + online(userId: string, run: (connection: { revision: string; token: string }) => Promise): Promise; }; @Injectable() @@ -63,8 +63,10 @@ export class StockScreenerService { ) {} async search(userId: string, conditions: StockScreenerConditions): Promise { - const connection = await this.access.current(userId); - const instruments = await this.client.getAllShares(connection.token); + const { instruments, revision } = await this.access.online(userId, async (connection) => ({ + instruments: await this.client.getAllShares(connection.token), + revision: connection.revision, + })); const asOf = new Date().toISOString(); const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions) .instruments.map((instrument) => this.item(instrument)); @@ -74,7 +76,7 @@ export class StockScreenerService { asOf, expiresAt: Date.now() + SNAPSHOT_TTL_MS, items: selected, - revision: connection.revision, + revision, userId, }); this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' }); diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 0b464d8..246a97c 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,5 +1,6 @@ import type { Server } from 'node:http'; +import { status } from '@grpc/grpc-js'; import { ConflictException, INestApplication, @@ -16,6 +17,13 @@ import { BondCatalogService } from '../src/modules/investments/instruments/bonds import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { + bootOnlineApp, + expectSafeBody, + failUpstream, + onlineUserId, + refusals, +} from './online-upstream'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { @@ -201,3 +209,39 @@ describe('Bond HTTP contract', () => { expect(response.body).not.toHaveProperty('stack'); }); }); + +// Real services and TbankClient error mapping; only the gRPC transport is stubbed. +// Bonds expose no company or screener route; their online entrypoints are search, detail and ticker. +describe('Bond online reads: upstream failure translation', () => { + const rpc = jest.fn(); + let ctx: Awaited>; + const get = (path: string) => request(ctx.app.getHttpServer()).get(path); + // [entrypoint, request, RPC that fails second]: a failure after the first call must translate too. + const entrypoints: [string, () => request.Test, string][] = [ + ['search', () => get('/api/v1/bonds?query=SBER'), 'getLastPrices'], + ['detail', () => get(`/api/v1/bonds/${uid}`), 'bondBy'], + ['ticker', () => get('/api/v1/bonds/by-ticker/SBER'), 'bondBy'], + ]; + const cases = entrypoints.flatMap(([name, send, later]) => + refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)), + ); + + beforeAll(async () => { + ctx = await bootOnlineApp(rpc); + }); + afterAll(() => ctx.app.close()); + + it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => { + failUpstream(rpc, 'bond', code, at); + const response = await send().set('Authorization', ctx.bearer).expect(400); + expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN'); + const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } }); + expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision }); + }); + + it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => { + failUpstream(rpc, 'bond', status.UNAVAILABLE); + const response = await send().set('Authorization', ctx.bearer).expect(503); + expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE'); + }); +}); diff --git a/test/online-upstream.ts b/test/online-upstream.ts new file mode 100644 index 0000000..293d3fe --- /dev/null +++ b/test/online-upstream.ts @@ -0,0 +1,87 @@ +import type { Server } from 'node:http'; + +import { status } from '@grpc/grpc-js'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; + +import { AppModule } from '../src/app.module'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; +import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { PrismaService } from '../src/prisma/prisma.service'; + +export const onlineUserId = 'online-reader'; +export const onlineToken = 'synthetic-online-token'; +export const rawUpstreamText = 'raw upstream refusal text'; +export const refusals = [status.UNAUTHENTICATED, status.PERMISSION_DENIED]; + +// Real TbankClient with only the transport stubbed: the gRPC status goes through its real error mapping. +export async function bootOnlineApp(rpc: jest.Mock) { + const upstream = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { + logger: { event: jest.fn() }, + queue: { run: (operation: () => Promise) => operation(), size: 0 }, + request: rpc, + }); + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .overrideProvider(TbankHourlyScheduler) + .useValue({ tick: jest.fn() }) + .compile(); + const app: INestApplication = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + const prisma = app.get(PrismaService); + await prisma.user.create({ + data: { id: onlineUserId, email: 'online@example.invalid', passwordHash: 'synthetic' }, + }); + const connection = await prisma.tbankConnection.create({ + data: { + userId: onlineUserId, + encryptedToken: app.get(TbankTokenService).encrypt(onlineToken), + connectedAt: new Date(), + }, + }); + const bearer = `Bearer ${new JwtService().sign( + { sub: onlineUserId, type: 'access' }, + { secret: process.env.JWT_SECRET }, + )}`; + return { app, bearer, connection, prisma }; +} + +// Healthy answers for every RPC an online entrypoint uses; `at` picks the single RPC that fails. +export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: number, at?: string) { + const instrument = { + apiTradeAvailableFlag: true, + assetUid: 'asset-uid', + instrumentKind: kind === 'bond' ? 'INSTRUMENT_TYPE_BOND' : 'INSTRUMENT_TYPE_SHARE', + ticker: 'SBER', + uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13', + }; + const healthy = new Map([ + ['bondBy', { instrument }], + ['findInstrument', { instruments: [instrument] }], + ['getAssetBy', { asset: { description: 'description' } }], + ['getAssetFundamentals', { fundamentals: [] }], + ['getLastPrices', { lastPrices: [] }], + ['shareBy', { instrument }], + ]); + const failure = Object.assign(new Error('grpc failure'), { + code, + details: `${rawUpstreamText} Bearer ${onlineToken}`, + }); + rpc.mockReset(); + rpc.mockImplementation((_token: string, _client: unknown, method: string) => + !at || method === at ? Promise.reject(failure) : Promise.resolve(healthy.get(method) ?? {}), + ); +} + +// Safe public error: stable code only, no upstream text, no credentials. +export function expectSafeBody(body: unknown, status: number, code: string) { + expect(body).toEqual({ code, status }); + expect(JSON.stringify(body)).not.toContain(rawUpstreamText); + expect(JSON.stringify(body)).not.toContain(onlineToken); +} diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index abf3fe7..ca1b8c0 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,5 +1,6 @@ import type { Server } from 'node:http'; +import { status } from '@grpc/grpc-js'; import { ConflictException, INestApplication, @@ -17,6 +18,13 @@ import { StockIncomeService } from '../src/modules/investments/instruments/stock import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { + bootOnlineApp, + expectSafeBody, + failUpstream, + onlineUserId, + refusals, +} from './online-upstream'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { @@ -201,3 +209,40 @@ describe('Stock HTTP contract', () => { expect(response.body).not.toHaveProperty('stack'); }); }); + +// Real services and TbankClient error mapping; only the gRPC transport is stubbed. +describe('Stock online reads: upstream failure translation', () => { + const rpc = jest.fn(); + let ctx: Awaited>; + const get = (path: string) => request(ctx.app.getHttpServer()).get(path); + // [entrypoint, request, RPC that fails second]: a failure after the first call must translate too. + const entrypoints: [string, () => request.Test, string][] = [ + ['search', () => get('/api/v1/stocks?query=SBER'), 'getLastPrices'], + ['detail', () => get(`/api/v1/stocks/${uid}`), 'shareBy'], + ['ticker', () => get('/api/v1/stocks/by-ticker/SBER'), 'shareBy'], + ['company', () => get(`/api/v1/stocks/${uid}/company`), 'getAssetBy'], + ['screener', () => request(ctx.app.getHttpServer()).post('/api/v1/stocks/screener/search').send({}), 'shares'], + ]; + const cases = entrypoints.flatMap(([name, send, later]) => + refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)), + ); + + beforeAll(async () => { + ctx = await bootOnlineApp(rpc); + }); + afterAll(() => ctx.app.close()); + + it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => { + failUpstream(rpc, 'share', code, at); + const response = await send().set('Authorization', ctx.bearer).expect(400); + expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN'); + const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } }); + expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision }); + }); + + it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => { + failUpstream(rpc, 'share', status.UNAVAILABLE); + const response = await send().set('Authorization', ctx.bearer).expect(503); + expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE'); + }); +}); -- 2.54.0 From 4f485ab0e9f0f9d52ae446e51966525473948b0e Mon Sep 17 00:00:00 2001 From: codex-bot Date: Sat, 17 Oct 2026 14:19:19 +0300 Subject: [PATCH 22/22] chore: adopt linear git model in backend docs (#111) --- .ai/conventions/engineering.md | 7 ++++- README.md | 50 ++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index 4c641a7..69ecdb4 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -89,4 +89,9 @@ Commits и PR titles — Conventional Commits с parser-recognized Issue referen `spec:`. Каждый commit содержит одну задачу. Исключения release и правила ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. Прямой push в `main`, force-push и перенос опубликованных тегов не входят в -обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». +обычную работу. PR сливается squash или rebase-merge; merge-коммит не +используется. `main` обновляется только fast-forward из `develop`, поэтому +`origin/main` всегда предок `origin/develop`: +`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix — +обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет. +Перед релизом прочитай местный README, раздел «Релизы». diff --git a/README.md b/README.md index 622b183..73dd653 100644 --- a/README.md +++ b/README.md @@ -307,35 +307,57 @@ Durability оставляет временные DB, логи и `result.json` Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; общие правила — в Engineering Workflow по ссылке из AGENTS. -Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. +PR сливается squash или rebase-merge; merge-коммит не используется. `main` +обновляется только fast-forward из `develop`, поэтому история линейна: + +```text +/- --squash--> develop --ff-only--> main --> tag vX.Y.Z +``` + +Инвариант: `origin/main` всегда предок `origin/develop`. Проверка: + +```bash +git fetch -q origin +git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN +``` + Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release), включая `spec:`. Версии вручную не повышай. -1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что - `develop` содержит последний релиз. Создай `release/` от проверенного - `develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в - `release/hotfix-` и проходит тот же процесс. +1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь + инвариант. Создай `release/X.Y.Z` от проверенного `develop`; + `nvm use --silent`, `npm ci`. 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, - открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных - `verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` - изменился, согласуй историю и повтори проверки кандидата. -4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией + открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных + `verify`, `security`, `commits` выполни fast-forward-only merge. Если + `develop` изменился, обнови release-ветку и повтори проверки кандидата. +4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only. +5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: `git tag -a vX.Y.Z -m "Release X.Y.Z"`, затем отправь только `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. -5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь, - что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после - merge создай отсутствующий тег без нового bump; до merge исправь и проверь - существующий кандидат без повторного повышения версии. +Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3. +Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег +без нового bump; до merge исправь и проверь существующий кандидат без +повторного повышения версии. + +Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают. +Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом; +пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение +требует явного решения: `release/hotfix-` от `main`, PR в `main`, тег, +затем merge `main → develop` для восстановления инварианта. `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход -администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. +администратором, включена блокировка устаревшей ветки, обязательны `verify`, +`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles +репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash, +rebase-merge и fast-forward-only; стиль по умолчанию — squash. Workflow сам не включает эти настройки. Отдельное review approval для одного владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. -- 2.54.0