import assert from 'node:assert/strict'; jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {}, })); import { PortfolioDto } from '../presentation/portfolio.dto'; import { PortfolioService } from './portfolio.service'; describe('persistent portfolio projection', () => { const access = { local: jest.fn(), tokenForAttempt: jest.fn(), rejectMembershipToken: jest.fn() }; const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() }; const metadata = { savedMany: jest.fn(), request: jest.fn() }; const classification = { refresh: jest.fn() }; const repository = { read: jest.fn(), reserve: jest.fn(), publish: jest.fn(), fail: jest.fn(), }; let service: PortfolioService; const membershipAt = new Date(); beforeEach(() => { jest.resetAllMocks(); service = new PortfolioService( access as never, tbank as never, metadata as never, classification as never, repository as never, { request: jest.fn().mockResolvedValue(null) } as never, { event: jest.fn() } as never, { hourly: jest.fn().mockResolvedValue(null) } as never, ); access.local.mockResolvedValue({ revision: 'private', membershipAt, accounts: [{ accountId: 'a' }, { accountId: 'b' }], }); access.tokenForAttempt.mockResolvedValue('synthetic'); access.rejectMembershipToken.mockResolvedValue(undefined); repository.reserve.mockResolvedValue(null); repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [], work: [], }); repository.publish.mockResolvedValue(true); metadata.savedMany.mockResolvedValue(new Map()); metadata.request.mockResolvedValue(null); classification.refresh.mockResolvedValue(new Map()); }); it('refreshes classification before publishing and passes the hourly walk time', async () => { const walkStartedAt = new Date('2026-10-31T12:00:00Z'); const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: '1' } }; repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } }); tbank.getPortfolio.mockResolvedValue({ positions: [position] }); tbank.getPositions.mockResolvedValue({ money: [] }); classification.refresh.mockResolvedValue(new Map([[JSON.stringify(['uid', 'figi']), { sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' }, }]])); const handle = await service.request('user', 'private', 'a', true, walkStartedAt); assert(handle); await handle.done; expect(classification.refresh).toHaveBeenCalledWith('synthetic', [position], walkStartedAt); expect(classification.refresh.mock.invocationCallOrder[0]).toBeLessThan(repository.publish.mock.invocationCallOrder[0]); const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; expect(snapshot.positions[0]).toEqual(expect.objectContaining({ sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' }, })); }); it('rejects an invalid classification token without publishing the snapshot', async () => { repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } }); tbank.getPortfolio.mockResolvedValue({ positions: [] }); tbank.getPositions.mockResolvedValue({ money: [] }); classification.refresh.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); const handle = await service.request('user', 'private', 'a'); assert(handle); await handle.done; expect(repository.publish).not.toHaveBeenCalled(); expect(access.rejectMembershipToken).toHaveBeenCalled(); }); it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => { repository.reserve.mockResolvedValueOnce({ launch: true, work: { id: 'work', attemptId: 'attempt' }, }); tbank.getPortfolio.mockResolvedValue({ positions: [ { figi: 'A', instrumentUid: 'uid', instrumentType: 'share', quantity: { units: '2' }, currentPrice: { currency: 'rub', units: '3', nano: 1 }, averagePositionPrice: { currency: 'RUB', units: '2' }, }, { figi: 'B', instrumentType: 'new-kind', quantity: { nano: -500000000 }, currentPrice: { currency: 'USD', units: '4' }, }, { figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } }, ], }); tbank.getPositions.mockResolvedValue({ money: [ { currency: 'rub', units: '1' }, { currency: 'EUR', units: '0' }, ], }); metadata.savedMany.mockResolvedValue( new Map([ [ JSON.stringify(['uid', 'A']), { data: { name: 'Known', ticker: 'A' } }, ], ]), ); repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [ { accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null, }, ], work: [], }); const handle = await service.request('user', 'private', 'a'); assert(handle); await handle.done; const snapshot = ( repository.publish.mock.calls as [unknown, string, PortfolioDto][] )[0][2]; expect( snapshot.positions.map((position) => [ position.displayName, position.type, position.quantity, ]), ).toEqual([ ['Known', 'STOCK', '2'], ]); expect(snapshot.positions[0].absoluteReturn).toEqual({ currency: 'RUB', value: '2.000000002', }); expect(snapshot.totals).toEqual({ currency: 'RUB', value: '7.000000002' }); expect(snapshot.cash).toEqual({ currency: 'RUB', value: '1' }); }); it('filters non-RUB cash and positions out of the RUB-only snapshot', async () => { repository.reserve.mockResolvedValueOnce({ launch: true, work: { id: 'work', attemptId: 'attempt' }, }); tbank.getPortfolio.mockResolvedValue({ positions: [ { quantity: { units: '1' }, currentPrice: { units: '3' } }, { quantity: { units: '1' }, currentPrice: { currency: 'USD', units: '3' }, averagePositionPrice: { currency: 'RUB', units: '2' }, }, ] }); tbank.getPositions.mockResolvedValue({ money: [ { currency: 'rub', units: '2' }, { currency: 'RUB', units: '-2' }, { units: '99' }, { currency: 'EUR', units: '1' }, ] }); const handle = await service.request('user', 'private', 'a'); assert(handle); await handle.done; const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; expect(snapshot.cash).toEqual({ currency: 'RUB', value: '0' }); expect(snapshot.totals).toEqual({ currency: 'RUB', value: '0' }); expect(snapshot.positions[0].currentValue).toBeNull(); expect(snapshot.positions).toHaveLength(1); }); it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => { repository.reserve.mockResolvedValueOnce({ launch: true, work: { id: 'work', attemptId: 'attempt' }, }); tbank.getPortfolio.mockResolvedValue({ positions: [ { figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } }, { figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } }, ] }); tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] }); repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }], work: [], }); const handle = await service.request('user', 'private', 'a'); assert(handle); await handle.done; const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; expect(snapshot.totals).toEqual({ currency: 'RUB', value: '121.49' }); expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND']); expect(snapshot.structure.totalValue).toBe('121.49'); }); // Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response. it('rejects a stored snapshot payload without the persisted shape', async () => { repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }], work: [], }); await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload'); }); it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), isStale: false, positions: [], cash: { currency: 'RUB', value: '0' }, structure: { totalValue: '1.000000001', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, totals: { currency: 'RUB', value: '1.000000001' }, }; const account = { accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: { payload: snapshot, fetchedAt: membershipAt, dataRevision: 1, }, }; repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [account, { ...account, accountId: 'b', portfolio: null }], work: [], }); const partial = await service.read('user', 'all'); expect(partial.data).toBeNull(); expect(partial.meta.availability).toBe('temporarily-unavailable'); expect(partial.meta.completeness).toBe('incomplete'); const saved = await service.saved('user', 'all'); expect(saved.accounts[0].snapshot?.totals).toEqual( snapshot.totals, ); expect(saved.accounts[1].snapshot).toBeNull(); repository.read.mockResolvedValue({ membershipAt, membershipRevision: 1, accounts: [ account, { ...account, accountId: 'b', portfolio: { ...account.portfolio, fetchedAt: new Date(0) }, }, ], work: [], }); const complete = await service.read('user', 'all'); expect(complete.data?.totals).toEqual({ currency: 'RUB', value: '2.000000002', }); expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString()); expect(complete.data?.isStale).toBe(true); expect(tbank.getPortfolio).not.toHaveBeenCalled(); }); });