import { setImmediate as immediate } from 'node:timers/promises'; jest.mock('../../../../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() }; const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() }; const metadata = { savedMany: jest.fn(), request: 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, 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'); 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); }); 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: [], }); await service.request('user', 'private', 'a'); for ( let index = 0; index < 10 && !repository.publish.mock.calls.length; index += 1 ) { await immediate(); } 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'], ['B', 'OTHER', '-0.5'], ]); expect(snapshot.positions[0].absoluteReturn).toEqual({ currency: 'RUB', value: '2.000000002', }); expect(snapshot.totalsByCurrency).toEqual([ { currency: 'RUB', value: '7.000000002' }, { currency: 'USD', value: '-2' }, ]); expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); }); it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', 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' }, ] }); await service.request('user', 'private', 'a'); for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) { await immediate(); } const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; expect(snapshot.cashBalances).toEqual([ { currency: 'RUB', value: '0' }, { currency: 'EUR', value: '1' }, ]); expect(snapshot.totalsByCurrency).toEqual([ { currency: 'EUR', value: '1' }, { currency: 'USD', value: '3' }, ]); expect(snapshot.positions[0].currentValue).toBeNull(); expect(snapshot.positions[1].absoluteReturn).toBeNull(); expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' }); }); it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), isStale: false, positions: [], cashBalances: [], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ 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?.totalsByCurrency).toEqual( snapshot.totalsByCurrency, ); 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?.totalsByCurrency).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(); }); });