import type { Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import type { PortfolioDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { AppModule } from '../src/app.module'; import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service'; import { type TbankAccount, TbankClient, type TbankPortfolio, } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; import { PrismaService } from '../src/prisma/prisma.service'; describe('T-Bank value history persistence and visibility (SQLite)', () => { let app: INestApplication; let prisma: PrismaService; let portfolioService: PortfolioService; let membershipService: TbankMembershipService; const upstream = { validateToken: jest.fn, [string]>(), getOpenAccounts: jest.fn, [string]>(), getPortfolio: jest.fn, [string, string]>(), getPositions: jest.fn(), getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), }; const accounts = [ { id: 'a', name: 'Account A', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'b', name: 'Account B', type: 'ACCOUNT_TYPE_TINKOFF' }, ]; const auth = (owner = 'owner') => `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; const putConnection = (owner: string, tbankToken: string) => request(app.getHttpServer()) .put('/api/v1/tbank-connection') .set('Authorization', auth(owner)) .send({ token: tbankToken }); beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(TbankClient) .useValue(upstream) .overrideProvider(TbankHourlyScheduler) .useValue({ connected: jest.fn().mockResolvedValue(undefined) }) .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); membershipService = app.get(TbankMembershipService); }); beforeEach(async () => { await prisma.tbankPortfolioValuePoint.deleteMany(); await prisma.tbankConnection.deleteMany(); await prisma.tbankPortfolioValuePoint.deleteMany(); await prisma.user.deleteMany(); await prisma.tbankPortfolioValueHistory.deleteMany(); jest.clearAllMocks(); upstream.validateToken.mockResolvedValue(accounts); upstream.getOpenAccounts.mockResolvedValue(accounts); upstream.getPortfolio.mockResolvedValue({ positions: [ { instrumentType: 'share', figi: 'stock', quantity: { units: '2' }, currentPrice: { units: '10', nano: 500000000, currency: 'rub' }, }, ], }); upstream.getPositions.mockResolvedValue({ money: [{ units: '5', currency: 'rub' }], }); }); afterAll(() => app?.close()); async function createUser(id: string) { return prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic', }, }); } async function publishPortfolio(userId: string, accountId: string) { const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId }, }); const work = portfolioService['queueSnapshot']( userId, connection.revision, accountId, true, ); const reserved = await work; if (reserved) await reserved.done; } async function publishMembership(userId: string) { const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId }, }); const result = await membershipService.request( userId, connection.revision, true, ); if (result) await result.done; } describe('1.1 Point persistence', () => { it('writes a single RUB point for a fully valued snapshot', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const history = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, include: { points: true }, }); expect(history?.deletedAt).toBeNull(); expect(history?.points).toHaveLength(1); const rubPoint = history?.points[0]; expect(rubPoint?.value).toBe('26'); const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({ where: { userId_revision_accountId: { userId: 'owner', revision: ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision, accountId: 'a', }, }, }); expect(rubPoint?.capturedAt).toEqual(snapshot.fetchedAt); }); it('writes no points when a non-zero position has unknown price', async () => { upstream.getPortfolio.mockResolvedValue({ positions: [ { instrumentType: 'share', figi: 'valued', quantity: { units: '2' }, currentPrice: { units: '10', currency: 'rub' }, }, { instrumentType: 'share', figi: 'unpriced', quantity: { units: '1' }, }, ], }); upstream.getPositions.mockResolvedValue({ money: [{ units: '5', currency: 'rub' }], }); await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const snapshot = await prisma.tbankPortfolio.findUnique({ where: { userId_revision_accountId: { userId: 'owner', revision: ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision, accountId: 'a', }, }, }); expect(snapshot).not.toBeNull(); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(0); }); it('writes a zero point for an empty fully valued account', async () => { upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(1); expect(points[0].value).toBe('0'); }); it('deduplicates repeated publication of same attempt', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const work = await prisma.tbankSyncWork.findFirstOrThrow({ where: { scopeKey: { startsWith: 'portfolio:' } }, }); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); await prisma.tbankSyncWork.update({ where: { id: work.id }, data: { state: 'running' }, }); const published = await prisma.tbankPortfolio .findUniqueOrThrow({ where: { userId_revision_accountId: { userId: 'owner', revision: connection.revision, accountId: 'a', }, }, }) .then((p) => p.payload as unknown as PortfolioDto); await app.get(PortfolioService)['repository'].publish( { id: work.id, attemptId: work.attemptId, userId: 'owner', revision: connection.revision, }, 'a', published, ); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(1); }); it('late attempt after token replacement adds no points', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); const oldRevision = ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision; upstream.validateToken.mockResolvedValue([accounts[0]]); await putConnection('owner', 'token-b').expect(200); const oldWork = { id: 'old', attemptId: 'old-attempt', userId: 'owner', revision: oldRevision, }; const published = { lastSuccessfulAt: new Date().toISOString(), isStale: false, totals: { currency: 'RUB', value: '100' }, positions: [ { figi: 'stock', displayName: 'Stock', type: 'STOCK', quantity: '1', currentValue: { currency: 'RUB', value: '100' }, averagePurchasePrice: null, absoluteReturn: null, isin: null, ticker: null, logoName: null, sector: null, issuer: null, }, ], cash: { currency: 'RUB', value: '0' }, structure: { totalValue: '100', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, }; const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published); expect(result).toBe(false); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(0); }); it('preserves old snapshot and points when publish fails', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(pointsBefore).toHaveLength(1); upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed')); await publishPortfolio('owner', 'a'); const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(pointsAfter).toHaveLength(1); expect(pointsAfter).toEqual(pointsBefore); }); }); describe('1.2 Visibility', () => { it('token replacement unhides confirmed account and continues history', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(oldPoints).toHaveLength(1); upstream.validateToken.mockResolvedValue([accounts[0]]); await putConnection('owner', 'token-b').expect(200); const history = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(history?.deletedAt).toBeNull(); await publishPortfolio('owner', 'a'); const newPoints = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, orderBy: { createdAt: 'asc' }, }); expect(newPoints.length).toBeGreaterThan(1); expect(newPoints.slice(0, 1)).toEqual(oldPoints); }); it('hides history when account disappears from membership', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); await publishPortfolio('owner', 'b'); const historyBefore = await prisma.tbankPortfolioValueHistory.findMany({ where: { userId: 'owner' }, }); expect(historyBefore).toHaveLength(2); expect(historyBefore.every((h) => h.deletedAt === null)).toBe(true); upstream.getOpenAccounts.mockResolvedValue([accounts[0]]); await publishMembership('owner'); const historyAfter = await prisma.tbankPortfolioValueHistory.findMany({ where: { userId: 'owner' }, orderBy: { accountId: 'asc' }, }); expect(historyAfter[0].accountId).toBe('a'); expect(historyAfter[0].deletedAt).toBeNull(); expect(historyAfter[1].accountId).toBe('b'); expect(historyAfter[1].deletedAt).not.toBeNull(); const pointsB = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'b' }, }); expect(pointsB).toHaveLength(1); }); it('leaves visibility unchanged on membership fetch error', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const historyBefore = await prisma.tbankPortfolioValueHistory.findUnique( { where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }, ); expect(historyBefore?.deletedAt).toBeNull(); upstream.getOpenAccounts.mockRejectedValue(new Error('Upstream failed')); await publishMembership('owner'); const historyAfter = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(historyAfter?.deletedAt).toEqual(historyBefore?.deletedAt); }); it('delete hides all history; reconnect unhides confirmed account', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(1); await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', auth('owner')) .expect(204); const historyDeleted = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(historyDeleted?.deletedAt).not.toBeNull(); upstream.validateToken.mockResolvedValue([accounts[0]]); await putConnection('owner', 'token-b').expect(200); const historyRestored = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(historyRestored?.deletedAt).toBeNull(); await publishPortfolio('owner', 'a'); const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(pointsAfter.length).toBeGreaterThan(1); }); it('unhides when disappeared account returns in membership', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); upstream.getOpenAccounts.mockResolvedValue([]); await publishMembership('owner'); const historyHidden = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(historyHidden?.deletedAt).not.toBeNull(); upstream.getOpenAccounts.mockResolvedValue([accounts[0]]); await publishMembership('owner'); const historyUnhidden = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); expect(historyUnhidden?.deletedAt).toBeNull(); }); it('isolates history by user', async () => { await createUser('owner'); await createUser('other'); await putConnection('owner', 'token-a').expect(200); await putConnection('other', 'token-b').expect(200); await publishPortfolio('owner', 'a'); await publishPortfolio('other', 'a'); await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', auth('owner')) .expect(204); const ownerHistory = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'owner', accountId: 'a' } }, }); const otherHistory = await prisma.tbankPortfolioValueHistory.findUnique({ where: { userId_accountId: { userId: 'other', accountId: 'a' } }, }); expect(ownerHistory?.deletedAt).not.toBeNull(); expect(otherHistory?.deletedAt).toBeNull(); }); it('cascades old private data on token replacement without losing history', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); const oldRevision = ( await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }) ).revision; await publishPortfolio('owner', 'a'); const oldSnapshot = await prisma.tbankPortfolio.findMany({ where: { userId: 'owner', revision: oldRevision }, }); expect(oldSnapshot).toHaveLength(1); upstream.validateToken.mockResolvedValue([accounts[0]]); await putConnection('owner', 'token-b').expect(200); const deletedSnapshot = await prisma.tbankPortfolio.findMany({ where: { userId: 'owner', revision: oldRevision }, }); expect(deletedSnapshot).toHaveLength(0); const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); expect(points).toHaveLength(1); }); }); });