173 lines
6.6 KiB
TypeScript
173 lines
6.6 KiB
TypeScript
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 = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
|
portfolio.saved.mockResolvedValue({
|
|
membership: meta,
|
|
accounts: [
|
|
{
|
|
id: 'a',
|
|
name: 'A',
|
|
type: 'BROKERAGE',
|
|
openedOn: null,
|
|
meta,
|
|
snapshot: {
|
|
cash: { currency: 'RUB', value: '10.000000001' },
|
|
structure,
|
|
totals: { 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({
|
|
cash: { currency: 'RUB', value: '10.000000001' },
|
|
structure,
|
|
totals: { currency: 'RUB', value: '100000000.000000001' },
|
|
stockPositionCount: 1,
|
|
bondPositionCount: 1,
|
|
fundPositionCount: 0,
|
|
totalPositionCount: 2,
|
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
|
});
|
|
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 = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
|
portfolio.saved.mockResolvedValueOnce({
|
|
membership: meta,
|
|
accounts: [{
|
|
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
|
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
|
|
}],
|
|
});
|
|
|
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
|
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
|
|
stockPositionCount: 0, bondPositionCount: 0,
|
|
fundPositionCount: 0, totalPositionCount: 0,
|
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
|
});
|
|
});
|
|
|
|
it('calculates exact returns, partial data, and zero cost basis', async () => {
|
|
portfolio.saved.mockResolvedValueOnce({
|
|
membership: meta,
|
|
accounts: [{
|
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
|
snapshot: {
|
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
|
|
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: 'RUB', value: '2.5' }, absoluteReturn: null },
|
|
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
|
],
|
|
},
|
|
}],
|
|
});
|
|
|
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
|
stockPositionCount: 1, bondPositionCount: 2,
|
|
fundPositionCount: 1, totalPositionCount: 4,
|
|
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
|
|
});
|
|
});
|
|
|
|
it('omits return percent for short positions', async () => {
|
|
portfolio.saved.mockResolvedValueOnce({
|
|
membership: meta,
|
|
accounts: [{
|
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
|
snapshot: {
|
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
|
|
positions: [
|
|
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
|
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
|
|
],
|
|
},
|
|
}],
|
|
});
|
|
|
|
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
|
|
{ absolute: '-19', costBasis: '-190', percent: null, 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');
|
|
});
|
|
});
|