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/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/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; + }); 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/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-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 }; -} 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..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'; @@ -18,6 +19,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 +104,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); @@ -570,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); + }); +}); 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) => {