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.
442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
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,
|
|
type TbankAccount,
|
|
type TbankPortfolio,
|
|
} from '../src/integrations/tbank/tbank.client';
|
|
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
|
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
|
|
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
|
|
|
|
function deferred<T>() {
|
|
let resolve: (value: T) => void = () => {
|
|
throw new Error('Uninitialized deferred');
|
|
};
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|
let app: INestApplication<Server>;
|
|
let prisma: PrismaService;
|
|
const accounts = [
|
|
{ id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' },
|
|
];
|
|
const upstream = {
|
|
validateToken: jest.fn(),
|
|
getOpenAccounts: jest.fn<Promise<TbankAccount[]>, [string]>(),
|
|
getPortfolio: jest.fn<Promise<TbankPortfolio>, [string, string]>(),
|
|
getPositions: jest.fn(),
|
|
getInstrumentByUid: jest.fn(),
|
|
getInstrumentByFigi: jest.fn(),
|
|
};
|
|
const auth = (owner = 'owner') =>
|
|
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
|
|
const get = (path: string, owner = 'owner') =>
|
|
request(app.getHttpServer())
|
|
.get(`/api/v1/${path}`)
|
|
.set('Authorization', auth(owner))
|
|
.timeout(1500);
|
|
const put = () =>
|
|
request(app.getHttpServer())
|
|
.put('/api/v1/tbank-connection')
|
|
.set('Authorization', auth())
|
|
.send({ token: 'synthetic' });
|
|
async function settled(scope = 'portfolio:') {
|
|
for (let index = 0; index < 500; index += 1) {
|
|
const work = await prisma.tbankSyncWork.findFirst({
|
|
where: { scopeKey: { startsWith: scope } },
|
|
});
|
|
if (work && ['complete', 'failed'].includes(work.state)) return work;
|
|
await delay(2);
|
|
}
|
|
throw new Error(`Work did not settle: ${scope}`);
|
|
}
|
|
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(accounts);
|
|
upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts);
|
|
upstream.getPortfolio.mockReset().mockResolvedValue({
|
|
positions: [
|
|
{
|
|
instrumentType: 'share',
|
|
figi: 'stock',
|
|
quantity: { units: '2' },
|
|
currentPrice: { units: '10', nano: 1, currency: 'rub' },
|
|
},
|
|
{ instrumentType: 'share', figi: 'zero', quantity: { units: '0' } },
|
|
],
|
|
});
|
|
upstream.getPositions.mockResolvedValue({
|
|
money: [{ units: '1', nano: 100000000, currency: 'rub' }],
|
|
});
|
|
});
|
|
afterAll(async () => app?.close());
|
|
|
|
it('returns missing without awaiting discovery, observe starts nothing, and unknown accounts become 404 after discovery', async () => {
|
|
await prisma.tbankConnection.create({
|
|
data: {
|
|
userId: 'owner',
|
|
connectedAt: new Date(),
|
|
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
|
|
},
|
|
});
|
|
const blocked = deferred<TbankAccount[]>();
|
|
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
|
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
|
|
expect((cold.body as PortfolioResourceDto).data).toBeNull();
|
|
expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
|
|
'missing',
|
|
);
|
|
await get('brokerage-accounts?sync=observe').expect(200);
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
|
blocked.resolve(accounts);
|
|
await settled('membership');
|
|
await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404);
|
|
await get('brokerage-accounts?sync=observe', 'other').expect(409);
|
|
await get('brokerage-accounts?sync=unexpected').expect(400);
|
|
});
|
|
|
|
it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => {
|
|
await prisma.tbankInstrumentLookup.upsert({
|
|
where: {
|
|
provider_identifierType_identifier: {
|
|
provider: 'tbank',
|
|
identifierType: 'figi',
|
|
identifier: 'stock',
|
|
},
|
|
},
|
|
create: {
|
|
provider: 'tbank',
|
|
identifierType: 'figi',
|
|
identifier: 'stock',
|
|
matchedUids: [],
|
|
fetchedAt: new Date(),
|
|
},
|
|
update: { matchedUids: [], fetchedAt: new Date() },
|
|
});
|
|
|
|
await put().expect(200);
|
|
const blocked = deferred<TbankPortfolio>();
|
|
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
|
const cold = await get('brokerage-accounts').expect(200);
|
|
expect(
|
|
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
|
).toBeNull();
|
|
blocked.resolve({
|
|
positions: [
|
|
{
|
|
instrumentType: 'share',
|
|
figi: 'stock',
|
|
quantity: { units: '2' },
|
|
currentPrice: { units: '10', nano: 1, currency: 'rub' },
|
|
},
|
|
],
|
|
});
|
|
await settled();
|
|
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
|
|
const warm = await get('brokerage-accounts').expect(200);
|
|
expect(
|
|
(warm.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
|
|
).toEqual({
|
|
stockPositionCount: 1,
|
|
bondPositionCount: 0,
|
|
totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }],
|
|
});
|
|
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
|
|
200,
|
|
);
|
|
expect(
|
|
(portfolio.body as PortfolioResourceDto).data?.totalsByCurrency,
|
|
).toEqual([{ currency: 'RUB', value: '21.100000002' }]);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
|
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
expect(decrypt).not.toHaveBeenCalled();
|
|
decrypt.mockRestore();
|
|
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
|
where: { userId: 'owner' },
|
|
});
|
|
expect(JSON.stringify(portfolio.body)).not.toContain(connection.revision);
|
|
expect(JSON.stringify(portfolio.body)).not.toContain(
|
|
connection.encryptedToken,
|
|
);
|
|
});
|
|
|
|
it('overlays new shared labels without changing the saved financial snapshot', async () => {
|
|
await put().expect(200);
|
|
await get('brokerage-accounts/account/portfolio').expect(200);
|
|
await settled();
|
|
const before = (
|
|
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
|
).body as PortfolioResourceDto;
|
|
const fetchedAt = new Date();
|
|
await prisma.tbankInstrument.create({
|
|
data: {
|
|
provider: 'tbank',
|
|
uid: 'enriched-stock',
|
|
figi: 'stock',
|
|
instrumentType: 'share',
|
|
metadata: {
|
|
uid: 'enriched-stock',
|
|
figi: 'stock',
|
|
name: 'Enriched name',
|
|
ticker: 'ENR',
|
|
brand: { logoName: 'enriched.png' },
|
|
},
|
|
fetchedAt,
|
|
},
|
|
});
|
|
await prisma.tbankInstrumentLookup.upsert({
|
|
where: {
|
|
provider_identifierType_identifier: {
|
|
provider: 'tbank',
|
|
identifierType: 'figi',
|
|
identifier: 'stock',
|
|
},
|
|
},
|
|
create: {
|
|
provider: 'tbank',
|
|
identifierType: 'figi',
|
|
identifier: 'stock',
|
|
matchedUids: ['enriched-stock'],
|
|
fetchedAt,
|
|
},
|
|
update: { matchedUids: ['enriched-stock'], fetchedAt },
|
|
});
|
|
const after = (
|
|
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
|
|
).body as PortfolioResourceDto;
|
|
expect(after.data?.positions[0]).toMatchObject({
|
|
displayName: 'Enriched name',
|
|
ticker: 'ENR',
|
|
logoName: 'enriched.png',
|
|
quantity: '2',
|
|
});
|
|
expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency);
|
|
expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt);
|
|
expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision);
|
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => {
|
|
await put().expect(200);
|
|
await get('brokerage-accounts/account/portfolio').expect(200);
|
|
await settled();
|
|
await prisma.tbankPortfolio.updateMany({
|
|
data: { fetchedAt: new Date(0) },
|
|
});
|
|
upstream.getPortfolio.mockRejectedValueOnce(new Error('synthetic outage'));
|
|
const stale = await get('brokerage-accounts/account/portfolio').expect(200);
|
|
const previous = (stale.body as PortfolioResourceDto).data;
|
|
expect(previous?.isStale).toBe(true);
|
|
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
|
await settled();
|
|
const failed = await get(
|
|
'brokerage-accounts/account/portfolio?sync=observe',
|
|
).expect(200);
|
|
expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
|
|
expect((failed.body as PortfolioResourceDto).meta.sync).toMatchObject({
|
|
state: 'failed',
|
|
errorCode: 'TBANK_UNAVAILABLE',
|
|
});
|
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
|
|
await put().expect(200);
|
|
const connection = await prisma.tbankConnection.update({
|
|
where: { userId: 'owner' },
|
|
data: { membershipAt: new Date(0) },
|
|
});
|
|
await prisma.tbankSyncWork.create({
|
|
data: {
|
|
id: 'abandoned-membership',
|
|
userId: 'owner',
|
|
revision: connection.revision,
|
|
scopeKey: 'membership',
|
|
attemptId: 'abandoned',
|
|
processRunId: 'terminated-process',
|
|
state: 'running',
|
|
priority: 0,
|
|
intent: {},
|
|
progress: {},
|
|
},
|
|
});
|
|
const blocked = deferred<TbankAccount[]>();
|
|
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
|
|
await get('brokerage-accounts?sync=observe').expect(200);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(
|
|
(
|
|
await prisma.tbankSyncWork.findUniqueOrThrow({
|
|
where: { id: 'abandoned-membership' },
|
|
})
|
|
).attemptId,
|
|
).toBe('abandoned');
|
|
const reads = await Promise.all(
|
|
Array.from({ length: 20 }, () => get('brokerage-accounts').expect(200)),
|
|
);
|
|
expect(
|
|
reads.every(
|
|
(read) =>
|
|
(read.body as BrokerageAccountsResourceDto).data?.[0].id ===
|
|
'account',
|
|
),
|
|
).toBe(true);
|
|
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
(
|
|
await prisma.tbankSyncWork.findUniqueOrThrow({
|
|
where: { id: 'abandoned-membership' },
|
|
})
|
|
).attemptId,
|
|
).not.toBe('abandoned');
|
|
blocked.resolve([]);
|
|
await settled('membership');
|
|
await get('brokerage-accounts/account/portfolio?sync=observe').expect(404);
|
|
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
|
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
|
|
data: [],
|
|
});
|
|
});
|
|
|
|
it('keeps account summaries independent and never turns a new missing member into zero personal holdings', async () => {
|
|
const uid = '11111111-1111-4111-8111-111111111111';
|
|
upstream.validateToken.mockResolvedValueOnce([
|
|
...accounts,
|
|
{ id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' },
|
|
]);
|
|
await put().expect(200);
|
|
upstream.getPortfolio.mockImplementation((_token, id) =>
|
|
id === 'second'
|
|
? Promise.reject(new Error('synthetic unavailable'))
|
|
: Promise.resolve({
|
|
positions: [
|
|
{
|
|
instrumentUid: uid,
|
|
instrumentType: 'share',
|
|
quantity: { units: '2' },
|
|
currentPrice: { units: '10', currency: 'rub' },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await get('brokerage-accounts').expect(200);
|
|
for (let index = 0; index < 200; index += 1) {
|
|
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
|
|
break;
|
|
await delay(5);
|
|
}
|
|
const summaries = (await get('brokerage-accounts?sync=observe').expect(200))
|
|
.body as BrokerageAccountsResourceDto;
|
|
expect(summaries.data?.[0].portfolioSummary?.totalsByCurrency).toEqual([
|
|
{ currency: 'RUB', value: '21.1' },
|
|
]);
|
|
expect(summaries.data?.[1].portfolioSummary).toBeNull();
|
|
expect(summaries.meta.availability).toBe('partial');
|
|
expect(
|
|
(await get('brokerage-accounts/all/portfolio?sync=observe')).body,
|
|
).toMatchObject({ data: null, meta: { availability: 'partial' } });
|
|
expect(
|
|
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
|
).toMatchObject({ data: null, meta: { availability: 'partial' } });
|
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
|
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
|
|
await prisma.tbankConnection.updateMany({
|
|
data: { membershipRevision: { increment: 1 } },
|
|
});
|
|
const position = await get(`stocks/${uid}/position?sync=observe`).expect(
|
|
200,
|
|
);
|
|
expect(position.body).toMatchObject({
|
|
data: { quantity: '2', accounts: [{ account: { id: 'account' } }] },
|
|
meta: { availability: 'complete' },
|
|
});
|
|
await prisma.$disconnect();
|
|
expect(
|
|
(await get(`stocks/${uid}/position?sync=observe`).expect(200)).body,
|
|
).toEqual(position.body);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
|
|
await prisma.tbankConnection.updateMany({ data: { invalidToken: true } });
|
|
await get(`stocks/${uid}/position?sync=observe`).expect(400);
|
|
});
|
|
|
|
it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
|
|
upstream.validateToken.mockResolvedValueOnce([]);
|
|
await put().expect(200);
|
|
await prisma.tbankConnection.updateMany({
|
|
data: { membershipAt: new Date(Date.now() - 120_000) },
|
|
});
|
|
const fresh = await get(
|
|
'brokerage-accounts/all/portfolio?sync=observe',
|
|
).expect(200);
|
|
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
|
|
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
|
|
isStale: false,
|
|
positions: [],
|
|
totalsByCurrency: [],
|
|
});
|
|
await prisma.tbankConnection.updateMany({
|
|
data: { membershipAt: new Date(Date.now() - 301_000) },
|
|
});
|
|
const stale = await get(
|
|
'brokerage-accounts/all/portfolio?sync=observe',
|
|
).expect(200);
|
|
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('stale');
|
|
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(true);
|
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
|
expect(upstream.getPortfolio).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => {
|
|
upstream.validateToken.mockResolvedValueOnce([]);
|
|
await put().expect(200);
|
|
const empty = await get('brokerage-accounts?sync=observe').expect(200);
|
|
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
|
|
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
|
|
'complete',
|
|
);
|
|
await put().expect(200);
|
|
const blocked = deferred<TbankPortfolio>();
|
|
upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
|
|
await get('brokerage-accounts/account/portfolio').expect(200);
|
|
await put().expect(200);
|
|
blocked.resolve({ positions: [] });
|
|
await delay(20);
|
|
const replaced = await get(
|
|
'brokerage-accounts/account/portfolio?sync=observe',
|
|
).expect(200);
|
|
expect((replaced.body as PortfolioResourceDto).data).toBeNull();
|
|
expect(await prisma.tbankPortfolio.count()).toBe(0);
|
|
});
|
|
});
|