refactor(tbank): centralize access rules
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user