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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user