fix: harden auth and cache concurrency
This commit is contained in:
@@ -31,6 +31,8 @@ describe('BrokerageOperationsService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tbank.getOpenAccounts.mockReset();
|
||||
tbank.getOperationsByCursor.mockReset();
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: crypto.encrypt('token'),
|
||||
});
|
||||
@@ -46,6 +48,11 @@ describe('BrokerageOperationsService', () => {
|
||||
(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', {});
|
||||
|
||||
@@ -134,4 +141,41 @@ describe('BrokerageOperationsService', () => {
|
||||
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);
|
||||
tbank.getOpenAccounts.mockResolvedValueOnce([]);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { RequestContextService } from '../common/logging/request-context.service';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -20,6 +21,7 @@ import { TbankClient } from './tbank.client';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const maxCachedPages = 100;
|
||||
const supportedAccounts = new Set([
|
||||
'ACCOUNT_TYPE_TINKOFF',
|
||||
'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
@@ -30,10 +32,11 @@ const supportedAccounts = new Set([
|
||||
@Injectable()
|
||||
export class BrokerageOperationsService {
|
||||
private readonly crypto: TbankCrypto;
|
||||
private readonly pages = new Map<
|
||||
string,
|
||||
{ page: BrokerageOperationsPageDto; storedAt: number }
|
||||
>();
|
||||
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
|
||||
max: maxCachedPages,
|
||||
ttl: freshForMs,
|
||||
ttlAutopurge: true,
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -53,10 +56,6 @@ export class BrokerageOperationsService {
|
||||
): Promise<BrokerageOperationsPageDto> {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
const range = this.range(query);
|
||||
const key = JSON.stringify({ userId, accountId, ...query, ...range });
|
||||
const cached = this.pages.get(key);
|
||||
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
||||
return cached.page;
|
||||
|
||||
try {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
@@ -74,9 +73,13 @@ export class BrokerageOperationsService {
|
||||
)
|
||||
throw new NotFoundException();
|
||||
|
||||
const key = cacheKey(userId, accountId, query, range);
|
||||
const cached = this.pages.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
if (range.from > range.to) {
|
||||
const page = emptyPage(accountId);
|
||||
this.pages.set(key, { page, storedAt: Date.now() });
|
||||
this.pages.set(key, page);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -101,7 +104,7 @@ export class BrokerageOperationsService {
|
||||
hasNext: Boolean(response.hasNext),
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
this.pages.set(key, { page, storedAt: Date.now() });
|
||||
this.pages.set(key, page);
|
||||
return page;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
@@ -121,6 +124,27 @@ export class BrokerageOperationsService {
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
query: BrokerageOperationsQueryDto,
|
||||
range: { from: Date; to: Date },
|
||||
): string {
|
||||
const categories = [...new Set(query.categories ?? [])].sort();
|
||||
const end = query.to
|
||||
? range.to.toISOString()
|
||||
: Math.floor(range.to.valueOf() / freshForMs);
|
||||
return JSON.stringify({
|
||||
userId,
|
||||
accountId,
|
||||
from: range.from.toISOString(),
|
||||
to: end,
|
||||
categories,
|
||||
cursor: query.cursor ?? null,
|
||||
limit: query.limit ?? 100,
|
||||
});
|
||||
}
|
||||
|
||||
function dateBoundary(value: string | undefined, end: boolean): Date | null {
|
||||
if (!value) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
|
||||
Reference in New Issue
Block a user