feat: aggregate portfolio data across accounts

Add authenticated all-account portfolio, operations, income and event endpoints. Preserve source accounts, exact currency totals and scoped cursor pagination.
This commit is contained in:
2026-09-09 20:36:31 +03:00
parent 33f703d9a3
commit eaf2f67b34
15 changed files with 909 additions and 83 deletions
+101
View File
@@ -0,0 +1,101 @@
import { ConflictException, INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Server } from 'node:http';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { TbankAccessService } from '../src/tbank/tbank-access.service';
describe('All accounts HTTP boundary', () => {
let app: INestApplication<Server>;
const withSupportedAccounts = jest.fn();
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const paths = [
'portfolio',
'operations',
'income-summary',
'income-analytics',
'events',
];
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({ withSupportedAccounts })
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
withSupportedAccounts.mockReset();
withSupportedAccounts.mockImplementation(
(_user: string, handler: (context: unknown) => unknown) =>
handler({ token: 'unused', generation: 'first', accounts: [] }),
);
});
afterAll(() => app.close());
it.each(paths)('requires authentication for /all/%s', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.expect(401);
expect(withSupportedAccounts).not.toHaveBeenCalled();
});
it.each(paths)(
'routes /all/%s to the aggregate with verified identity',
async (path) => {
const response = await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(withSupportedAccounts).toHaveBeenCalledWith(
'account-owner',
expect.any(Function),
);
expect(response.body).not.toHaveProperty('token');
expect(response.body).not.toHaveProperty('generation');
if (path === 'portfolio')
expect(response.body).toMatchObject({ positions: [] });
else expect(response.body).toMatchObject({ accountId: 'all' });
},
);
it.each([
'operations?limit=0',
'operations?limit=1001',
'operations?categories=unknown',
'operations?from=not-a-date',
'income-analytics?from=2026-08-01',
'income-analytics?from=2026-08-31&to=2026-08-01',
])('validates /all/%s in the configured pipeline', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
});
it('returns a recoverable code for an expired or foreign continuation', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/operations?cursor=unknown')
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(response.body).toMatchObject({ code: 'INVALID_OPERATIONS_CURSOR' });
});
it('preserves connection errors without a partial success response', async () => {
withSupportedAccounts.mockRejectedValue(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(409);
expect(response.body).toMatchObject({ code: 'TBANK_NOT_CONNECTED' });
});
});