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
+478 -90
View File
@@ -2,111 +2,499 @@ import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { MoexClient } from '../src/integrations/moex/moex.client';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
import {
TbankClient,
TbankTokenService,
} from '../src/integrations/tbank/tbank.api';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto';
import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
describe('Upcoming events date filter HTTP boundary', () => {
const shareUid = '00000000-0000-4000-8000-000000000001';
const bondUid = '00000000-0000-4000-8000-000000000002';
const newUid = '00000000-0000-4000-8000-000000000003';
const window = { from: '2099-02-01', to: '2099-02-28' };
const time = (date: string) => ({
seconds: String(Date.parse(`${date}T00:00:00Z`) / 1000),
});
const position = (uid = shareUid, quantity = '2', type = 'STOCK') => ({
instrumentUid: uid,
figi: uid,
ticker: type === 'STOCK' ? 'STOCK' : 'BOND',
displayName: 'Synthetic',
type,
quantity,
averagePurchasePrice: null,
currentPrice: null,
currentValue: null,
absoluteReturn: null,
isin: null,
logoName: null,
});
describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
let app: INestApplication<Server>;
const getPortfolio = jest.fn();
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
let prisma: PrismaService;
const upstream = {
getShare: jest.fn(),
getBond: jest.fn(),
getDividends: jest.fn(),
getBondCoupons: jest.fn(),
getCandles: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getOpenAccounts: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const moex = { bondEvents: jest.fn() };
const auth = (userId: string) =>
new JwtService().sign(
{ sub: userId, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const get = (path: string, userId = 'owner') =>
request(app.getHttpServer())
.get(`/api/v1/${path}`)
.set('Authorization', `Bearer ${auth(userId)}`)
.timeout(1500);
const events = (
observe = true,
scope = 'account',
userId = 'owner',
range = window,
) =>
get(
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
userId,
);
async function settled() {
for (let i = 0; i < 500; i++) {
await delay(5);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
) {
await delay(10);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
)
return;
}
}
throw new Error('Forecast dependencies did not settle');
}
async function portfolio(
positions = [position()],
userId = 'owner',
accountId = 'account',
) {
const payload = {
lastSuccessfulAt: new Date().toISOString(),
isStale: false,
positions,
totalsByCurrency: [],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
};
await prisma.tbankPortfolio.upsert({
where: {
userId_revision_accountId: {
userId,
revision: `${userId}-revision`,
accountId,
},
},
create: {
userId,
revision: `${userId}-revision`,
accountId,
payload,
fetchedAt: new Date(),
},
update: {
payload,
fetchedAt: new Date(),
dataRevision: { increment: 1 },
},
});
}
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({
withAccount: (
_user: string,
_id: string,
handler: (ctx: unknown) => unknown,
) => handler({ token: 'unused' }),
withSupportedAccounts: (
_user: string,
handler: (ctx: unknown) => unknown,
) =>
handler({
token: 'unused',
accounts: [
{ id: 'account', name: 'Main' },
{ id: 'second', name: 'Second' },
],
}),
})
.overrideProvider(TbankClient)
.useValue({
getPortfolio,
getInstrumentByFigi: () => Promise.resolve(null),
})
.useValue(upstream)
.overrideProvider(MoexClient)
.useValue({
bondEvents: () =>
Promise.resolve([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'offer', date: '2099-03-01' },
]),
})
.useValue(moex)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
getPortfolio.mockReset().mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
});
afterAll(() => app.close());
describe.each(['account', 'all'])('%s account scope', (scope) => {
it('returns only the requested inclusive period and matching totals', async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${scope}/events?from=2099-02-01&to=2099-02-28`,
)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
accountId: scope,
events:
scope === 'all'
? [
{ date: '2099-02-01', account: { id: 'account' } },
{ date: '2099-02-01', account: { id: 'second' } },
{ date: '2099-02-28', account: { id: 'account' } },
{ date: '2099-02-28', account: { id: 'second' } },
]
: [{ date: '2099-02-01' }, { date: '2099-02-28' }],
incomeTotalsByCurrency: [
{ currency: 'RUB', value: scope === 'all' ? '12' : '6' },
],
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 settled();
await prisma.tbankConnection.deleteMany();
await prisma.tbankInstrumentLookup.deleteMany();
await prisma.tbankInstrument.deleteMany();
jest.resetAllMocks();
upstream.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
uid,
ticker: 'STOCK',
name: 'Synthetic share',
currency: 'rub',
}),
);
upstream.getBond.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
uid,
ticker: 'BOND',
name: 'Synthetic bond',
currency: 'rub',
}),
);
upstream.getInstrumentByUid.mockImplementation(
(_token: string, uid: string) =>
Promise.resolve({
uid,
ticker: uid === bondUid ? 'BOND' : 'STOCK',
name: 'Synthetic',
}),
);
upstream.getCandles.mockResolvedValue([]);
upstream.getDividends.mockResolvedValue([
{
recordDate: time('2099-02-01'),
dividendNet: { units: '10', nano: 1, currency: 'usd' },
},
{
recordDate: time('2099-02-10'),
dividendType: 'Cancelled',
dividendNet: { units: '999', currency: 'rub' },
},
]);
upstream.getBondCoupons.mockResolvedValue([]);
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'maturity', date: '2099-02-28', value: '500', currency: 'RUB' },
{ type: 'offer', date: '2099-03-01' },
]);
for (const userId of ['owner', 'other']) {
await prisma.tbankConnection.create({
data: {
userId,
revision: `${userId}-revision`,
encryptedToken: app
.get(TbankTokenService)
.encrypt(`synthetic-${userId}`),
connectedAt: new Date(),
membershipAt: new Date(),
membershipRevision: 1,
accounts: {
create: {
accountId: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Synthetic',
upstreamOrder: 0,
},
},
},
});
await portfolio([position()], userId);
}
});
afterAll(async () => {
await settled();
await app?.close();
});
it.each([
'from=not-a-date',
'from=2099-02-30',
'to=2099-13-01',
'from=2099-02-01T00:00:00Z',
'from=2099-02-28&to=2099-02-01',
])('rejects invalid dates before upstream access: %s', async (query) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${scope}/events?${query}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(getPortfolio).not.toHaveBeenCalled();
it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => {
let release: (value: unknown[]) => void = () => {};
upstream.getDividends.mockReturnValueOnce(
new Promise<unknown[]>((resolve) => {
release = resolve;
}),
);
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toBeNull();
expect(upstream.getDividends).not.toHaveBeenCalled();
const cold = (await events(false).expect(200))
.body as UpcomingEventsResourceDto;
expect(cold.data).toBeNull();
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++)
await delay(5);
await events().expect(200);
release([]);
await settled();
const warm = (await events().expect(200)).body as UpcomingEventsResourceDto;
expect(warm.meta.availability).toBe('complete');
expect(warm.data?.events).toEqual([]);
const other = (await events(false, 'account', 'other').expect(200))
.body as UpcomingEventsResourceDto;
expect(other.data?.events).toEqual([]);
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
expect(await prisma.tbankForecast.count()).toBe(2);
expect(JSON.stringify(warm)).not.toContain('owner-revision');
});
it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => {
await portfolio([position(), position(bondUid, '3', 'BOND')]);
await events(false).expect(200);
await settled();
const single = (await events().expect(200))
.body as UpcomingEventsResourceDto;
expect(single.data?.events).toHaveLength(4);
expect(
single.data?.events.find((event) => event.type === 'offer')
?.projectedAmount,
).toBeNull();
expect(single.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '6' },
{ currency: 'USD', value: '20.000000002' },
]);
const all = (await events(true, 'all').expect(200))
.body as UpcomingEventsResourceDto;
expect(
all.data?.events.every((event) => event.account?.id === 'account'),
).toBe(true);
expect(all.data?.incomeTotalsByCurrency).toEqual(
single.data?.incomeTotalsByCurrency,
);
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
expect(upstream.getBondCoupons).not.toHaveBeenCalled();
expect(
JSON.stringify(await prisma.tbankInstrumentSource.findMany()),
).not.toMatch(/quantity|projectedAmount|estimatedAmount|synthetic-owner/);
expect(upstream.getPortfolio).not.toHaveBeenCalled();
});
it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => {
await events(false).expect(200);
await settled();
const before = (await events().expect(200))
.body as UpcomingEventsResourceDto;
await portfolio([position(shareUid, '7'), position(newUid, '5')]);
upstream.getDividends.mockImplementation((_token: string, uid: string) =>
uid === newUid
? Promise.reject(new Error('new calendar unavailable'))
: Promise.resolve([]),
);
const pending = (await events(false).expect(200))
.body as UpcomingEventsResourceDto;
expect(pending.data).toEqual(before.data);
await settled();
await prisma.$disconnect();
const after = (await events().expect(200))
.body as UpcomingEventsResourceDto;
expect(after.data).toEqual(before.data);
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(after.meta.sync.state).toBe('failed');
const count = upstream.getDividends.mock.calls.length;
await events().expect(200);
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
});
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
await events(false).expect(200);
await settled();
await events().expect(200);
const newRange = (
await events(true, 'account', 'owner', {
from: '2099-03-01',
to: '2099-03-31',
}).expect(200)
).body as UpcomingEventsResourceDto;
expect(newRange.data).toBeNull();
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { membershipRevision: { increment: 1 } },
});
await portfolio([position(newUid)]);
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toBeNull();
await events(true, 'foreign').expect(404);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => {
await events(false).expect(200);
await settled();
const before = (await events().expect(200))
.body as UpcomingEventsResourceDto;
await portfolio([position(shareUid, '3')]);
const repository = app.get(ForecastRepository);
const original = repository.resolve.bind(repository);
const publish = jest
.spyOn(repository, 'resolve')
.mockImplementationOnce(async (input) => {
await portfolio([position(shareUid, '9')]);
return original(input);
});
try {
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toEqual(before.data);
} finally {
publish.mockRestore();
}
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data
?.incomeTotalsByCurrency,
).toEqual([{ currency: 'USD', value: '90.000000009' }]);
});
it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => {
const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
upstream.getDividends.mockResolvedValue([
{
recordDate: time(date),
paymentDate: time(date),
dividendNet: { units: '10', nano: 1, currency: 'rub' },
},
]);
await get(`stocks/${shareUid}/dividends`).expect(200);
await settled();
const before = (
await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)
).body as StockDividendsResourceDto;
expect(before.data?.dividends[0].estimatedAmount).toEqual({
currency: 'RUB',
value: '20.000000002',
});
await portfolio([position(shareUid, '3')], 'other');
const other = (
await get(`stocks/${shareUid}/dividends`, 'other').expect(200)
).body as StockDividendsResourceDto;
expect(other.data?.dividends[0].estimatedAmount?.value).toBe(
'30.000000003',
);
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
upstream.getDividends.mockRejectedValue(new Error('outage'));
await app
.get(CatalogEnrichmentService)
.requestMany(
'owner',
'owner-revision',
'share',
[shareUid],
true,
['dividends'],
{ from: before.data?.from ?? '', to: before.data?.to ?? '' },
);
await settled();
expect(
(
(await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200))
.body as StockDividendsResourceDto
).data,
).toEqual(before.data);
await portfolio([position(bondUid, '3', 'BOND')]);
upstream.getBondCoupons.mockResolvedValue([
{
couponDate: time(date),
fixDate: time(date),
payOneBond: { units: '2', currency: 'rub' },
},
]);
await get(`bonds/${bondUid}/coupons`).expect(200);
await settled();
const coupons = (
await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200)
).body as BondCouponsResourceDto;
expect(coupons.data?.coupons[0].estimatedAmount).toEqual({
currency: 'RUB',
value: '6',
});
expect(moex.bondEvents).not.toHaveBeenCalled();
});
it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => {
upstream.getBond.mockRejectedValue(new Error('metadata unavailable'));
await portfolio([position(bondUid, '3', 'BOND')]);
await events(false).expect(200);
await settled();
const result = (await events().expect(200))
.body as UpcomingEventsResourceDto;
expect(result.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '6' },
]);
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
});
it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => {
await portfolio([position(bondUid, '3', 'BOND')]);
moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block'));
await events(false).expect(200);
await settled();
expect(
((await events().expect(200)).body as UpcomingEventsResourceDto).data,
).toBeNull();
await get('brokerage-accounts/account/events?from=2099-02-01').expect(200);
await settled();
const result = (
await get(
'brokerage-accounts/account/events?from=2099-02-01&sync=observe',
).expect(200)
).body as UpcomingEventsResourceDto;
expect(result.data?.events.map((event) => event.date)).toEqual([
'2099-02-01',
'2099-02-28',
'2099-02-28',
'2099-03-01',
]);
});
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
let release: (value: unknown[]) => void = () => {};
upstream.getDividends.mockReturnValueOnce(
new Promise<unknown[]>((resolve) => {
release = resolve;
}),
);
await events(false).expect(200);
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++)
await delay(5);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
release([
{
recordDate: time('2099-02-01'),
dividendNet: { units: '999', currency: 'rub' },
},
]);
await delay(30);
expect(
await prisma.tbankInstrumentSource.count({
where: { kind: 'dividends' },
}),
).toBe(0);
await events().expect(409);
expect(await prisma.tbankForecast.count()).toBe(0);
});
it.each([
'from=not-a-date',
'from=2099-02-30',
'to=2099-13-01',
'from=2099-02-01T00:00:00Z',
'from=2099-02-28&to=2099-02-01',
'sync=invalid',
])('rejects invalid query before source reads: %s', async (query) => {
await get(`brokerage-accounts/account/events?${query}`).expect(400);
expect(upstream.getDividends).not.toHaveBeenCalled();
});
});