jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {}, })); import { investmentResourceMeta } from '../investment-resource.mapper'; import { BrokerageAccountsService } from './brokerage-accounts.service'; jest.mock('../portfolio/application/portfolio.service', () => ({ PortfolioService: class PortfolioService {}, })); const meta = investmentResourceMeta({ resourceKey: 'membership', dataRevision: '1', availability: 'complete', fetchedAt: new Date(), freshForMs: 300_000, work: null, }); describe('saved brokerage account projection', () => { const portfolio = { saved: jest.fn() }; const sync = { hourly: jest.fn().mockResolvedValue(null) }; const service = new BrokerageAccountsService(portfolio as never, sync as never); it('distinguishes unknown membership from a confirmed empty list', async () => { portfolio.saved.mockResolvedValueOnce({ membership: { ...meta, completeness: 'incomplete' }, accounts: [], }); expect((await service.list('owner')).data).toBeNull(); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); expect((await service.list('owner')).data).toEqual([]); }); it('keeps exact saved totals and independent missing summaries', async () => { const structure = { currencies: [], unvaluedCategories: [] }; portfolio.saved.mockResolvedValue({ membership: meta, accounts: [ { id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, snapshot: { cashBalances: [{ currency: 'RUB', value: '10.000000001' }], structure, totalsByCurrency: [ { currency: 'RUB', value: '100000000.000000001' }, ], positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }], }, }, { id: 'b', name: 'B', type: 'BROKERAGE', openedOn: null, meta: { ...meta, availability: 'missing' }, snapshot: null, }, ], }); const response = await service.list('owner'); expect(response.data?.[0].portfolioSummary).toEqual({ cashBalances: [{ currency: 'RUB', value: '10.000000001' }], structure, totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], stockPositionCount: 1, bondPositionCount: 1, fundPositionCount: 0, totalPositionCount: 2, unrealizedReturnByCurrency: [], }); expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.meta.availability).toBe('available'); expect(response.meta.completeness).toBe('complete'); }); it('returns an empty summary from an empty saved portfolio', async () => { const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] }; portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta, snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] }, }], }); expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({ cashBalances: [], structure, totalsByCurrency: [], stockPositionCount: 0, bondPositionCount: 0, fundPositionCount: 0, totalPositionCount: 0, unrealizedReturnByCurrency: [], }); }); it('calculates exact returns by currency, partial data, and zero cost basis', async () => { portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, snapshot: { cashBalances: [], structure: {}, totalsByCurrency: [ { currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' }, ], positions: [ { type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } }, { type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } }, { type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null }, { type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } }, { type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } }, { type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null }, ], }, }], }); expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({ stockPositionCount: 2, bondPositionCount: 2, fundPositionCount: 1, totalPositionCount: 6, unrealizedReturnByCurrency: [ { currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true }, { currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false }, { currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true }, ], }); }); it('omits return percent for a currency with short positions', async () => { portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, snapshot: { cashBalances: [], structure: {}, totalsByCurrency: [], positions: [ { type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } }, { type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } }, ], }, }], }); expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([ { currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false }, { currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false }, ]); }); it('observes the latest hourly walk without starting another one', async () => { portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] }); sync.hourly.mockResolvedValue({ startedAt: new Date('2026-09-25T12:00:00Z'), finishedAt: new Date('2026-09-25T12:01:00Z'), state: 'partial', sections: [ { section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE', }, ], }); const response = await service.list('owner'); expect(response.sync).toEqual({ startedAt: '2026-09-25T12:00:00.000Z', finishedAt: '2026-09-25T12:01:00.000Z', state: 'partial', problems: [ { section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE', }, ], }); expect(sync.hourly).toHaveBeenCalledWith('owner'); }); });