From d1c5f575cfb4fae01032b2d8bab53f71c31db144 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 5 Nov 2026 22:22:48 +0300 Subject: [PATCH] refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196) --- eslint-suppressions.json | 13 - .../income/income-analytics.controller.ts | 7 - .../income/income-analytics.dto.ts | 3 +- .../income/income-analytics.service.ts | 4 - .../instruments/application/candle.service.ts | 4 +- .../application/catalog-discovery.service.ts | 228 ++---------------- .../application/catalog-enrichment.service.ts | 32 ++- .../bonds/application/bond-catalog.service.ts | 7 +- .../bonds/application/bond-income.service.ts | 5 +- .../bonds/application/bond-market.service.ts | 4 - .../bonds/presentation/bond.controller.ts | 17 +- .../application/get-stock-company.use-case.ts | 2 - .../application/stock-catalog.service.ts | 7 +- .../application/stock-income.service.ts | 3 +- .../application/stock-market.service.ts | 4 - .../stocks/presentation/stock.controller.ts | 15 +- .../stocks/presentation/stock.dto.ts | 5 +- .../investments/investment-resource.dto.ts | 8 - .../brokerage-operations.service.ts | 27 +-- .../tbank-sync/tbank-sync.controller.ts | 43 ---- .../investments/tbank-sync/tbank-sync.dto.ts | 13 - .../tbank-sync/tbank-sync.module.ts | 5 +- .../tbank-sync/tbank-sync.repository.spec.ts | 10 - .../tbank-sync/tbank-sync.repository.ts | 34 --- .../tbank-sync/tbank-sync.service.ts | 103 -------- .../upcoming-events/upcoming-events.dto.ts | 3 +- test/all-accounts.e2e-spec.ts | 6 +- test/bonds.e2e-spec.ts | 6 +- test/openapi-contract.e2e-spec.ts | 39 ++- test/stocks.e2e-spec.ts | 5 +- test/tbank-candles-persistence.e2e-spec.ts | 17 +- test/tbank-catalog-discovery.e2e-spec.ts | 138 ----------- test/tbank-income-local.e2e-spec.ts | 67 ++--- test/tbank-ledger-local.e2e-spec.ts | 45 ++-- test/tbank-local-reads.e2e-spec.ts | 46 ++-- test/upcoming-events.e2e-spec.ts | 39 +-- 36 files changed, 182 insertions(+), 832 deletions(-) delete mode 100644 src/modules/investments/tbank-sync/tbank-sync.controller.ts delete mode 100644 src/modules/investments/tbank-sync/tbank-sync.service.ts delete mode 100644 test/tbank-catalog-discovery.e2e-spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 80fdc16..51f2439 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -53,11 +53,6 @@ "count": 1 } }, - "src/modules/investments/instruments/application/catalog-discovery.service.ts": { - "sonarjs/no-nested-conditional": { - "count": 1 - } - }, "src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "sonarjs/no-nested-conditional": { "count": 1 @@ -135,11 +130,6 @@ "count": 1 } }, - "src/modules/investments/tbank-sync/tbank-sync.service.ts": { - "sonarjs/no-nested-conditional": { - "count": 6 - } - }, "test/bank-cards-durability.cjs": { "sonarjs/no-nested-template-literals": { "count": 1 @@ -158,9 +148,6 @@ "test/tbank-ledger-local.e2e-spec.ts": { "sonarjs/no-nested-conditional": { "count": 1 - }, - "sonarjs/no-nested-template-literals": { - "count": 3 } } } \ No newline at end of file diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index b07edf5..973b593 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -6,7 +6,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; -import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { IncomeAnalyticsQueryDto, IncomeAnalyticsResourceDto, @@ -26,12 +25,10 @@ export class IncomeAnalyticsController { @ApiOkResponse({ type: IncomeSummaryResourceDto }) allSummary( @Req() request: Request & { user: AccessPayload }, - @Query() query: InvestmentReadQueryDto, ) { return this.income.summary( request.user.sub, 'all', - query.sync === 'observe', ); } @@ -46,7 +43,6 @@ export class IncomeAnalyticsController { 'all', query.from, query.to, - query.sync === 'observe', ); } @@ -55,12 +51,10 @@ export class IncomeAnalyticsController { summary( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, - @Query() query: InvestmentReadQueryDto, ) { return this.income.summary( request.user.sub, accountId, - query.sync === 'observe', ); } @@ -76,7 +70,6 @@ export class IncomeAnalyticsController { accountId, query.from, query.to, - query.sync === 'observe', ); } } diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index f503f07..9ab09c5 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -5,14 +5,13 @@ import { MoneyDto } from '#src/common/money.dto'; import { InvestmentDependencyDto, - InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; import { BrokerageOperationDto } from '../operations/operations.api'; const dateOnly = /^\d{4}-\d{2}-\d{2}$/; -export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto { +export class IncomeAnalyticsQueryDto { @ApiPropertyOptional({ format: 'date', description: diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 940d39d..5eb9734 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -44,14 +44,12 @@ export class IncomeAnalyticsService { async summary( userId: string, accountId: string, - observe = false, ): Promise { const plan = planIncomePeriod(undefined, undefined); const [selected] = await this.operations.collectRanges( userId, accountId, () => [plan.selected], - observe, ); if (selected.items === null) return { data: null, meta: selected.meta }; assert(selected.meta.lastSuccessfulAt); @@ -71,14 +69,12 @@ export class IncomeAnalyticsService { accountId: string, from: string | undefined, to: string | undefined, - observe = false, ): Promise { const plan = planIncomePeriod(from, to); const [current] = await this.operations.collectRanges( userId, accountId, () => [plan.selected], - observe, ); const dependencies: IncomeDependencyDto[] = [ { ...current.meta, role: 'selected' }, diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index df9e3bb..bbe5275 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -99,7 +99,7 @@ export class CandleService { private readonly logger: StructuredLogger, ) {} - async read(userId: string, input: CandleInput, observe = false) { + async read(userId: string, input: CandleInput) { const { revision } = await this.access.local(userId); const [detail] = await this.sources.readMany( userId, @@ -119,7 +119,7 @@ export class CandleService { const hasCompleteRange = Boolean( range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS, ); - if (!observe && (!saved || !hasCompleteRange)) { + if (!saved || !hasCompleteRange) { await this.request(userId, revision, input, now); } const work = await this.repository.work(userId, revision, scope(input)); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index ba3b5c2..4ea5e5e 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -13,12 +13,10 @@ import { import { investmentResourceMeta } from '../../investment-resource.mapper'; import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { - CatalogDiscoveryRepository, catalogQuery, type CatalogQuery, catalogScope, } from '../infrastructure/catalog-discovery.repository'; -import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { catalogSearchLimit, selectCatalogInstruments, @@ -26,7 +24,6 @@ import { import { publicInstrument } from './public-instrument'; const UNAMBIGUOUS_MATCH_COUNT = 1; -const NO_MATCHES = 0; const FIRST_RESULT_OFFSET = 0; const tickerAmbiguityLimit = 2; @@ -34,203 +31,56 @@ const tickerAmbiguityLimit = 2; @Injectable() export class CatalogDiscoveryService { constructor( - private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, private readonly tbank: TbankClient, - private readonly enrichment: CatalogEnrichmentService, ) {} - async read(userId: string, input: CatalogQuery) { - input = catalogQuery(input); - const connection = await this.access.local(userId); - const saved = await this.repository.read(input); - return { - data: saved.data, - meta: investmentResourceMeta({ - resourceKey: catalogScope(input), - dataRevision: saved.dataRevision, - availability: saved.data === null ? 'missing' : 'complete', - fetchedAt: saved.fetchedAt, - freshForMs: Number.MAX_SAFE_INTEGER, - work: await this.repository.work(userId, connection.revision, input), - }), - }; - } - - async search(userId: string, input: CatalogQuery, observe = false) { - if (!observe) return this.onlineSearch(userId, catalogQuery(input)); - const discovery = await this.read(userId, input); - const sources = discovery.data - ? await this.enrichment.readMany( - userId, - input.instrumentType, - discovery.data.map((item) => item.uid), - observe, - ) - : []; - const dependencies = [ - discovery.meta, - ...sources.map((source) => source.meta), - ]; - return { - data: - discovery.data?.map((short) => ({ - ...short, - detail: - sources.find( - (source) => - source.input.uid === short.uid && - source.input.kind === 'detail', - )?.instrument ?? null, - price: - sources.find( - (source) => - source.input.uid === short.uid && - source.input.kind === 'last-price', - )?.price ?? null, - })) ?? null, - meta: investmentResourceMeta({ - resourceKey: `search:${catalogScope(catalogQuery(input))}`, - dataRevision: dependencies - .map((dependency) => dependency.dataRevision) - .join('/'), - availability: - discovery.data === null - ? 'missing' - : sources.every((source) => source.meta.completeness === 'complete') - ? 'complete' - : 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt - ? new Date(discovery.meta.lastSuccessfulAt) - : null, - freshForMs: Number.MAX_SAFE_INTEGER, - work: discovery.meta.sync.id - ? { - id: discovery.meta.sync.id, - state: discovery.meta.sync.state, - errorCode: discovery.meta.sync.errorCode, - } - : null, - dependencies, - }), - }; + async search(userId: string, input: CatalogQuery) { + return this.onlineSearch(userId, catalogQuery(input)); } async detail( userId: string, instrumentType: 'bond' | 'share', uid: string, - observe = false, ) { - if (!observe) { - const instrument = await this.access.online(userId, ({ token }) => - instrumentType === 'share' - ? 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 }, - meta: investmentResourceMeta({ - resourceKey: `${instrumentType}/detail/${uid}`, - dataRevision: `online:${Date.now()}`, - availability: 'complete', - fetchedAt: new Date(), - freshForMs: tbankSourceTtlMs.portfolio, - work: null, - }), - }; - } - const sources = await this.enrichment.readMany( - userId, - instrumentType, - [uid], - observe, - ['detail'], + const instrument = await this.access.online(userId, ({ token }) => + instrumentType === 'share' + ? this.tbank.getShare(token, uid) + : this.tbank.getBond(token, uid), ); - const source = sources[0]; - if (source.meta.completeness === 'complete' && !source.instrument) { - throw new NotFoundException(); - } - const instrument = source.instrument; - let slug = uid; - const dependencies = [source.meta]; - if ( - instrument?.ticker && - /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker) - ) { - 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(); - } - dependencies.push(alias.meta); - } + if (!instrument) throw new NotFoundException(); return { - data: instrument ? { ...instrument, uid, slug } : null, + data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/detail/${uid}`, - dataRevision: dependencies - .map((dependency) => dependency.dataRevision) - .join('/'), - availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt - ? new Date(source.meta.lastSuccessfulAt) - : null, + dataRevision: `online:${Date.now()}`, + availability: 'complete', + fetchedAt: new Date(), freshForMs: tbankSourceTtlMs.portfolio, - work: source.meta.sync.id - ? { - id: source.meta.sync.id, - state: source.meta.sync.state, - errorCode: source.meta.sync.errorCode, - } - : null, - dependencies, + work: null, }), }; } - // eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics. async byTicker( userId: string, instrumentType: 'bond' | 'share', ticker: string, - observe = false, ) { - if (!observe) { - const query = ticker.trim().toUpperCase(); - const found = await this.access.online(userId, ({ token }) => - instrumentType === 'share' - ? this.tbank.findShares(token, query) - : this.tbank.findBonds(token, query), - ); - const matches = selectCatalogInstruments( - found.filter((item) => item.ticker?.trim().toUpperCase() === query), - query, - instrumentType, - ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit); - if (!matches.length) throw new NotFoundException(); - if (matches.length > UNAMBIGUOUS_MATCH_COUNT) { - throw new ConflictException({ - code: - instrumentType === 'share' - ? InstrumentErrorCode.StockTickerAmbiguous - : InstrumentErrorCode.BondTickerAmbiguous, - }); - } - 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, { + const query = ticker.trim().toUpperCase(); + const found = await this.access.online(userId, ({ token }) => + instrumentType === 'share' + ? this.tbank.findShares(token, query) + : this.tbank.findBonds(token, query), + ); + const matches = selectCatalogInstruments( + found.filter((item) => item.ticker?.trim().toUpperCase() === query), + query, instrumentType, - query: ticker, - exactTicker: true, - }); - if (lookup.data?.length === NO_MATCHES) throw new NotFoundException(); - if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { + ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit); + if (!matches.length) throw new NotFoundException(); + if (matches.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: instrumentType === 'share' @@ -238,34 +88,8 @@ export class CatalogDiscoveryService { : InstrumentErrorCode.BondTickerAmbiguous, }); } - const result = lookup.data - ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) - : null; - const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; - return { - data: result?.data - ? { ...result.data, slug: ticker.trim().toUpperCase() } - : null, - meta: investmentResourceMeta({ - resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, - dataRevision: dependencies - .map((dependency) => dependency.dataRevision) - .join('/'), - availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt - ? new Date(lookup.meta.lastSuccessfulAt) - : null, - freshForMs: tbankSourceTtlMs.portfolio, - work: lookup.meta.sync.id - ? { - id: lookup.meta.sync.id, - state: lookup.meta.sync.state, - errorCode: lookup.meta.sync.errorCode, - } - : null, - dependencies, - }), - }; + const result = await this.detail(userId, instrumentType, matches[0].uid); + return { ...result, data: result.data ? { ...result.data, slug: query } : null }; } private async onlineSearch(userId: string, input: CatalogQuery) { diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 457528d..e8f6f95 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -222,9 +222,8 @@ export class CatalogEnrichmentService { instrumentType: 'bond' | 'share', uid: string, section: 'company' | 'metrics' | 'quote', - observe = false, ) { - if (section === 'company' && !observe) { + if (section === 'company') { const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => { const instrument = instrumentType === 'share' @@ -254,25 +253,22 @@ export class CatalogEnrichmentService { observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(), }; } - const kinds: CatalogSource['kind'][] = - section === 'company' - ? ['detail', 'description', 'fundamentals'] - : [ - ...(instrumentType === 'bond' - ? ['bond-current' as const] - : ['detail' as const]), - 'last-price', - 'trading-status', - 'comparison', - ...(section === 'metrics' - ? ['coupons' as const, 'yield' as const, 'rating' as const] - : []), - ]; + const kinds: CatalogSource['kind'][] = [ + ...(instrumentType === 'bond' + ? ['bond-current' as const] + : ['detail' as const]), + 'last-price', + 'trading-status', + 'comparison', + ...(section === 'metrics' + ? ['coupons' as const, 'yield' as const, 'rating' as const] + : []), + ]; const sources = await this.readMany( userId, instrumentType, [uid], - observe, + false, kinds, ); const first = sources[0]; @@ -300,7 +296,7 @@ export class CatalogEnrichmentService { fetchedAt: first.meta.lastSuccessfulAt ? new Date(first.meta.lastSuccessfulAt) : null, - freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote, + freshForMs: tbankSourceTtlMs.quote, work: null, dependencies, }); diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index d5c0fe6..6c4cc15 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -27,12 +27,10 @@ export class BondCatalogService { async search( userId: string, query: string, - observe = false, ): Promise { const result = await this.discovery.search( userId, { instrumentType: 'bond', query }, - observe, ); return { ...result, @@ -60,9 +58,8 @@ export class BondCatalogService { async get( userId: string, uid: string, - observe = false, ): Promise { - const result = await this.discovery.detail(userId, 'bond', uid, observe); + const result = await this.discovery.detail(userId, 'bond', uid); return { ...result, data: result.data @@ -74,13 +71,11 @@ export class BondCatalogService { async getByTicker( userId: string, ticker: string, - observe = false, ): Promise { const result = await this.discovery.byTicker( userId, 'bond', ticker, - observe, ); return { ...result, diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index f4a59b8..5e71d90 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -55,14 +55,12 @@ export class BondIncomeService { async metrics( userId: string, uid: string, - observe = false, ): Promise { const result = await this.sources.section( userId, 'bond', uid, 'metrics', - observe, ); return { data: result.values.instrument @@ -83,7 +81,6 @@ export class BondIncomeService { async coupons( userId: string, uid: string, - observe = false, ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); @@ -94,7 +91,7 @@ export class BondIncomeService { userId, 'bond', [uid], - observe, + false, ['detail', 'coupons'], { from, to }, ); diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index 824248b..e7919d6 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -39,14 +39,12 @@ export class BondMarketService { async quote( userId: string, uid: string, - observe = false, ): Promise { const result = await this.sources.section( userId, 'bond', uid, 'quote', - observe, ); return { data: result.values.instrument @@ -60,12 +58,10 @@ export class BondMarketService { userId: string, uid: string, period: BondCandlesQueryDto['period'] = 'month', - observe = false, ) { return this.history.read( userId, { uid, instrumentType: 'bond', period }, - observe, ); } } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index 8750921..f5f9a6b 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -13,7 +13,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api'; -import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto'; import { BondCatalogService } from '../application/bond-catalog.service'; import { BondIncomeService } from '../application/bond-income.service'; @@ -48,9 +47,8 @@ export class BondController { metrics( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); + return this.income.metrics(request.user.sub, uid); } @Get() @@ -62,7 +60,6 @@ export class BondController { return this.bonds.search( request.user.sub, query.query, - query.sync === 'observe', ); } @@ -71,12 +68,10 @@ export class BondController { getByTicker( @Req() request: Request & { user: AccessPayload }, @Param() params: BondTickerParamsDto, - @Query() query: InvestmentReadQueryDto, ) { return this.bonds.getByTicker( request.user.sub, params.ticker, - query.sync === 'observe', ); } @@ -85,9 +80,8 @@ export class BondController { get( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); + return this.bonds.get(request.user.sub, uid); } @Get(':uid/quote') @@ -95,9 +89,8 @@ export class BondController { quote( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.market.quote(request.user.sub, uid, query.sync === 'observe'); + return this.market.quote(request.user.sub, uid); } @Get(':uid/candles') @@ -111,7 +104,6 @@ export class BondController { request.user.sub, uid, query.period, - query.sync === 'observe', ); } @@ -129,8 +121,7 @@ export class BondController { coupons( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.income.coupons(request.user.sub, uid, query.sync === 'observe'); + return this.income.coupons(request.user.sub, uid); } } diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index 858e1cc..f5e5e74 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -13,14 +13,12 @@ export class GetStockCompanyUseCase { async execute( userId: string, uid: string, - observe = false, ): Promise { const result = await this.sources.section( userId, 'share', uid, 'company', - observe, ); return { data: result.values.instrument diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index cb343f4..e5d65e2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -23,12 +23,10 @@ export class StockCatalogService { async search( userId: string, query: string, - observe = false, ): Promise { const result = await this.discovery.search( userId, { instrumentType: 'share', query }, - observe, ); return { ...result, @@ -60,9 +58,8 @@ export class StockCatalogService { async get( userId: string, uid: string, - observe = false, ): Promise { - const result = await this.discovery.detail(userId, 'share', uid, observe); + const result = await this.discovery.detail(userId, 'share', uid); return { ...result, data: result.data @@ -74,13 +71,11 @@ export class StockCatalogService { async getByTicker( userId: string, ticker: string, - observe = false, ): Promise { const result = await this.discovery.byTicker( userId, 'share', ticker, - observe, ); return { ...result, diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index 4d9b735..96986bd 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -52,7 +52,6 @@ export class StockIncomeService { async dividends( userId: string, uid: string, - observe = false, ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); @@ -63,7 +62,7 @@ export class StockIncomeService { userId, 'share', [uid], - observe, + false, ['detail', 'dividends'], { from, to }, ); diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index 65bb25d..468904d 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -35,14 +35,12 @@ export class StockMarketService { async quote( userId: string, uid: string, - observe = false, ): Promise { const result = await this.sources.section( userId, 'share', uid, 'quote', - observe, ); return { data: result.values.instrument @@ -56,12 +54,10 @@ export class StockMarketService { userId: string, uid: string, period: StockCandlesQueryDto['period'] = 'month', - observe = false, ) { return this.history.read( userId, { uid, instrumentType: 'share', period }, - observe, ); } } diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index 4a1fa4a..f5286d5 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -19,7 +19,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swa import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api'; -import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto'; import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; import { StockCatalogService } from '../application/stock-catalog.service'; @@ -65,7 +64,6 @@ export class StockController { return this.stocks.search( request.user.sub, query.query, - query.sync === 'observe', ); } @@ -74,12 +72,10 @@ export class StockController { getByTicker( @Req() request: Request & { user: AccessPayload }, @Param() params: StockTickerParamsDto, - @Query() query: InvestmentReadQueryDto, ) { return this.stocks.getByTicker( request.user.sub, params.ticker, - query.sync === 'observe', ); } @@ -116,9 +112,8 @@ export class StockController { get( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); + return this.stocks.get(request.user.sub, uid); } @Get(':uid/quote') @@ -126,9 +121,8 @@ export class StockController { quote( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { - return this.market.quote(request.user.sub, uid, query.sync === 'observe'); + return this.market.quote(request.user.sub, uid); } @Get(':uid/candles') @@ -142,7 +136,6 @@ export class StockController { request.user.sub, uid, query.period, - query.sync === 'observe', ); } @@ -160,12 +153,10 @@ export class StockController { company( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { return this.companyInfo.execute( request.user.sub, uid, - query.sync === 'observe', ); } @@ -174,12 +165,10 @@ export class StockController { dividends( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, - @Query() query: InvestmentReadQueryDto, ) { return this.income.dividends( request.user.sub, uid, - query.sync === 'observe', ); } } diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 29ad8ff..10b2eef 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -17,7 +17,6 @@ import { MIN_INSTRUMENT_QUERY_LENGTH, } from '#src/modules/investments/instrument-query'; import { - InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '#src/modules/investments/investment-resource.dto'; import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api'; @@ -104,7 +103,7 @@ export class StockScreenerPageDto { @ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[]; } -export class StockSearchQueryDto extends InvestmentReadQueryDto { +export class StockSearchQueryDto { @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, @@ -126,7 +125,7 @@ export class StockTickerParamsDto { @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; } -export class StockCandlesQueryDto extends InvestmentReadQueryDto { +export class StockCandlesQueryDto { @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'], default: 'month', diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index ec666f6..4031d23 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional } from 'class-validator'; export const availabilityValues = [ 'available', @@ -17,13 +16,6 @@ export const attemptStateValues = [ 'failed', ] as const; -export class InvestmentReadQueryDto { - @ApiPropertyOptional({ enum: ['observe'] }) - @IsOptional() - @IsIn(['observe']) - declare sync?: 'observe'; -} - export class InvestmentAttemptProblemDto { @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] }) declare section: 'events' | 'membership' | 'operations' | 'portfolio'; diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 4c6dd50..c83fbc9 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -238,7 +238,6 @@ export class BrokerageOperationsService { userId: string, accountId: string, ranges: (openedAt: Date | undefined) => DateOnlyRange[], - observe = false, ) { const connection = await this.access.local(userId, accountId); const input = { @@ -252,21 +251,19 @@ export class BrokerageOperationsService { periods: ranges, }; let saved = await this.repository.readPage(input); - if (!observe) { - const targets = (saved?.connection.accounts ?? []).flatMap((account) => - (saved?.periods ?? []).map((period) => ({ - accountId: account.accountId, - ...period, - })), - ); - if (targets.length) { - await this.sync.readPlan(userId, targets, { - movingEnd: saved?.periods?.some((period) => period.movingEnd), - revision: connection.revision, - }); - } - saved = await this.repository.readPage(input); + const targets = (saved?.connection.accounts ?? []).flatMap((account) => + (saved?.periods ?? []).map((period) => ({ + accountId: account.accountId, + ...period, + })), + ); + if (targets.length) { + await this.sync.readPlan(userId, targets, { + movingEnd: saved?.periods?.some((period) => period.movingEnd), + revision: connection.revision, + }); } + saved = await this.repository.readPage(input); const now = new Date(); const hourly = await this.hourlySync.hourly(userId); const membership = investmentResourceMeta({ diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts deleted file mode 100644 index 26fc867..0000000 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Request } from 'express'; - -import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiExtraModels, - ApiOkResponse, - ApiTags, -} from '@nestjs/swagger'; - -import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; - -import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; -import { - EventsInvestmentScreenDto, - IncomeInvestmentScreenDto, - TbankSyncStatusDto, - TimestampInvestmentScreenDto, -} from './tbank-sync.dto'; -import { TbankSyncService } from './tbank-sync.service'; - -@ApiTags('tbank-sync') -@ApiExtraModels( - EventsInvestmentScreenDto, - IncomeInvestmentScreenDto, - TimestampInvestmentScreenDto, - TbankSyncStatusDto, -) -@ApiProblemResponses() -@ApiBearerAuth() -@UseGuards(JwtAuthGuard) -@Controller('tbank-sync') -export class TbankSyncController { - constructor(private readonly sync: TbankSyncService) {} - @Get(':id') - @ApiOkResponse({ type: TbankSyncStatusDto }) - status( - @Req() request: Request & { user: AccessPayload }, - @Param('id') id: string, - ) { - return this.sync.status(request.user.sub, id); - } -} diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index d3a28f7..0d76ed7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -209,19 +209,6 @@ export const investmentScreenSchema = { }, }; -export class TbankSyncStatusDto { - @ApiProperty({ format: 'uuid' }) declare id: string; - @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) - declare scope: InvestmentScreenDto | null; - - @ApiProperty({ format: 'date-time' }) declare now: string; - @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) - declare state: 'cancelled' | 'failed' | 'running' | 'succeeded'; - - @ApiProperty({ type: String, nullable: true }) declare errorCode: - null | string; -} - export class HourlySyncStatusDto { @ApiProperty({ enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'], diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index 5662f35..4e4cd40 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -9,8 +9,6 @@ import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankHourlyScheduler } from './tbank-hourly.scheduler'; import { TbankSyncRepositoryModule } from './tbank-sync-repository.module'; -import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ AuthModule, @@ -21,8 +19,7 @@ import { TbankSyncService } from './tbank-sync.service'; UpcomingEventsModule, TbankSyncRepositoryModule, ], - controllers: [TbankSyncController], - providers: [TbankSyncService, TbankHourlyScheduler], + providers: [TbankHourlyScheduler], exports: [TbankHourlyScheduler], }) export class TbankSyncModule {} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts index 75a38ef..d3938d7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts @@ -4,19 +4,9 @@ jest.mock('#src/prisma/prisma.service', () => ({ import { hourlyProgress, - screenIntent, screenProgress, } from './tbank-sync.repository'; -describe('screen sync intent', () => { - it('restores a persisted intent and rejects a corrupted one', () => { - const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } }; - expect(screenIntent(intent)).toEqual(intent); - expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent'); - expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent'); - }); -}); - describe('screen sync progress', () => { it('restores a persisted progress unchanged', () => { const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true }; diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 5ea9ca7..23a305e 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -10,16 +10,6 @@ import { toJson } from '#src/prisma/to-json'; import type { InvestmentAttemptDto } from '../investment-resource.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto'; -export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; - -// Json columns are written only by this repository; a shape check turns corruption into an explicit 500. -export function screenIntent(value: Prisma.JsonValue): ScreenIntent { - const intent = value as null | Partial; - if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') { - throw new Error('Corrupted screen sync intent'); - } - return intent as ScreenIntent; -} export type ScreenProgress = { attempts?: Record; children: string[]; @@ -189,30 +179,6 @@ export class TbankSyncRepository { }); } - read(userId: string, revision: string, id: string) { - return this.prisma.$transaction(async (tx) => { - const work = await tx.tbankSyncWork.findFirst({ - where: { userId, revision, id }, - }); - if (!work) return null; - const progress = screenProgress(work.progress); - const children = await tx.tbankSyncWork.findMany({ - where: { userId, revision, id: { in: progress.children ?? [] } }, - select: { id: true, attemptId: true, state: true, errorCode: true }, - }); - return { - work, - children: children.map((child) => - progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId - ? { ...child, state: 'cancelled', errorCode: null } - : child, - ), - progress, - }; - }); - } - async progress( attempt: TbankSyncAttempt, children: string[], diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts deleted file mode 100644 index 0746095..0000000 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; - -import { TbankAccessService } from '#src/integrations/tbank/tbank.api'; - -import { BrokerageOperationsSyncService } from '../operations/operations.api'; -import { TbankSyncStatusDto } from './tbank-sync.dto'; -import { screenIntent, TbankSyncRepository } from './tbank-sync.repository'; - -@Injectable() -export class TbankSyncService { - private readonly runId = randomUUID(); - private readonly startedAt = new Date(); - constructor( - private readonly repository: TbankSyncRepository, - private readonly access: TbankAccessService, - private readonly operations: BrokerageOperationsSyncService, - ) {} - - async status(userId: string, id: string): Promise { - const connection = await this.access - .local(userId) - .catch((error: unknown) => { - if (error instanceof ConflictException) throw new NotFoundException(); - throw error; - }); - const saved = await this.repository.read(userId, connection.revision, id); - if (!saved) throw new NotFoundException(); - if (!saved.work.scopeKey.startsWith('screen:')) { - return this.sourceStatus(userId, saved.work); - } - const intent = screenIntent(saved.work.intent); - await this.access.local(userId, intent.scope.accountId); - const children = await Promise.all( - saved.children.map((child) => - child.state === 'superseded' - ? this.operations.observe(userId, child.id) - : Promise.resolve(child), - ), - ); - const running = children.some((child) => child.state === 'running'); - const failed = - saved.work.errorCode ?? - children.find((child) => child.errorCode)?.errorCode ?? - null; - const state = - saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) - ? 'cancelled' - : !saved.progress.dispatched || running - ? 'running' - : failed - ? 'failed' - : children.some((child) => child.state === 'cancelled') - ? 'cancelled' - : 'succeeded'; - if ( - (await this.access.local(userId, intent.scope.accountId)).revision !== - connection.revision - ) { - throw new NotFoundException(); - } - return { - id, - ...intent, - state, - errorCode: failed, - }; - } - - private async sourceStatus( - userId: string, - work: NonNullable>>['work'], - ): Promise { - const observed = work.scopeKey.startsWith('operations:') - ? await this.operations.observe(userId, work.id) - : work; - const state = - observed.state === 'running' - ? observed.updatedAt < this.startedAt - ? 'cancelled' - : 'running' - : observed.state === 'failed' - ? 'failed' - : observed.state === 'cancelled' - ? 'cancelled' - : 'succeeded'; - if ((await this.access.local(userId)).revision !== work.revision) { - throw new NotFoundException(); - } - return { - id: work.id, - scope: null, - now: work.createdAt.toISOString(), - state, - errorCode: observed.errorCode, - }; - } -} diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 809d7f1..fa2656b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -5,11 +5,10 @@ import { MoneyDto } from '#src/common/money.dto'; import { AccountReferenceDto } from '../account-reference.dto'; import { - InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; -export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { +export class UpcomingEventsQueryDto { @ApiPropertyOptional({ format: 'date', description: diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index c94be41..34c3a95 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -66,7 +66,7 @@ describe('All accounts HTTP boundary', () => { it.each(paths)('requires authentication for /all/%s', async (path) => { await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .expect(401); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); @@ -77,7 +77,7 @@ describe('All accounts HTTP boundary', () => { async (path) => { const response = await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .set('Authorization', `Bearer ${token}`) .expect(200); @@ -100,7 +100,7 @@ describe('All accounts HTTP boundary', () => { ])('validates /all/%s in the configured pipeline', async (path) => { await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .set('Authorization', `Bearer ${token}`) .expect(400); diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 246a97c..ff02c05 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -125,14 +125,14 @@ describe('Bond HTTP contract', () => { .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toEqual(metrics); - expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false); + expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid); }); it('uses verified identity and supports a direct instrument URL', async () => { await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false); + expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid); }); it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -150,7 +150,6 @@ describe('Bond HTTP contract', () => { expect(bonds.getByTicker).toHaveBeenCalledWith( 'bond-reader', ticker, - false, ); }, ); @@ -185,7 +184,6 @@ describe('Bond HTTP contract', () => { expect(bonds.search).toHaveBeenCalledWith( 'bond-reader', 'SU26238RMFS4', - false, ); expect(response.body).toEqual({ data: [item], diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 816eb0f..32f91693 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -28,31 +28,11 @@ describe('OpenAPI contract (integration)', () => { const document = createOpenApiDocument(app); const schemas = document.components?.schemas; expect(document.paths['/api/v1/tbank-sync']).toBeUndefined(); - expect(schemas?.TbankSyncStatusDto).toBeDefined(); - for (const [model, screen] of [ - ['IncomeInvestmentScreenDto', 'income'], - ['EventsInvestmentScreenDto', 'events'], - ]) { - // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. - const schema = schemas?.[model]; - assert(schema && !('$ref' in schema)); - expect(schema.required).toContain('screen'); - expect(schema.required).toContain('accountId'); - expect(schema).toMatchObject({ - properties: { - screen: { enum: [screen] }, - from: { type: 'string', format: 'date' }, - to: { type: 'string', format: 'date' }, - }, - }); - } - expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({ - properties: { - from: { type: 'string', format: 'date-time' }, - to: { type: 'string', format: 'date-time' }, - }, - }); - expect(schemas).not.toHaveProperty('InvestmentScreenDto'); + expect(document.paths['/api/v1/tbank-sync/{id}']).toBeUndefined(); + expect(schemas?.TbankSyncStatusDto).toBeUndefined(); + expect(schemas).not.toHaveProperty('IncomeInvestmentScreenDto'); + expect(schemas).not.toHaveProperty('EventsInvestmentScreenDto'); + expect(schemas).not.toHaveProperty('TimestampInvestmentScreenDto'); for (const scope of ['all', '{accountId}']) { for (const [resource, format] of [ ['income-analytics', 'date'], @@ -77,6 +57,15 @@ describe('OpenAPI contract (integration)', () => { } }); + it('does not advertise the retired observe query on investment reads', () => { + const document = createOpenApiDocument(app); + for (const [path, resource] of Object.entries(document.paths)) { + if (!path.startsWith('/api/v1/')) continue; + const parameters = resource.get?.parameters ?? []; + expect(parameters.some((parameter) => 'name' in parameter && parameter.name === 'sync')).toBe(false); + } + }); + it('exposes only the selected income dependency role and no comparison data', () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({ diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index ca1b8c0..5220ff5 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -136,7 +136,7 @@ describe('Stock HTTP contract', () => { .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false); + expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid); }); it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -154,7 +154,6 @@ describe('Stock HTTP contract', () => { expect(stocks.getByTicker).toHaveBeenCalledWith( 'stock-reader', ticker, - false, ); }, ); @@ -186,7 +185,7 @@ describe('Stock HTTP contract', () => { .get('/api/v1/stocks?query=SBER') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false); + expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER'); expect(response.body).toEqual({ data: [item], meta: { availability: 'complete' }, diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index eb041e8..a03c661 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -199,7 +199,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ).toBe(false); expect((await repository.read('uid', range)).coverage).toEqual([]); }); - it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => { + it('coalesces twenty reads and reuses closed windows after reopen', async () => { const service = createService(); const input = { uid: 'uid', @@ -216,7 +216,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); expect(cold.every((row) => row.data === null)).toBe(true); expect(await prisma.tbankSyncWork.count()).toBe(1); - await service.read('owner', input, true); release([]); await settled(); const count = upstream.getCandles.mock.calls.length; @@ -239,7 +238,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { where: { isComplete: false }, data: { fetchedAt: new Date(0) }, }); - const stale = await reopened.read('owner', input, true); + const stale = await reopened.read('owner', input); expect(stale.meta.availability).toBe('available'); expect(stale.meta.completeness).toBe('complete'); expect(stale.meta.freshness).toBe('stale'); @@ -251,7 +250,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1); }); - it('recovers abandoned work only on a new ordinary request', async () => { + it('recovers abandoned work on an ordinary request', async () => { const input = { uid: 'uid', instrumentType: 'share' as const, @@ -265,12 +264,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => { new Set(), ); const reopened = createService(); - await reopened.read('owner', input, true); - expect(upstream.getCandles).not.toHaveBeenCalled(); - expect( - (await repository.work('owner', 'revision', 'candles:uid:month')) - ?.attemptId, - ).toBe(abandoned?.work.attemptId); await reopened.read('owner', input); await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(1); @@ -302,7 +295,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before); expect(await prisma.tbankCandle.count()).toBe(0); - const read = await service.read('owner', input, true); + const read = await service.read('owner', input); expect(read.meta.lastAttempt.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); @@ -474,7 +467,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }; await service.read('owner', input); await settled(); - const result = await service.read('owner', input, true); + const result = await service.read('owner', input); expect(result.data?.currency).toBeNull(); expect(result.data?.candles).toEqual([ { time: now.toISOString(), close: '99.123456789', isComplete: false }, diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts deleted file mode 100644 index 3dc6f2b..0000000 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import assert from 'node:assert/strict'; - -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; -import { - CatalogDiscoveryRepository, - type CatalogInstrument, - type CatalogQuery, -} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; -import { PrismaService } from '../src/prisma/prisma.service'; - -// 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 }), - ); - const tokens = new TbankTokenService( - new ConfigService({ - TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, - }) as never, - ); - const upstream = { findShares: jest.fn(), findBonds: jest.fn() }; - const access = new TbankAccessService(prisma, tokens); - const repository = new CatalogDiscoveryRepository(prisma); - const createService = () => - new CatalogDiscoveryService( - repository, - access, - upstream as never, - { requestMany: jest.fn().mockResolvedValue([]) } as never, - ); - let service: CatalogDiscoveryService; - const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' }; - const instrument = { - uid: 'uid', - name: 'Synthetic share', - ticker: 'SBER', - isin: 'TEST', - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - }; - const seed = (userId: string) => - prisma.tbankConnection.create({ - data: { - userId, - revision: `${userId}-revision`, - encryptedToken: tokens.encrypt(`synthetic-${userId}`), - connectedAt: new Date(), - }, - }); - // 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(), - }, - }); - } - await prisma.tbankCatalogDiscovery.create({ - data: { - provider: 'tbank', - instrumentType: 'share', - query, - instrumentUids: instruments.map((item) => item.uid), - fetchedAt: new Date(), - }, - }); - } - beforeAll(async () => { - await prisma.$connect(); - for (const id of ['owner', 'other']) { - await prisma.user.create({ - data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, - }); - } - }); - afterAll(async () => prisma.$disconnect()); - 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(); - service = createService(); - }); - 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('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(upstream.findShares).not.toHaveBeenCalled(); - }); - 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'); - }); -}); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 6484605..1eddbb2 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -34,12 +34,9 @@ describe('local income completeness and exact money (SQLite)', () => { const get = ( account = 'all', period = 'from=2025-01-01&to=2025-01-31', - observe = true, ) => request(app.getHttpServer()) - .get( - `/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`, - ) + .get(`/api/v1/brokerage-accounts/${account}/income-analytics?${period}`) .set('Authorization', authorization) .timeout(2000); const read = async (account = 'all', period?: string) => @@ -127,7 +124,7 @@ describe('local income completeness and exact money (SQLite)', () => { } } }); - it('returns exact selected sums; reopen and observe need no RPC', async () => { + it('returns exact selected sums and reuses covered reads without RPC', async () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); @@ -210,7 +207,9 @@ describe('local income completeness and exact money (SQLite)', () => { expect( (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency, ).toEqual([]); - expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + expect(upstream.getOperationsByCursor).toHaveBeenCalledWith( + 'synthetic', expect.objectContaining({ accountId: 'b' }), + ); }); it('counts corrected identities once for the selected period', async () => { await prisma.tbankOperation.updateMany({ @@ -274,7 +273,7 @@ describe('local income completeness and exact money (SQLite)', () => { 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 () => { + it('retains current-month completeness and does not renew source timestamps', async () => { const now = new Date(); const today = moscowDate(now); const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`); @@ -283,7 +282,7 @@ describe('local income completeness and exact money (SQLite)', () => { await coverage(accountId, start, asOf); } const response = await request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/all/income-summary?sync=observe') + .get('/api/v1/brokerage-accounts/all/income-summary') .set('Authorization', authorization) .expect(200); const result = response.body as IncomeSummaryResourceDto; @@ -302,58 +301,32 @@ describe('local income completeness and exact money (SQLite)', () => { where: { userId: 'income-owner' }, data: { membershipAt: new Date('2020-01-01Z') }, }); - const response = await get('a', undefined, false).expect(200); + const response = await get('a').expect(200); const result = response.body as IncomeAnalyticsResourceDto; expect(result.data).not.toBeNull(); expect(result.meta.completeness).toBe('complete'); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); - it('cold income imports only the missing requested interval', async () => { + it('ignores retired sync=observe on income reads and keeps the cold response incomplete', async () => { await prisma.tbankConnection.update({ where: { userId: 'income-owner' }, data: { membershipAt: new Date('2020-01-01Z') }, }); - let release: () => void = () => { - throw new Error('Upstream not initialized'); - }; - const blocked = new Promise<{ hasNext: boolean; items: never[] }>((resolve) => { - release = () => resolve({ hasNext: false, items: [] }); - }); - upstream.getOperationsByCursor.mockReturnValue(blocked); - const first = await get('a', 'from=2025-02-01&to=2025-02-28', false).expect(200); - const cold = first.body as IncomeAnalyticsResourceDto; - release(); - expect(cold.data).toBeNull(); - expect(cold.meta.completeness).toBe('incomplete'); - let result: IncomeAnalyticsResourceDto = cold; - for (let attempt = 0; attempt < 100; attempt++) { - const response = await get('a', 'from=2025-02-01&to=2025-02-28').expect(200); - result = response.body as IncomeAnalyticsResourceDto; - if (result.meta.completeness === 'complete') break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(result.data).not.toBeNull(); - expect(result.meta.completeness).toBe('complete'); + const response = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/a/income-analytics?from=2025-02-01&to=2025-02-28&sync=observe') + .set('Authorization', authorization) + .expect(200); + const result = response.body as IncomeAnalyticsResourceDto; + expect(result.data).toBeNull(); + expect(result.meta.completeness).toBe('incomplete'); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); - expect(upstream.getOperationsByCursor.mock.calls).toEqual([ - ['synthetic', expect.objectContaining({ - accountId: 'a', - from: { seconds: '1738357199' }, - to: { seconds: '1738368001' }, - })], - ['synthetic', expect.objectContaining({ - accountId: 'a', - from: { seconds: '1738367999' }, - to: { seconds: '1740776401' }, - })], - ]); }); it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => { - await get('absent', undefined, false).expect(404); - await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400); + await get('absent').expect(404); + await get('a', 'from=2025-02-31&to=2025-03-01').expect(400); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); - const response = await get('all', undefined, false).expect(200); + const response = await get('all').expect(200); expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull(); }); it('plans only the selected period for a leap day without scheduling it', async () => { @@ -361,7 +334,7 @@ describe('local income completeness and exact money (SQLite)', () => { expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', ]); - expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable'); + expect(result.meta.dependencies[0].availability).toBe('initial-loading'); expect(result.data).toBeNull(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 9d35709..b3c2f6f 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -30,10 +30,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }; const auth = (owner = 'owner') => `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const cursorParam = (cursor?: null | string) => (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''); const get = (query = '', account = 'all', owner = 'owner') => request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`, + `/api/v1/brokerage-accounts/${account}/operations?${[period, query].filter(Boolean).join('&')}`, ) .set('Authorization', auth(owner)) .timeout(30_000); @@ -138,7 +139,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { let complete = false; for (let attempt = 0; attempt < 100; attempt++) { const response = await request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28&sync=observe') + .get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28') .set('Authorization', auth()) .expect(200); const result = response.body as BrokerageOperationsResourceDto; @@ -162,7 +163,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const ids: string[] = []; do { const response = await get( - `limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `limit=2${cursorParam(cursor)}`, ).expect(200); const result = response.body as BrokerageOperationsResourceDto; expect(result.meta.availability).toBe('available'); @@ -243,7 +244,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { do { const response = await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1${cursorParam(cursor)}`, ) .set('Authorization', auth()) .expect(200); @@ -258,7 +259,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const income = ( await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`, + `/api/v1/brokerage-accounts/a/income-analytics?${period}`, ) .set('Authorization', auth()) .expect(200) @@ -291,10 +292,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { periodKind === 'default' ? '' : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; - const page = (cursor?: string, observe = false) => + const page = (cursor?: string) => request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `/api/v1/brokerage-accounts/all/operations?limit=1${query}${cursorParam(cursor)}`, ) .set('Authorization', auth()); const first = (await page().expect(200)) @@ -307,7 +308,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(second.meta.availability).toBe('available'); expect(second.meta.completeness).toBe('complete'); expect(second.meta.coverage?.missingRanges).toEqual([]); - const observed = (await page(first.data.nextCursor, true).expect(200)) + const observed = (await page(first.data.nextCursor).expect(200)) .body as BrokerageOperationsResourceDto; expect(observed.meta.availability).toBe('available'); expect(observed.meta.completeness).toBe('complete'); @@ -317,7 +318,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { it('keeps data revisions stable while observational reads expose current coverage', async () => { const observe = () => request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100') + .get('/api/v1/brokerage-accounts/all/operations?limit=100') .set('Authorization', auth()); const first = (await observe().expect(200)) .body as BrokerageOperationsResourceDto; @@ -340,7 +341,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('reads shared metadata from SQLite without invalidating a row cursor', async () => { - const before = (await get('limit=1&sync=observe').expect(200)) + const before = (await get('limit=1').expect(200)) .body as BrokerageOperationsResourceDto; assert(before.data?.nextCursor); const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ @@ -371,22 +372,22 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { new Date(), ); await prisma.$disconnect(); - const after = (await get('limit=1&sync=observe').expect(200)) + const after = (await get('limit=1').expect(200)) .body as BrokerageOperationsResourceDto; expect(after.data?.items[0].logoName).toBe('saved.png'); expect(after.meta.dataRevision).toBe(before.meta.dataRevision); await get( - `limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`, + `limit=1&cursor=${encodeURIComponent(before.data.nextCursor)}`, ).expect(200); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => { const first = ( - await get('limit=1&categories=trade,income&sync=observe').expect(200) + await get('limit=1&categories=trade,income').expect(200) ).body as BrokerageOperationsResourceDto; assert(first.data?.nextCursor); const cursor = encodeURIComponent(first.data.nextCursor); - const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`; + const next = `limit=1&categories=income,trade,income&cursor=${cursor}`; await prisma.tbankOperationCoverage.updateMany({ data: { fetchedAt: new Date(0) }, }); @@ -400,10 +401,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { operationsRevision: { increment: 1 } }, }); await get(next).expect(400); - const single = (await get('limit=1&sync=observe', 'a').expect(200)) + const single = (await get('limit=1', 'a').expect(200)) .body as BrokerageOperationsResourceDto; assert(single.data?.nextCursor); - const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`; + const singleNext = `limit=1&cursor=${encodeURIComponent(single.data.nextCursor)}`; await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipRevision: { increment: 1 } }, @@ -431,7 +432,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }); const partial = ( await get( - `instrumentUid=${uid}&categories=income&limit=100&sync=observe`, + `instrumentUid=${uid}&categories=income&limit=100`, 'a', ).expect(200) ).body as BrokerageOperationsResourceDto; @@ -439,7 +440,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(partial.meta.completeness).toBe('incomplete'); expect(partial.data).toBeNull(); expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0); - const whole = (await get('sync=observe', 'a').expect(200)) + const whole = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(whole.meta.availability).toBe('available'); expect(whole.meta.completeness).toBe('complete'); @@ -450,7 +451,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await prisma.tbankAccount.updateMany({ data: { openedOn: null } }); await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } }); await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } }); - const result = (await get('sync=observe', 'a').expect(200)) + const result = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.meta.coverage?.historyBoundary).toBe('unknown'); expect(result.meta.coverage?.historyState).toBe('missing'); @@ -474,7 +475,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('initial-loading'); expect(cold.meta.completeness).toBe('incomplete'); - await get('sync=observe', 'a').expect(200); + await get('', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. unblock({ hasNext: false, items: [] }); @@ -485,12 +486,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await delay(5); } const count = upstream.getOperationsByCursor.mock.calls.length; - const result = (await get('sync=observe', 'a').expect(200)) + const result = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.data?.items).toEqual([]); expect(result.meta.availability).toBe('available'); expect(result.meta.completeness).toBe('complete'); - await get('sync=observe', 'a').expect(200); + await get('', 'a').expect(200); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count); }); }); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 58aa495..be0c72d 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -153,19 +153,19 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), }, }); - const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200); + const cold = await get('brokerage-accounts/unknown/portfolio').expect(200); expect((cold.body as PortfolioResourceDto).data).toBeNull(); expect((cold.body as PortfolioResourceDto).meta.availability).toBe( 'temporarily-unavailable', ); - await get('brokerage-accounts?sync=observe').expect(200); + await get('brokerage-accounts').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).not.toHaveBeenCalled(); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } }); await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } }); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } }); - await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404); - await get('brokerage-accounts?sync=observe', 'other').expect(409); + await get('brokerage-accounts/unknown/portfolio').expect(404); + await get('brokerage-accounts', 'other').expect(409); await get('brokerage-accounts?sync=unexpected').expect(200); }); @@ -192,7 +192,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await seedMembership(); const blocked = deferred(); upstream.getPortfolio.mockReturnValueOnce(blocked.promise); - const cold = await get('brokerage-accounts?sync=observe').expect(200); + const cold = await get('brokerage-accounts').expect(200); expect( (cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, ).toBeNull(); @@ -270,7 +270,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { if (work) await work.done; await settled(); const before = ( - await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + await get('brokerage-accounts/account/portfolio').expect(200) ).body as PortfolioResourceDto; const fetchedAt = new Date(); await prisma.tbankInstrument.create({ @@ -307,7 +307,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { update: { matchedUids: ['enriched-stock'], fetchedAt }, }); const after = ( - await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + await get('brokerage-accounts/account/portfolio').expect(200) ).body as PortfolioResourceDto; expect(after.data?.positions[0]).toMatchObject({ displayName: 'Enriched name', @@ -333,7 +333,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { where: { userId: 'owner', scopeKey: 'portfolio:account' }, data: { state: 'complete' }, }); - const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200); + const stale = await get('brokerage-accounts/account/portfolio').expect(200); const previous = (stale.body as PortfolioResourceDto).data; expect(previous?.isStale).toBe(true); expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString()); @@ -343,7 +343,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); const failed = await get( - 'brokerage-accounts/account/portfolio?sync=observe', + 'brokerage-accounts/account/portfolio', ).expect(200); expect((failed.body as PortfolioResourceDto).data).toEqual(previous); expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none'); @@ -403,7 +403,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }, ]); const income = await get( - 'brokerage-accounts/account/income-analytics?sync=observe', + 'brokerage-accounts/account/income-analytics', ).expect(200); const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; expect(incomeMeta.lastAttempt.state).toBe('partial'); @@ -443,7 +443,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }); const blocked = deferred(); upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise); - await get('brokerage-accounts?sync=observe').expect(200); + await get('brokerage-accounts').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect( ( @@ -468,9 +468,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { where: { id: 'abandoned-membership' }, data: { state: 'complete' }, }); - expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); + expect((await get('brokerage-accounts/account/portfolio').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(await prisma.tbankPortfolio.count()).toBe(0); - expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ + expect((await get('brokerage-accounts')).body).toMatchObject({ data: [{ id: 'account', portfolioSummary: null }], }); }); @@ -510,7 +510,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { } await delay(5); } - const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) + const summaries = (await get('brokerage-accounts').expect(200)) .body as BrokerageAccountsResourceDto; expect(summaries.data?.[0].portfolioSummary).toMatchObject({ totalsByCurrency: [{ currency: 'RUB', value: '21.1' }], @@ -528,10 +528,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { expect(summaries.meta.availability).toBe('available'); expect(summaries.meta.completeness).toBe('complete'); expect( - (await get('brokerage-accounts/all/portfolio?sync=observe')).body, + (await get('brokerage-accounts/all/portfolio')).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect( - (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + (await get(`stocks/${uid}/position`).expect(200)).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); @@ -542,7 +542,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await prisma.tbankConnection.updateMany({ data: { membershipRevision: { increment: 1 } }, }); - const position = await get(`stocks/${uid}/position?sync=observe`).expect( + const position = await get(`stocks/${uid}/position`).expect( 200, ); expect(position.body).toMatchObject({ @@ -551,12 +551,12 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }); await prisma.$disconnect(); expect( - (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + (await get(`stocks/${uid}/position`).expect(200)).body, ).toEqual(position.body); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankConnection.updateMany({ data: { invalidToken: true } }); - await get(`stocks/${uid}/position?sync=observe`).expect(400); + await get(`stocks/${uid}/position`).expect(400); }); it('uses membership TTL for an empty aggregate without starting work through observation', async () => { @@ -570,7 +570,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { membershipAt: new Date(Date.now() - 120_000) }, }); const fresh = await get( - 'brokerage-accounts/all/portfolio?sync=observe', + 'brokerage-accounts/all/portfolio', ).expect(200); expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((fresh.body as PortfolioResourceDto).data).toMatchObject({ @@ -580,7 +580,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { membershipAt: new Date(Date.now() - 301_000) }, }); const stale = await get( - 'brokerage-accounts/all/portfolio?sync=observe', + 'brokerage-accounts/all/portfolio', ).expect(200); expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false); @@ -595,7 +595,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date() }, }); - const empty = await get('brokerage-accounts?sync=observe').expect(200); + const empty = await get('brokerage-accounts').expect(200); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( 'available', @@ -625,7 +625,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await late.done; expect(await prisma.tbankPortfolio.count()).toBe(0); const replaced = await get( - 'brokerage-accounts/account/portfolio?sync=observe', + 'brokerage-accounts/account/portfolio', ).expect(200); expect((replaced.body as PortfolioResourceDto).data).toBeNull(); }); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 361f329..1d7481c 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -73,13 +73,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .set('Authorization', `Bearer ${auth(userId)}`) .timeout(1500); const events = ( - observe = true, scope = 'account', userId = 'owner', range = window, ) => get( - `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`, + `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}`, userId, ); const refresh = async ( @@ -278,7 +277,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { { currency: 'RUB', value: '6' }, { currency: 'USD', value: '20.000000002' }, ]); - const all = (await events(true, 'all').expect(200)) + const all = (await events('all').expect(200)) .body as UpcomingEventsResourceDto; expect( all.data?.events.every((event) => event.account?.id === 'account'), @@ -327,7 +326,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const saved = (await events().expect(200)) .body as UpcomingEventsResourceDto; const newRange = ( - await events(true, 'account', 'owner', { + await events('account', 'owner', { from: '2099-03-01', to: '2099-03-31', }).expect(200) @@ -346,7 +345,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toBeNull(); - await events(true, 'foreign').expect(404); + await events('foreign').expect(404); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); @@ -389,7 +388,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await get(`stocks/${shareUid}/dividends`).expect(200); await settled(); const before = ( - await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200) + await get(`stocks/${shareUid}/dividends`).expect(200) ).body as StockDividendsResourceDto; expect(before.data?.dividends[0].estimatedAmount).toEqual({ currency: 'RUB', @@ -418,7 +417,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); expect( ( - (await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)) + (await get(`stocks/${shareUid}/dividends`).expect(200)) .body as StockDividendsResourceDto ).data, ).toEqual(before.data); @@ -433,7 +432,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await get(`bonds/${bondUid}/coupons`).expect(200); await settled(); const coupons = ( - await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200) + await get(`bonds/${bondUid}/coupons`).expect(200) ).body as BondCouponsResourceDto; expect(coupons.data?.coupons[0].estimatedAmount).toEqual({ currency: 'RUB', @@ -467,7 +466,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await portfolio([position(uid, '3', type)]); const path = `${kind}/${uid}/${payout}`; const read = async () => - (await get(`${path}?sync=observe`).expect(200)).body as + (await get(`${path}`).expect(200)).body as BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); @@ -614,7 +613,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); const result = ( await get( - 'brokerage-accounts/account/events?from=2099-02-01&sync=observe', + 'brokerage-accounts/account/events?from=2099-02-01', ).expect(200) ).body as UpcomingEventsResourceDto; expect(result.data?.events.map((event) => event.date)).toEqual([ @@ -636,7 +635,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const calculatedAt = full.data?.calculatedAt; jest.clearAllMocks(); - const narrow = (await events(false, 'account', 'owner', { + const narrow = (await events('account', 'owner', { from: '2099-02-01', to: '2099-02-10', }).expect(200)).body as UpcomingEventsResourceDto; @@ -656,7 +655,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const narrowRange = { from: '2099-02-01', to: '2099-02-10' }; await refresh(narrowRange); - const narrow = (await events(true, 'account', 'owner', narrowRange).expect( + const narrow = (await events('account', 'owner', narrowRange).expect( 200, )).body as UpcomingEventsResourceDto; @@ -679,7 +678,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { dividendNet: { units: '10', nano: 1, currency: 'usd' }, }); const defaultEvents = async () => - (await get('brokerage-accounts/account/events?sync=observe').expect(200)) + (await get('brokerage-accounts/account/events').expect(200)) .body as UpcomingEventsResourceDto; // Only Date is faked: the app and SQLite helpers need real timers. const clock = (date: string) => { @@ -724,12 +723,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(second.data?.incomeTotalsByCurrency).toEqual([ { currency: 'USD', value: '100.00000001' }, ]); - const stale = (await events(true, 'account', 'owner', w0).expect(200)) + const stale = (await events('account', 'owner', w0).expect(200)) .body as UpcomingEventsResourceDto; expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt); expect(stale.data?.window).toEqual(w1); const disjoint = ( - await events(true, 'account', 'owner', { + await events('account', 'owner', { from: '2100-01-01', to: '2100-01-31', }).expect(200) @@ -794,7 +793,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); }; const latest = async () => - (await get('brokerage-accounts/account/events?sync=observe').expect(200)) + (await get('brokerage-accounts/account/events').expect(200)) .body as UpcomingEventsResourceDto; const dividendRow = () => prisma.tbankInstrumentSource.findFirstOrThrow({ @@ -924,12 +923,16 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { 'to=2099-13-01', 'from=2099-02-01T00:00:00Z', 'from=2099-02-28&to=2099-02-01', - 'sync=invalid', ])('rejects invalid query before source reads: %s', async (query) => { await get(`brokerage-accounts/account/events?${query}`).expect(400); expect(upstream.getDividends).not.toHaveBeenCalled(); }); + it('ignores the retired sync query parameter', async () => { + const response = await get(`brokerage-accounts/account/events?from=2099-02-01&to=2099-02-28&sync=invalid`).expect(200); + expect(response.body).toHaveProperty('meta'); + }); + it('uses UTC today for an end-only range and includes the entire leap day', async () => { const today = new Date().toISOString().slice(0, 10); await refresh({ from: today, to: '2096-02-29' }); @@ -954,7 +957,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const range = { from: '2096-02-29', to: '2096-02-29' }; await refresh(range); jest.clearAllMocks(); - const result = (await events(true, 'account', 'owner', range).expect(200)) + const result = (await events('account', 'owner', range).expect(200)) .body as UpcomingEventsResourceDto; expect(result.data?.events).toHaveLength(1); expect(result.data?.events[0]).toMatchObject({