feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { configureApp } from '../src/main';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto';
|
||||
|
||||
describe('screen sync HTTP and SQLite', () => {
|
||||
let app: INestApplication<Server>;
|
||||
let prisma: PrismaService;
|
||||
const upstream = {
|
||||
getOpenAccounts: jest.fn(),
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getOperationsByCursor: jest.fn(),
|
||||
findInstrument: jest.fn(),
|
||||
};
|
||||
const auth = (id = 'owner') =>
|
||||
`Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
||||
const post = (scope: object, id = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/tbank-sync')
|
||||
.set('Authorization', auth(id))
|
||||
.send(scope);
|
||||
const status = (id: string, owner = 'owner') =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/v1/tbank-sync/${id}`)
|
||||
.set('Authorization', auth(owner));
|
||||
async function settled(id: string) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const response = await status(id).expect(200);
|
||||
const body = response.body as TbankSyncStatusDto;
|
||||
if (body.state !== 'running') return body;
|
||||
await delay(5);
|
||||
}
|
||||
throw new Error('Screen work did not settle');
|
||||
}
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
await app.listen(0, '127.0.0.1');
|
||||
prisma = app.get(PrismaService);
|
||||
for (const id of ['owner', 'other'])
|
||||
await prisma.user.create({
|
||||
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.tbankConnection.deleteMany();
|
||||
jest.resetAllMocks();
|
||||
upstream.getOpenAccounts.mockResolvedValue([]);
|
||||
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
upstream.getPositions.mockResolvedValue({ money: [] });
|
||||
for (const userId of ['owner', 'other'])
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
||||
connectedAt: new Date(),
|
||||
membershipAt: new Date(),
|
||||
membershipRevision: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => app.close());
|
||||
it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => {
|
||||
let release: (value: unknown[]) => void = () => {};
|
||||
upstream.getOpenAccounts.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
post({ screen: 'accounts' }).expect(202),
|
||||
),
|
||||
);
|
||||
const ids = responses.map(
|
||||
(response) => (response.body as { id: string }).id,
|
||||
);
|
||||
expect(new Set(ids).size).toBe(1);
|
||||
expect((await status(ids[0]).expect(200)).body).toMatchObject({
|
||||
state: 'running',
|
||||
scope: { screen: 'accounts' },
|
||||
});
|
||||
await status(ids[0], 'other').expect(404);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
release([]);
|
||||
const done = await settled(ids[0]);
|
||||
expect(done.state).toBe('succeeded');
|
||||
expect(done.resources[0]).toMatchObject({
|
||||
resourceKey: 'accounts',
|
||||
availability: 'complete',
|
||||
});
|
||||
expect(JSON.stringify(done)).not.toMatch(
|
||||
/revision|attemptId|encryptedToken|synthetic/,
|
||||
);
|
||||
await status(ids[0]).expect(200);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{ screen: 'accounts', userId: 'other' },
|
||||
{ screen: 'accounts', accountId: 'all' },
|
||||
{ screen: 'events' },
|
||||
{ screen: 'stock', instrumentUid: 'not-uuid' },
|
||||
{ screen: 'operations', accountId: 'all', limit: 1001 },
|
||||
{
|
||||
screen: 'income',
|
||||
accountId: 'all',
|
||||
from: '2026-02-31',
|
||||
to: '2026-03-01',
|
||||
},
|
||||
{
|
||||
screen: 'stock',
|
||||
instrumentUid: '00000000-0000-4000-8000-000000000001',
|
||||
candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] },
|
||||
},
|
||||
])('rejects malformed scope without reserving work: %j', async (scope) => {
|
||||
const response = await post(scope).expect(400);
|
||||
expect(response.headers['content-type']).toContain(
|
||||
'application/problem+json',
|
||||
);
|
||||
const problem = response.body as {
|
||||
code: string;
|
||||
errors: { pointer: string; detail: string }[];
|
||||
};
|
||||
expect(problem.code).toBe('VALIDATION_ERROR');
|
||||
expect(problem.errors.length).toBeGreaterThan(0);
|
||||
for (const error of problem.errors) {
|
||||
expect(typeof error.pointer).toBe('string');
|
||||
expect(typeof error.detail).toBe('string');
|
||||
}
|
||||
expect(await prisma.tbankSyncWork.count()).toBe(0);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
|
||||
upstream.getOpenAccounts.mockRejectedValue(
|
||||
new Error('upstream details never exposed'),
|
||||
);
|
||||
const accepted = await post({ screen: 'accounts' }).expect(202);
|
||||
const id = (accepted.body as { id: string }).id;
|
||||
const failed = await settled(id);
|
||||
expect(failed.state).toBe('failed');
|
||||
expect(failed.errorCode).toBe('TBANK_UNAVAILABLE');
|
||||
await status(id).expect(200);
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
await prisma.tbankSyncWork.update({
|
||||
where: { id },
|
||||
data: {
|
||||
state: 'running',
|
||||
errorCode: null,
|
||||
processRunId: 'previous-process',
|
||||
progress: { children: [], dispatched: false },
|
||||
},
|
||||
});
|
||||
expect((await status(id).expect(200)).body).toMatchObject({
|
||||
state: 'cancelled',
|
||||
});
|
||||
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
|
||||
await status(id).expect(404);
|
||||
});
|
||||
it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => {
|
||||
const account = {
|
||||
id: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedDate: { seconds: '1577836800' },
|
||||
};
|
||||
upstream.getOpenAccounts.mockResolvedValue([account]);
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
await prisma.tbankAccount.create({
|
||||
data: {
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
accountId: 'a',
|
||||
name: 'Synthetic',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
openedOn: '2020-01-01',
|
||||
upstreamOrder: 0,
|
||||
},
|
||||
});
|
||||
const path =
|
||||
'/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe';
|
||||
const before = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
upstream.getOperationsByCursor.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
});
|
||||
const response = await post({
|
||||
screen: 'operations',
|
||||
accountId: 'a',
|
||||
from: '2025-01-01',
|
||||
to: '2025-01-31',
|
||||
}).expect(202);
|
||||
const done = await settled((response.body as { id: string }).id);
|
||||
expect(done.state).toBe('succeeded');
|
||||
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
const after = await request(app.getHttpServer())
|
||||
.get(path)
|
||||
.set('Authorization', auth())
|
||||
.expect(200);
|
||||
const first = before.body as {
|
||||
meta: { dataRevision: string; stateVersion: string };
|
||||
};
|
||||
const last = after.body as {
|
||||
data: { items: unknown[] };
|
||||
meta: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
sync: { id: string };
|
||||
};
|
||||
};
|
||||
expect(last.data.items).toEqual([]);
|
||||
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
|
||||
expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion);
|
||||
expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion);
|
||||
await status(last.meta.sync.id).expect(200);
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('rejects known foreign accounts locally and requires authentication', async () => {
|
||||
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/tbank-sync')
|
||||
.send({ screen: 'accounts' })
|
||||
.expect(401);
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user