refactor(tbank): centralize access rules

This commit is contained in:
2026-08-25 12:06:41 +03:00
parent 5375e5598a
commit 130b303ec1
18 changed files with 573 additions and 327 deletions
+30 -34
View File
@@ -1,23 +1,18 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import { PortfolioService } from './portfolio.service';
import { TbankCrypto } from './tbank.crypto';
import type { TbankAccountContext } from './tbank-access.service';
describe('PortfolioService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const access = { withAccount: jest.fn() };
const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(),
@@ -26,39 +21,40 @@ describe('PortfolioService', () => {
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const service = new PortfolioService(
prisma as never,
config as never,
access as never,
tbank as never,
requestContext as never,
logger as never,
);
const crypto = TbankCrypto.fromBase64(key);
beforeEach(() => {
jest.clearAllMocks();
service.invalidate('user');
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
]);
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('requires a connection and an accessible brokerage account', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException),
);
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
it('resolves account-scoped access before loading upstream portfolio', async () => {
await service.get('user', 'account');
expect(access.withAccount).toHaveBeenCalledWith(
'user',
'account',
expect.any(Function),
);
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'account');
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'account');
});
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
@@ -148,15 +144,15 @@ describe('PortfolioService', () => {
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(tbank.getOpenAccounts).toHaveBeenCalledTimes(1);
expect(access.withAccount).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2);
expect(access.withAccount).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce(
access.withAccount.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
@@ -170,8 +166,8 @@ describe('PortfolioService', () => {
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),