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

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:
2026-09-26 21:29:15 +03:00
parent 0862d56c4e
commit d6e6c4ace5
116 changed files with 18321 additions and 5409 deletions
+263
View File
@@ -0,0 +1,263 @@
import { INestApplication, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
const upstream = { validateToken: jest.fn(), getOpenAccounts: jest.fn() };
const jwt = (owner: string) =>
new JwtService().sign(
{ sub: owner, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const put = (token = 'synthetic-token', owner = 'owner') =>
request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', `Bearer ${jwt(owner)}`)
.send({ token });
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.clearAllMocks();
upstream.validateToken.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: ' Synthetic ' },
{ id: 'unsupported', type: 'ACCOUNT_TYPE_UNSPECIFIED', name: 'Ignored' },
]);
});
afterAll(async () => app?.close());
it('publishes validated membership once, checks local ownership without RPC or decrypt, and preserves failed replacement', async () => {
await request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.send({ token: 'synthetic' })
.expect(401);
expect(upstream.validateToken).not.toHaveBeenCalled();
const response = await put(' synthetic-token ').expect(200);
expect(Object.keys(response.body as object).sort()).toEqual([
'connected',
'connectedAt',
]);
const saved = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
expect(app.get(TbankTokenService).decrypt(saved.encryptedToken)).toBe(
'synthetic-token',
);
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
const access = app.get(TbankAccessService);
const local = await access.local('owner', 'account');
expect(
local.accounts.map((account) => [account.accountId, account.name]),
).toEqual([['account', 'Synthetic']]);
expect(local.membershipAt).not.toBeNull();
expect(decrypt).not.toHaveBeenCalled();
decrypt.mockRestore();
expect(upstream.validateToken).toHaveBeenCalledTimes(1);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
await expect(access.local('owner', 'unsupported')).rejects.toBeInstanceOf(
NotFoundException,
);
await put('other-token', 'other').expect(200);
await prisma.tbankAccount.deleteMany({ where: { userId: 'other' } });
await expect(access.local('other', 'account')).rejects.toBeInstanceOf(
NotFoundException,
);
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { membershipAt: new Date(0) },
});
expect((await access.local('owner', 'account')).accounts).toHaveLength(1);
upstream.validateToken.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
expect((await put('invalid').expect(400)).body).toMatchObject({
code: 'INVALID_TBANK_TOKEN',
});
expect(
(
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision,
).toBe(saved.revision);
expect((await access.local('owner', 'account')).accounts).toHaveLength(1);
});
it('rotates even the same token atomically, removes private rows and fences late writes after replace/delete', async () => {
await put().expect(200);
const old = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const operation = {
userId: 'owner',
revision: old.revision,
accountId: 'account',
brokerOperationId: 'operation',
occurredAt: new Date(),
category: 'income',
payload: { payment: { value: '1.000000001', currency: 'RUB' } },
};
await prisma.tbankOperation.create({ data: operation });
upstream.validateToken.mockResolvedValueOnce([
{ id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'First' },
{ id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Second' },
]);
await put('candidate-with-invalid-membership').expect(500);
expect(
(
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision,
).toBe(old.revision);
expect(await prisma.tbankOperation.count()).toBe(1);
await put().expect(200);
const replacement = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
expect(replacement.revision).not.toBe(old.revision);
expect(replacement.encryptedToken).not.toBe(old.encryptedToken);
expect(await prisma.tbankOperation.count()).toBe(0);
await expect(
prisma.tbankOperation.create({ data: operation }),
).rejects.toMatchObject({ code: 'P2003' });
for (let index = 0; index < 2; index += 1)
await request(app.getHttpServer())
.delete('/api/v1/tbank-connection')
.set('Authorization', `Bearer ${jwt('owner')}`)
.expect(204);
await expect(
prisma.tbankOperation.create({
data: { ...operation, revision: replacement.revision },
}),
).rejects.toMatchObject({ code: 'P2003' });
await expect(
app.get(TbankAccessService).local('owner'),
).rejects.toMatchObject({ response: { code: 'TBANK_NOT_CONNECTED' } });
});
it('distinguishes unknown membership, complete empty membership and known invalid credentials', async () => {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
encryptedToken: 'never-decrypted',
connectedAt: new Date(),
},
});
const access = app.get(TbankAccessService);
expect((await access.local('owner', 'unknown')).membershipAt).toBeNull();
upstream.validateToken.mockResolvedValueOnce([]);
await put().expect(200);
expect((await access.local('owner')).accounts).toEqual([]);
await expect(access.local('owner', 'unknown')).rejects.toBeInstanceOf(
NotFoundException,
);
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { invalidToken: true },
});
await expect(access.local('owner')).rejects.toMatchObject({
response: { code: 'INVALID_TBANK_TOKEN' },
});
await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts')
.set('Authorization', `Bearer ${jwt('owner')}`)
.expect(400)
.expect((response) => {
expect(response.body).toMatchObject({ code: 'INVALID_TBANK_TOKEN' });
});
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it('publishes membership atomically without losing retained data and rejects superseded attempts', async () => {
await put().expect(200);
const access = app.get(TbankAccessService);
const connection = await access.local('owner');
const scope = { userId: 'owner', revision: connection.revision };
const attempt = { ...scope, id: 'membership', attemptId: 'first' };
await prisma.tbankSyncWork.create({
data: {
...attempt,
scopeKey: 'membership',
state: 'running',
intent: {},
progress: {},
processRunId: 'test',
priority: 0,
},
});
await prisma.tbankPortfolio.create({
data: {
...scope,
accountId: 'account',
payload: { total: '123.000000001' },
fetchedAt: new Date(0),
},
});
const accounts = [
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Synthetic' },
];
expect(
await access.publishMembership(attempt, accounts, new Date(1000)),
).toBe(true);
expect((await access.local('owner')).membershipRevision).toBe(
connection.membershipRevision,
);
expect(await prisma.tbankPortfolio.count()).toBe(1);
await prisma.tbankSyncWork.update({
where: { id: attempt.id },
data: { attemptId: 'second' },
});
expect(await access.publishMembership(attempt, [], new Date(2000))).toBe(
false,
);
expect(await access.rejectMembershipToken(attempt)).toBe(false);
expect((await access.local('owner')).membershipAt).toEqual(new Date(1000));
const current = { ...attempt, attemptId: 'second' };
await expect(
access.publishMembership(
current,
[...accounts, ...accounts],
new Date(3000),
),
).rejects.toThrow();
expect(await prisma.tbankPortfolio.count()).toBe(1);
expect(await access.publishMembership(current, [], new Date(4000))).toBe(
true,
);
expect(await prisma.tbankPortfolio.count()).toBe(0);
const empty = await access.local('owner');
expect(empty.accounts).toEqual([]);
expect(empty.membershipRevision).toBe(connection.membershipRevision + 1);
await put('replacement').expect(200);
expect(await access.publishMembership(current, [], new Date(5000))).toBe(
false,
);
expect(await access.rejectMembershipToken(current)).toBe(false);
expect((await access.local('owner')).accounts).toHaveLength(1);
});
});