270 lines
8.1 KiB
TypeScript
270 lines
8.1 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
|
|
jest.mock('./tbank-access.service', () => ({
|
|
TbankAccessService: class TbankAccessService {},
|
|
}));
|
|
|
|
import { BrokerageOperationsService } from './brokerage-operations.service';
|
|
import type { TbankAccountContext } from './tbank-access.service';
|
|
|
|
describe('BrokerageOperationsService', () => {
|
|
const access = { withAccount: jest.fn() };
|
|
const tbank = {
|
|
getOperationsByCursor: jest.fn(),
|
|
};
|
|
const requestContext = { set: jest.fn() };
|
|
const metadata = { resolve: jest.fn() };
|
|
const service = new BrokerageOperationsService(
|
|
access as never,
|
|
tbank as never,
|
|
requestContext as never,
|
|
metadata as never,
|
|
);
|
|
let operationsRequest: unknown;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
access.withAccount.mockReset();
|
|
tbank.getOperationsByCursor.mockReset();
|
|
metadata.resolve.mockReset();
|
|
access.withAccount.mockImplementation(
|
|
(
|
|
_userId: string,
|
|
_accountId: string,
|
|
handler: (context: TbankAccountContext) => Promise<unknown>,
|
|
) =>
|
|
handler({
|
|
token: 'token',
|
|
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
|
}),
|
|
);
|
|
tbank.getOperationsByCursor.mockImplementation(
|
|
(_token: string, request: unknown) => {
|
|
operationsRequest = request;
|
|
return Promise.resolve({ items: [] });
|
|
},
|
|
);
|
|
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
|
await service.get('user', 'account', {});
|
|
|
|
const request = operationsRequest as {
|
|
state: string;
|
|
from: { seconds: string };
|
|
};
|
|
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
|
|
expect(request.from.seconds).toBe(
|
|
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
|
|
);
|
|
});
|
|
|
|
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
hasNext: true,
|
|
nextCursor: 'next',
|
|
items: [
|
|
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
|
|
{
|
|
id: 'fee',
|
|
date: { seconds: '2' },
|
|
type: 'OPERATION_TYPE_BROKER_FEE',
|
|
},
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
service.get('user', 'account', {
|
|
from: '2025-01-01',
|
|
to: '2025-01-31',
|
|
categories: ['income'],
|
|
cursor: 'previous',
|
|
limit: 10,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
items: [{ id: 'income' }],
|
|
nextCursor: 'next',
|
|
hasNext: true,
|
|
});
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
'token',
|
|
expect.objectContaining({
|
|
from: { seconds: '1735689600' },
|
|
to: { seconds: '1738367999', nanos: 999_999_999 },
|
|
cursor: 'previous',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('includes resolved instrument logo metadata in a ledger entry', async () => {
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 'coupon',
|
|
date: { seconds: '1' },
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
instrumentUid: 'instrument-uid',
|
|
},
|
|
],
|
|
});
|
|
metadata.resolve.mockResolvedValue({
|
|
brand: { logoName: 'instrument-logo.png' },
|
|
});
|
|
|
|
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
|
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
|
|
});
|
|
});
|
|
|
|
it('resolves repeated instrument metadata only once per ledger page', async () => {
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 'first',
|
|
date: { seconds: '1' },
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
instrumentUid: 'instrument-uid',
|
|
},
|
|
{
|
|
id: 'second',
|
|
date: { seconds: '2' },
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
instrumentUid: 'instrument-uid',
|
|
},
|
|
],
|
|
});
|
|
metadata.resolve.mockResolvedValue({
|
|
brand: { logoName: 'instrument-logo.png' },
|
|
});
|
|
|
|
await service.get('user', 'account', {});
|
|
|
|
expect(metadata.resolve).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
|
|
tbank.getOperationsByCursor.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 'first',
|
|
date: { seconds: '1' },
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
instrumentUid: 'instrument-uid',
|
|
},
|
|
{
|
|
id: 'second',
|
|
date: { seconds: '2' },
|
|
type: 'OPERATION_TYPE_COUPON',
|
|
instrumentUid: 'instrument-uid',
|
|
figi: 'BBG000000001',
|
|
},
|
|
],
|
|
});
|
|
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
brand: { logoName: 'fallback-logo.png' },
|
|
});
|
|
|
|
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
|
items: [
|
|
{ id: 'first', logoName: null },
|
|
{ id: 'second', logoName: 'fallback-logo.png' },
|
|
],
|
|
});
|
|
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
|
1,
|
|
'token',
|
|
'instrument-uid',
|
|
null,
|
|
);
|
|
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
|
2,
|
|
'token',
|
|
'instrument-uid',
|
|
'BBG000000001',
|
|
);
|
|
});
|
|
|
|
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
|
await expect(
|
|
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
|
).resolves.toMatchObject({
|
|
accountId: 'account',
|
|
items: [],
|
|
nextCursor: null,
|
|
hasNext: false,
|
|
});
|
|
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects inaccessible accounts before reading operations', async () => {
|
|
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
|
|
|
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
expect.any(NotFoundException),
|
|
);
|
|
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retains existing T-Bank error semantics', async () => {
|
|
access.withAccount.mockRejectedValueOnce(
|
|
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
|
);
|
|
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
expect.any(BadRequestException),
|
|
);
|
|
tbank.getOperationsByCursor.mockRejectedValueOnce(
|
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
);
|
|
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
expect.any(ServiceUnavailableException),
|
|
);
|
|
});
|
|
|
|
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
|
|
|
|
await service.get('user', 'account', {
|
|
categories: ['tax', 'income', 'income'],
|
|
});
|
|
jest.advanceTimersByTime(500);
|
|
await service.get('user', 'account', { categories: ['income', 'tax'] });
|
|
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not let a cached page bypass account ownership checks', async () => {
|
|
const query = { from: '2025-01-01', to: '2025-01-31' };
|
|
await service.get('user', 'account', query);
|
|
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
|
|
|
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
|
expect.any(NotFoundException),
|
|
);
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
|
for (let index = 0; index < 100; index += 1)
|
|
await service.get('user', 'account', { cursor: String(index) });
|
|
|
|
await service.get('user', 'account', { cursor: '0' });
|
|
await service.get('user', 'account', { cursor: '100' });
|
|
await service.get('user', 'account', { cursor: '1' });
|
|
|
|
const pages = (service as unknown as { pages: { size: number } }).pages;
|
|
expect(pages.size).toBe(100);
|
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
|
});
|
|
});
|