diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts index 945b08e..f9881bf 100644 --- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts @@ -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 }, - }); }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts index 14bc828..70b5892 100644 --- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -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, diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index 682d12f..d679856 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -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) => ({ - 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) => ({ + 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) => ({ + 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) => ({ + 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) => ({ + 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', + ); }); }); }); diff --git a/apps/backend/src/modules/tbank/tbank.config.spec.ts b/apps/backend/src/modules/tbank/tbank.config.spec.ts index e3f9a34..5f874f6 100644 --- a/apps/backend/src/modules/tbank/tbank.config.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.config.spec.ts @@ -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); }); });