import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { type TbankAccount, TbankClient, type TbankPortfolio, } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { throw new Error('Uninitialized deferred'); }; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } describe('local accounts and portfolio HTTP (SQLite)', () => { let app: INestApplication; let prisma: PrismaService; let portfolioService: PortfolioService; const accounts = [ { id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' }, ]; const upstream = { validateToken: jest.fn(), getOpenAccounts: jest.fn, [string]>(), getPortfolio: jest.fn, [string, string]>(), getPositions: jest.fn(), getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), }; const auth = (owner = 'owner') => `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; const get = (path: string, owner = 'owner') => request(app.getHttpServer()) .get(`/api/v1/${path}`) .set('Authorization', auth(owner)) .timeout(1500); const put = () => request(app.getHttpServer()) .put('/api/v1/tbank-connection') .set('Authorization', auth()) .send({ token: 'synthetic' }); async function settled(scope = 'portfolio:') { for (let index = 0; index < 500; index += 1) { const work = await prisma.tbankSyncWork.findFirst({ where: { scopeKey: { startsWith: scope } }, }); if (work && ['complete', 'failed'].includes(work.state)) return work; await delay(2); } throw new Error(`Work did not settle: ${scope}`); } async function requestPortfolio(accountId: string) { const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); const work = await portfolioService.request( 'owner', connection.revision, accountId, true, ); if (work) await work.done; } async function seedMembership(accountList = accounts) { const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); await prisma.tbankAccount.deleteMany({ where: { userId: 'owner', revision: connection.revision }, }); await prisma.tbankAccount.createMany({ data: accountList.map((account, upstreamOrder) => ({ userId: 'owner', revision: connection.revision, accountId: account.id, name: account.name, type: account.type, upstreamOrder, })), }); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() }, }); } beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); portfolioService = app.get(PortfolioService); for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); await prisma.tbankSyncWork.deleteMany(); await prisma.tbankOperationCoverage.deleteMany(); await prisma.tbankOperation.deleteMany(); await prisma.tbankAccount.deleteMany(); jest.clearAllMocks(); upstream.validateToken.mockResolvedValue(accounts); upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts); upstream.getPortfolio.mockReset().mockResolvedValue({ positions: [ { instrumentType: 'share', figi: 'stock', quantity: { units: '2' }, currentPrice: { units: '10', nano: 1, currency: 'rub' }, }, { instrumentType: 'share', figi: 'zero', quantity: { units: '0' } }, ], }); upstream.getPositions.mockResolvedValue({ money: [{ units: '1', nano: 100000000, currency: 'rub' }], }); }); afterAll(async () => app?.close()); it('returns missing without awaiting discovery, observe starts nothing, and unknown accounts become 404 after discovery', async () => { await prisma.tbankConnection.create({ data: { userId: 'owner', connectedAt: new Date(), encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), }, }); const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').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); 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?sync=unexpected').expect(200); }); it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => { await prisma.tbankInstrumentLookup.upsert({ where: { provider_identifierType_identifier: { provider: 'tbank', identifierType: 'figi', identifier: 'stock', }, }, create: { provider: 'tbank', identifierType: 'figi', identifier: 'stock', matchedUids: [], fetchedAt: new Date(), }, update: { matchedUids: [], fetchedAt: new Date() }, }); await put().expect(200); await seedMembership(); const blocked = deferred(); upstream.getPortfolio.mockReturnValueOnce(blocked.promise); const cold = await get('brokerage-accounts?sync=observe').expect(200); expect( (cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, ).toBeNull(); blocked.resolve({ positions: [ { instrumentType: 'share', figi: 'stock', quantity: { units: '2' }, currentPrice: { units: '10', nano: 1, currency: 'rub' }, }, ], }); const currentConnection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } }); const refreshed = await portfolioService.request('owner', currentConnection.revision, 'account', true); if (refreshed) await refreshed.done; const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); const warm = await get('brokerage-accounts').expect(200); expect( (warm.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, ).toEqual({ stockPositionCount: 1, bondPositionCount: 0, totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }], }); const portfolio = await get('brokerage-accounts/account/portfolio').expect( 200, ); expect( (portfolio.body as PortfolioResourceDto).data?.totalsByCurrency, ).toEqual([{ currency: 'RUB', value: '21.100000002' }]); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled(); expect(decrypt).not.toHaveBeenCalled(); decrypt.mockRestore(); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); expect(JSON.stringify(portfolio.body)).not.toContain(connection.revision); expect(JSON.stringify(portfolio.body)).not.toContain( connection.encryptedToken, ); }); it('overlays new shared labels without changing the saved financial snapshot', async () => { await put().expect(200); await seedMembership(); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); const work = await portfolioService.request( 'owner', connection.revision, 'account', true, ); if (work) await work.done; await settled(); const before = ( await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) ).body as PortfolioResourceDto; const fetchedAt = new Date(); await prisma.tbankInstrument.create({ data: { provider: 'tbank', uid: 'enriched-stock', figi: 'stock', instrumentType: 'share', metadata: { uid: 'enriched-stock', figi: 'stock', name: 'Enriched name', ticker: 'ENR', brand: { logoName: 'enriched.png' }, }, fetchedAt, }, }); await prisma.tbankInstrumentLookup.upsert({ where: { provider_identifierType_identifier: { provider: 'tbank', identifierType: 'figi', identifier: 'stock', }, }, create: { provider: 'tbank', identifierType: 'figi', identifier: 'stock', matchedUids: ['enriched-stock'], fetchedAt, }, update: { matchedUids: ['enriched-stock'], fetchedAt }, }); const after = ( await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) ).body as PortfolioResourceDto; expect(after.data?.positions[0]).toMatchObject({ displayName: 'Enriched name', ticker: 'ENR', logoName: 'enriched.png', quantity: '2', }); expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency); expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt); expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => { await put().expect(200); await seedMembership(); await requestPortfolio('account'); await settled(); await prisma.tbankPortfolio.updateMany({ data: { fetchedAt: new Date(0) }, }); await prisma.tbankSyncWork.updateMany({ where: { userId: 'owner', scopeKey: 'portfolio:account' }, data: { state: 'complete' }, }); const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200); const previous = (stale.body as PortfolioResourceDto).data; expect(previous?.isStale).toBe(true); expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString()); await settled(); await prisma.tbankSyncWork.updateMany({ where: { userId: 'owner', scopeKey: 'portfolio:account' }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); const failed = await get( 'brokerage-accounts/account/portfolio?sync=observe', ).expect(200); expect((failed.body as PortfolioResourceDto).data).toEqual(previous); expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none'); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => { await put().expect(200); await seedMembership(); await requestPortfolio('account'); await settled(); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); const startedAt = new Date('2026-10-10T10:00:00.000Z'); const finishedAt = new Date('2026-10-10T10:05:00.000Z'); await prisma.tbankSyncWork.create({ data: { id: 'hourly-attempt', userId: 'owner', revision: connection.revision, scopeKey: 'hourly', intent: {}, state: 'complete', attemptId: 'hourly-attempt', processRunId: 'test', priority: 0, progress: { result: 'partial', sections: [ { section: 'portfolio', accountId: 'account', errorCode: 'TBANK_UNAVAILABLE', }, { section: 'operations', accountId: 'account', errorCode: 'TBANK_UNAVAILABLE', }, ], }, createdAt: startedAt, updatedAt: finishedAt, }, }); const accounts = await get('brokerage-accounts').expect(200); const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta; expect(accountsMeta.lastAttempt.state).toBe('partial'); expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); expect(accountsMeta.lastAttempt.problems).toEqual([ { section: 'portfolio', accountId: 'account', errorCode: 'TBANK_UNAVAILABLE', }, ]); const income = await get( 'brokerage-accounts/account/income-analytics?sync=observe', ).expect(200); const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; expect(incomeMeta.lastAttempt.state).toBe('partial'); expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); expect(incomeMeta.lastAttempt.problems).toEqual([ { section: 'operations', accountId: 'account', errorCode: 'TBANK_UNAVAILABLE', }, ]); }); it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => { await put().expect(200); const connection = await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date(0) }, }); await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner', revision: connection.revision, scopeKey: 'membership' }, }); await prisma.tbankSyncWork.create({ data: { id: 'abandoned-membership', userId: 'owner', revision: connection.revision, scopeKey: 'membership', attemptId: 'abandoned', processRunId: 'terminated-process', state: 'running', priority: 0, intent: {}, progress: {}, }, }); const blocked = deferred(); upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise); await get('brokerage-accounts?sync=observe').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect( ( await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: 'abandoned-membership' }, }) ).attemptId, ).toBe('abandoned'); const reads = await Promise.all( Array.from({ length: 20 }, () => get('brokerage-accounts').expect(200)), ); expect( reads.every( (read) => (read.body as BrokerageAccountsResourceDto).data?.[0].id === 'account', ), ).toBe(true); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); blocked.resolve([]); await prisma.tbankSyncWork.update({ 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 prisma.tbankPortfolio.count()).toBe(0); expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ data: [{ id: 'account', portfolioSummary: null }], }); }); it('keeps account summaries independent and never turns a new missing member into zero personal holdings', async () => { const uid = '11111111-1111-4111-8111-111111111111'; upstream.validateToken.mockResolvedValueOnce([ ...accounts, { id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' }, ]); await put().expect(200); await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } }); await seedMembership([ ...accounts, { id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' }, ]); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } }); await Promise.all(['account', 'second'].map((accountId) => portfolioService.request('owner', connection.revision, accountId, true))); upstream.getPortfolio.mockImplementation((_token, id) => id === 'second' ? Promise.reject(new Error('synthetic unavailable')) : Promise.resolve({ positions: [ { instrumentUid: uid, instrumentType: 'share', quantity: { units: '2' }, currentPrice: { units: '10', currency: 'rub' }, }, ], }), ); await get('brokerage-accounts').expect(200); for (let index = 0; index < 200; index += 1) { if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; } await delay(5); } const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) .body as BrokerageAccountsResourceDto; expect(summaries.data?.[0].portfolioSummary?.totalsByCurrency).toEqual([ { currency: 'RUB', value: '21.1' }, ]); expect(summaries.data?.[1].portfolioSummary).toBeNull(); expect(summaries.meta.availability).toBe('available'); expect(summaries.meta.completeness).toBe('complete'); expect( (await get('brokerage-accounts/all/portfolio?sync=observe')).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect( (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); await prisma.tbankSyncWork.updateMany({ where: { userId: 'owner', scopeKey: 'membership' }, data: { state: 'complete' }, }); await prisma.tbankConnection.updateMany({ data: { membershipRevision: { increment: 1 } }, }); const position = await get(`stocks/${uid}/position?sync=observe`).expect( 200, ); expect(position.body).toMatchObject({ data: { quantity: '2', accounts: [{ account: { id: 'account' } }] }, meta: { availability: 'available', completeness: 'complete' }, }); await prisma.$disconnect(); expect( (await get(`stocks/${uid}/position?sync=observe`).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); }); it('uses membership TTL for an empty aggregate without starting work through observation', async () => { upstream.validateToken.mockResolvedValueOnce([]); await put().expect(200); await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } }); await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date() }, }); await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date(Date.now() - 120_000) }, }); const fresh = await get( 'brokerage-accounts/all/portfolio?sync=observe', ).expect(200); expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((fresh.body as PortfolioResourceDto).data).toMatchObject({ isStale: false, }); await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date(Date.now() - 301_000) }, }); const stale = await get( 'brokerage-accounts/all/portfolio?sync=observe', ).expect(200); expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).not.toHaveBeenCalled(); }); it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => { upstream.validateToken.mockResolvedValueOnce([]); await put().expect(200); await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } }); await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date() }, }); const empty = await get('brokerage-accounts?sync=observe').expect(200); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( 'available', ); await put().expect(200); await seedMembership(); const blocked = deferred(); upstream.getPortfolio.mockReturnValueOnce(blocked.promise); await get('brokerage-accounts/account/portfolio').expect(200); await put().expect(200); await seedMembership(); blocked.resolve({ positions: [] }); await delay(20); 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); }); });