codex/broker-operations-ui #18

Merged
ksv741 merged 22 commits from codex/broker-operations-ui into main 2026-06-18 06:34:55 +03:00
4 changed files with 174 additions and 58 deletions
Showing only changes of commit 4b87eccba4 - Show all commits

View File

@ -11,7 +11,7 @@ describe('portfolio.mapper', () => {
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('combines portfolio totals, cash, and enriched positions', () => {
it('combines portfolio totals and cash (positions removed)', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
@ -44,11 +44,5 @@ describe('portfolio.mapper', () => {
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
});
});

View File

@ -32,6 +32,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
name: null,
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
@ -98,6 +99,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade',
description: null,
state: null,
name: null,
instrumentUid: null,
figi: null,
ticker: null,

View File

@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => {
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
it('fetches portfolio and positions through cache', async () => {
it('fetches portfolio through cache without positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
@ -60,6 +60,7 @@ describe('BrokerPortfolioService', () => {
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect('positions' in result.data).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
@ -68,73 +69,189 @@ describe('BrokerPortfolioService', () => {
);
});
it('returns portfolio when one instrument enrichment request fails', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
describe('getPositions', () => {
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
it('returns first page of positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'figi-1',
instrumentUid: 'uid-1',
quantity: { units: '1', nano: 0 },
positionUid: 'pos-1',
quantity: { units: '10', nano: 0 },
},
{
figi: 'figi-2',
instrumentUid: 'uid-2',
quantity: { units: '2', nano: 0 },
positionUid: 'pos-2',
quantity: { units: '20', nano: 0 },
},
],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [],
blocked: [],
securities: [],
});
vi.mocked(instruments.findByInstrumentUid)
.mockResolvedValueOnce({
uid: 'uid-1',
figi: 'figi-1',
ticker: 'AAA',
classCode: 'TQBR',
name: 'First share',
instrumentType: 'share',
})
.mockRejectedValueOnce(new Error('instrument lookup failed'));
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 1);
expect(result.data.positions).toHaveLength(2);
expect(result.data.positions[0]).toMatchObject({
instrumentUid: 'uid-1',
ticker: 'AAA',
name: 'First share',
expect(result.data.accountId).toBe('acc-1');
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].positionUid).toBe('pos-1');
expect(result.data.hasNext).toBe(true);
expect(result.data.nextCursor).toBe('pos-1');
});
expect(result.data.positions[1]).toMatchObject({
instrumentUid: 'uid-2',
ticker: null,
name: null,
it('paginates using cursor', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
{
figi: 'f2',
instrumentUid: 'u2',
positionUid: 'p2',
quantity: { units: '20', nano: 0 },
},
{
figi: 'f3',
instrumentUid: 'u3',
positionUid: 'p3',
quantity: { units: '30', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', 'p1', 1);
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].positionUid).toBe('p2');
expect(result.data.nextCursor).toBe('p2');
expect(result.data.hasNext).toBe(true);
});
it('returns last page with hasNext=false', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 10);
expect(result.data.items).toHaveLength(1);
expect(result.data.hasNext).toBe(false);
expect(result.data.nextCursor).toBeNull();
});
it('caches positions with cursor/limit in key and tbankPositionsTtl', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await service.getPositions('acc-1', 'some-cursor', 5);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:positions',
['acc-1', 'some-cursor', '5'],
expect.any(Function),
'tbankPositionsTtl',
);
});
});
});

View File

@ -21,6 +21,7 @@ describe('T-Bank configuration', () => {
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(5);
expect(config.cache.tbankPortfolioTtl).toBe(60);
expect(config.cache.tbankPositionsTtl).toBe(60);
});
it('reads T-Bank token and TTL overrides from environment', () => {
@ -29,6 +30,7 @@ describe('T-Bank configuration', () => {
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
const config = configuration();
@ -37,5 +39,6 @@ describe('T-Bank configuration', () => {
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120);
expect(config.cache.tbankPositionsTtl).toBe(45);
});
});