Files
backend/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts
codex-bot 809936e62d
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m32s
Backend verify / verify (push) Failing after 9m25s
refactor!: support RUB only (WealthLedger/main#55) (#204)
BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
2026-11-11 10:07:11 +03:00

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');
});
});