refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,373 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
import type {
TbankAccountContext,
TbankConnectionContext,
} from '../../../../integrations/tbank/tbank-access.service';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
const access = {
withConnection: jest.fn(),
withAccountForConnection: jest.fn(),
};
const tbank = {
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const metadata = new InstrumentMetadataService(
tbank as never,
logger as never,
);
const service = new PortfolioService(
access as never,
tbank as never,
requestContext as never,
metadata,
);
let generation: string;
beforeEach(() => {
jest.clearAllMocks();
service.invalidate('user');
generation = 'generation-1';
access.withConnection.mockImplementation(
(
_userId: string,
handler: (context: TbankConnectionContext) => Promise<unknown>,
) => handler({ token: 'token', generation }),
);
access.withAccountForConnection.mockImplementation(
(
connection: TbankConnectionContext,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
...connection,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('resolves account-scoped access before loading upstream portfolio', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withConnection).toHaveBeenCalledTimes(2);
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
});
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'ONE',
instrumentType: 'share',
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
averagePositionPrice: { currency: 'RUB', units: 2 },
},
{
figi: 'TWO',
instrumentType: 'new-kind',
quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: 4 },
},
{ figi: 'ZERO', quantity: {} },
],
});
tbank.getPositions.mockResolvedValue({
money: [
{ currency: 'RUB', units: 1 },
{ currency: 'rub', nano: 500000000 },
{ currency: 'USD' },
],
});
tbank.getInstrumentByFigi
.mockResolvedValueOnce({
ticker: 'ONE',
name: 'Первая позиция',
isin: 'RU000A0JX0J2',
brand: { logoName: 'RU000A0JX0J2x160.png' },
})
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [
{
figi: 'ONE',
displayName: 'Первая позиция',
type: 'STOCK',
quantity: '2',
currentValue: { currency: 'RUB', value: '6' },
absoluteReturn: { currency: 'RUB', value: '2' },
isin: 'RU000A0JX0J2',
ticker: 'ONE',
logoName: 'RU000A0JX0J2x160.png',
},
{
figi: 'TWO',
displayName: 'TWO',
type: 'OTHER',
quantity: '-0.5',
currentValue: { currency: 'USD', value: '-2' },
isin: null,
ticker: null,
logoName: null,
},
],
cashBalances: [{ currency: 'RUB', value: '1.5' }],
totalsByCurrency: [
{ currency: 'RUB', value: '7.5' },
{ currency: 'USD', value: '-2' },
],
});
});
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
quantity: { units: 1 },
currentPrice: { currency: 'RUB' },
},
{
quantity: { units: 1 },
currentPrice: { units: 10 },
},
],
});
tbank.getPositions.mockResolvedValue({
money: [{ currency: 'USD' }, { units: 2 }],
});
await expect(service.get('user', 'account')).resolves.toMatchObject({
totalsByCurrency: [],
cashBalances: [],
});
});
it('uses instrument UID before FIGI when resolving a name', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'FIGI',
instrumentUid: 'UID',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [{ displayName: 'Имя по UID', logoName: null }],
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
const positions = Array.from({ length: 60 }, (_, index) => ({
figi: `BATCH-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
}));
tbank.getPortfolio.mockResolvedValue({ positions });
let active = 0;
tbank.getInstrumentByFigi.mockImplementation(
async (_token: string, figi: string) => {
active += 1;
try {
if (active > 50)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
await Promise.resolve();
return {
name: `Название ${figi}`,
ticker: figi,
isin: `ISIN-${figi}`,
brand: { logoName: `${figi}.png` },
};
} finally {
active -= 1;
}
},
);
const result = await service.get('user', 'account');
expect(result.positions).toMatchObject(
positions.map((position, index) => ({
displayName: `Название BATCH-${index}`,
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
ticker: `BATCH-${index}`,
isin: `ISIN-BATCH-${index}`,
logoName: `BATCH-${index}.png`,
quantity: '2',
currentValue: { currency: 'RUB', value: '20' },
})),
);
});
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
try {
tbank.getPortfolio.mockResolvedValue({
positions: Array.from({ length: 60 }, (_, index) => ({
figi: `BUDGET-${index}`,
instrumentUid: `budget-uid-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
})),
});
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
const pending = service.get('user', 'account');
await jest.advanceTimersByTimeAsync(0);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(30_000);
const result = await pending;
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
expect(result.positions).toHaveLength(60);
expect(result.positions[59]).toMatchObject({
displayName: 'BUDGET-59',
currentValue: { currency: 'RUB', value: '20' },
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withConnection).toHaveBeenCalledTimes(2);
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not mask an invalid token with a snapshot', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('does not reuse a snapshot or fallback across connection generations', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
generation = 'generation-2';
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not return a snapshot when the current connection is absent', async () => {
await service.get('user', 'account');
access.withConnection.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException),
);
});
it('does not expose a late previous-generation portfolio snapshot', async () => {
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
const previous = new Promise<{ positions: never[] }>((resolve) => {
finishPrevious = resolve;
});
tbank.getPortfolio
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({ positions: [] })
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
const previousRequest = service.get('user', 'account');
generation = 'generation-2';
const current = await service.get('user', 'account');
finishPrevious?.({ positions: [] });
await previousRequest;
const afterLateCompletion = await service.get('user', 'account');
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
expect(afterLateCompletion.cashBalances).toEqual([
{ currency: 'USD', value: '3' },
]);
});
});