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
+48 -24
View File
@@ -1,15 +1,18 @@
import { ConflictException, INestApplication } from '@nestjs/common';
import { INestApplication } 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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
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 { configureApp } from '../src/main';
describe('All accounts HTTP boundary', () => {
let app: INestApplication<Server>;
const withSupportedAccounts = jest.fn();
let prisma: PrismaService;
const upstream = { getOpenAccounts: jest.fn() };
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
@@ -24,45 +27,66 @@ describe('All accounts HTTP boundary', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({ withSupportedAccounts })
.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);
await prisma.user.create({
data: {
id: 'account-owner',
email: 'all@example.invalid',
passwordHash: 'synthetic',
},
});
});
beforeEach(() => {
withSupportedAccounts.mockReset();
withSupportedAccounts.mockImplementation(
(_user: string, handler: (context: unknown) => unknown) =>
handler({ token: 'unused', generation: 'first', accounts: [] }),
);
beforeEach(async () => {
upstream.getOpenAccounts.mockReset().mockResolvedValue([]);
await prisma.tbankConnection.deleteMany();
await prisma.tbankConnection.create({
data: {
userId: 'account-owner',
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
connectedAt: new Date(),
membershipAt: new Date(),
membershipRevision: 1,
},
});
});
afterAll(() => app.close());
it.each(paths)('requires authentication for /all/%s', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.get(
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
)
.expect(401);
expect(withSupportedAccounts).not.toHaveBeenCalled();
expect(upstream.getOpenAccounts).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}`)
.get(
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
)
.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' });
expect(response.body).toMatchObject({
data: { positions: [] },
meta: { availability: 'complete' },
});
else
expect(response.body).toMatchObject({
meta: { availability: 'complete' },
});
},
);
@@ -75,7 +99,9 @@ describe('All accounts HTTP boundary', () => {
'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}`)
.get(
`/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`,
)
.set('Authorization', `Bearer ${token}`)
.expect(400);
});
@@ -89,9 +115,7 @@ describe('All accounts HTTP boundary', () => {
});
it('preserves connection errors without a partial success response', async () => {
withSupportedAccounts.mockRejectedValue(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await prisma.tbankConnection.deleteMany();
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/portfolio')
.set('Authorization', `Bearer ${token}`)
+41 -4
View File
@@ -39,6 +39,41 @@ async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode
async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;}
function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});}
function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}}
// The rollback build must leave every additive T-Bank dataset and work intent intact.
function seedTbank(owner) {
const db = new Database(file);
const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision;
const now = Date.now();
const scope = {userId:owner,revision,accountId:'durable-account'};
const exact = {currency:'RUB',value:'-9007199254740993.123456789'};
const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row));
db.transaction(() => {
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now,owner);
insert('TbankAccount',{...scope,type:'BROKERAGE',name:'Synthetic persisted account',upstreamOrder:0});
insert('TbankPortfolio',{...scope,payload:JSON.stringify({positions:[],cashBalances:[],totalsByCurrency:[],structure:{currencies:[],unvaluedCategories:[]}}),fetchedAt:now});
insert('TbankOperation',{...scope,brokerOperationId:'durable-income',occurredAt:now,category:'income',payload:JSON.stringify({id:'durable-income',payment:exact})});
insert('TbankOperationCoverage',{...scope,from:now-1000,to:now+1000,fetchedAt:now,asOf:now});
insert('TbankForecast',{userId:owner,revision,scopeKey:'durable-forecast',membershipRevision:1,payload:JSON.stringify({incomeTotalsByCurrency:[exact]}),dependencyRevisions:JSON.stringify({portfolio:1}),sourceTimestamps:JSON.stringify({portfolio:new Date(now).toISOString()}),calculatedAt:now});
insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now});
insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now});
insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now});
insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now});
insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now});
insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now});
insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now});
insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})});
})();
db.close();
}
function tbankSnapshot() {
const db = new Database(file,{readonly:true});
db.defaultSafeIntegers(true);
try {
assert.equal(db.pragma('integrity_check',{simple:true}),'ok');
assert.deepEqual(db.pragma('foreign_key_check'),[]);
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()]));
} finally {db.close();}
}
(async()=>{
try{
migrate(baseline); await start(baseline);
@@ -62,6 +97,7 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204);
card=await http(`bank-cards/${card.id}`,'GET',null,token);
card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token);
seedTbank(owner); const tbankBefore=tbankSnapshot();
const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')};
await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;
assert.deepEqual(await http('bank-cards','POST',intent,token,201),card);
@@ -69,17 +105,18 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI
await http('bank-cards/operations','POST',deletedIntent,token,410);
assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);
await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
assert.deepEqual(tbankSnapshot(),tbankBefore);
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
console.log(`PASS durability ${dir}`);
}finally{await stop();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+25 -7
View File
@@ -40,10 +40,14 @@ describe('Bond HTTP contract', () => {
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
});
beforeEach(() => {
jest.clearAllMocks();
bonds.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
bonds.get.mockResolvedValue({
data: { instrumentUid: uid, name: 'Test' },
meta: { availability: 'complete' },
});
bonds.search.mockResolvedValue([]);
bonds.getByTicker.mockResolvedValue({
instrumentUid: uid,
@@ -108,14 +112,14 @@ describe('Bond HTTP contract', () => {
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toEqual(metrics);
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid);
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false);
});
it('uses verified identity and supports a direct instrument URL', async () => {
await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid);
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false);
});
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
'resolves ticker %s through the authenticated endpoint',
@@ -130,7 +134,11 @@ describe('Bond HTTP contract', () => {
instrumentUid: uid,
slug: 'SU26238RMFS4',
});
expect(bonds.getByTicker).toHaveBeenCalledWith('bond-reader', ticker);
expect(bonds.getByTicker).toHaveBeenCalledWith(
'bond-reader',
ticker,
false,
);
},
);
it('returns an explicit conflict for an ambiguous ticker', async () => {
@@ -153,13 +161,23 @@ describe('Bond HTTP contract', () => {
price: { currency: 'RUB', value: '306.12' },
priceAt: null,
};
bonds.search.mockResolvedValue([item]);
bonds.search.mockResolvedValue({
data: [item],
meta: { availability: 'complete' },
});
const response = await request(app.getHttpServer())
.get('/api/v1/bonds?query=SU26238RMFS4')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(bonds.search).toHaveBeenCalledWith('bond-reader', 'SU26238RMFS4');
expect(response.body).toEqual([item]);
expect(bonds.search).toHaveBeenCalledWith(
'bond-reader',
'SU26238RMFS4',
false,
);
expect(response.body).toEqual({
data: [item],
meta: { availability: 'complete' },
});
});
it('preserves safe not-found and upstream errors', async () => {
bonds.get.mockRejectedValueOnce(new NotFoundException());
+666
View File
@@ -0,0 +1,666 @@
{
"phase": "baseline",
"runtime": "v24.21.0",
"hardware": {
"cpu": "Apple M1 Pro",
"cpus": 10,
"memoryBytes": 34359738368
},
"profile": {
"accounts": 3,
"operationsPerAccount": 100000,
"instruments": 200,
"candlesPerInstrument": 5000,
"upstream": "synthetic immediate responses; transport limiter excluded",
"timing": "HTTP client end-to-end, includes response transfer",
"limitation": "host is not constrained to 4 CPU / 8 GiB; delayed/failing transport and filled persistent datasets remain acceptance checks",
"database": "isolated temporary SQLite with production migrations; synthetic upstream datasets not yet persisted",
"rpcCounting": "TbankClient/MOEX/NCR method invocations, excludes transport retries and limiter; not actual network RPC"
},
"evidence": [
{
"route": "/brokerage-accounts",
"cold": {
"ms": 24.586499999999887,
"bytes": 634
},
"count": 100,
"p50": 12.34679200000005,
"p95": 24.04108300000007,
"p99": 27.170749999999998,
"max": 27.204958000000033,
"maxBytes": 634,
"rpc": {
"accounts": 100,
"portfolio": 300,
"positions": 300
}
},
{
"route": "/brokerage-accounts/baseline-0/portfolio",
"cold": {
"ms": 7.224249999999984,
"bytes": 59510
},
"count": 100,
"p50": 7.004541000000245,
"p95": 11.336917000000085,
"p99": 12.390540999999985,
"max": 12.702708000000257,
"maxBytes": 59510,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0
}
},
{
"route": "/brokerage-accounts/all/portfolio",
"cold": {
"ms": 6.5479999999997744,
"bytes": 208050
},
"count": 100,
"p50": 16.86850000000004,
"p95": 28.5612500000002,
"p99": 31.27654199999961,
"max": 32.9204579999996,
"maxBytes": 208050,
"rpc": {
"accounts": 100,
"portfolio": 0,
"positions": 0,
"metadata": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50",
"cold": {
"ms": 45.70783399999982,
"bytes": 18949
},
"count": 100,
"p50": 5.6965000000000146,
"p95": 8.919000000000324,
"p99": 9.326040999999805,
"max": 9.336499999999887,
"maxBytes": 18949,
"rpc": {
"accounts": 100,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0
}
},
{
"route": "/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50",
"cold": {
"ms": 85.20100000000002,
"bytes": 21522
},
"count": 100,
"p50": 6.50545800000009,
"p95": 12.267667000000074,
"p99": 16.363749999999982,
"max": 16.446166000000176,
"maxBytes": 21522,
"rpc": {
"accounts": 100,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31",
"cold": {
"ms": 417.28716699999995,
"bytes": 111178
},
"count": 100,
"p50": 1350.6744159999998,
"p95": 2300.347584,
"p99": 2365.9105830000008,
"max": 2645.628458,
"maxBytes": 111178,
"rpc": {
"accounts": 100,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0
}
},
{
"route": "/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31",
"cold": {
"ms": 873.1202080000003,
"bytes": 318889
},
"count": 100,
"p50": 3821.4419160000034,
"p95": 7101.092166000002,
"p99": 7166.68979199999,
"max": 7346.219790999996,
"maxBytes": 318889,
"rpc": {
"accounts": 100,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 21
}
},
{
"route": "/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31",
"cold": {
"ms": 5.617958000002545,
"bytes": 54910
},
"count": 100,
"p50": 13.213250000000698,
"p95": 25.864916999998968,
"p99": 46.8791249999922,
"max": 47.04558299999917,
"maxBytes": 54910,
"rpc": {
"accounts": 100,
"portfolio": 100,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 10000
}
},
{
"route": "/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31",
"cold": {
"ms": 7.403374999994412,
"bytes": 195043
},
"count": 100,
"p50": 35.85599999999977,
"p95": 61.01170899999852,
"p99": 72.77300000000105,
"max": 78.4074580000015,
"maxBytes": 195043,
"rpc": {
"accounts": 100,
"portfolio": 300,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 30000
}
},
{
"route": "/stocks?query=SYNTH",
"cold": {
"ms": 16.416916000001947,
"bytes": 11471
},
"count": 100,
"p50": 4.549584000007599,
"p95": 6.839957999996841,
"p99": 7.346624999991036,
"max": 7.711332999999286,
"maxBytes": 11471,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0
}
},
{
"route": "/bonds?query=SYNTH",
"cold": {
"ms": 2.677958000000217,
"bytes": 19906
},
"count": 100,
"p50": 4.722166000006837,
"p95": 7.557249999998021,
"p99": 9.096749999996973,
"max": 10.10037500000908,
"maxBytes": 19906,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000",
"cold": {
"ms": 1.367707999990671,
"bytes": 322
},
"count": 100,
"p50": 3.424165999997058,
"p95": 5.021249999990687,
"p99": 5.329417000000831,
"max": 5.656291999999667,
"maxBytes": 322,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/quote",
"cold": {
"ms": 1.9067500000091968,
"bytes": 364
},
"count": 100,
"p50": 3.7470829999947455,
"p95": 8.519499999994878,
"p99": 10.102916999996523,
"max": 10.448667000004207,
"maxBytes": 364,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/company",
"cold": {
"ms": 1.2771250000077998,
"bytes": 265
},
"count": 100,
"p50": 3.6486670000012964,
"p95": 5.218625000008615,
"p99": 5.3912080000009155,
"max": 5.564749999990454,
"maxBytes": 265,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=year",
"cold": {
"ms": 2.915374999996857,
"bytes": 26672
},
"count": 100,
"p50": 4.780624999999418,
"p95": 8.190875000000233,
"p99": 10.902166999992914,
"max": 11.337415999994846,
"maxBytes": 26672,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=all",
"cold": {
"ms": 17.35724999998638,
"bytes": 389549
},
"count": 100,
"p50": 18.983874999990803,
"p95": 31.194582999989507,
"p99": 36.280375000002095,
"max": 37.303083000006154,
"maxBytes": 389549,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/dividends",
"cold": {
"ms": 7.10624999999709,
"bytes": 360
},
"count": 100,
"p50": 3.821125000002212,
"p95": 5.636291999995592,
"p99": 5.875499999994645,
"max": 6.0989580000023125,
"maxBytes": 360,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001",
"cold": {
"ms": 1.7262079999927664,
"bytes": 594
},
"count": 100,
"p50": 4.336542000004556,
"p95": 9.00204200000735,
"p99": 10.431166999987909,
"max": 11.163165999998455,
"maxBytes": 594,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/quote",
"cold": {
"ms": 1.3817500000004657,
"bytes": 394
},
"count": 100,
"p50": 4.270417000007001,
"p95": 7.741708000001381,
"p99": 9.260208000006969,
"max": 9.583583999992697,
"maxBytes": 394,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/metrics",
"cold": {
"ms": 1.5154590000020107,
"bytes": 286
},
"count": 100,
"p50": 4.241500000003725,
"p95": 7.91508399999293,
"p99": 8.770042000003741,
"max": 9.00604100000055,
"maxBytes": 286,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=year",
"cold": {
"ms": 3.042042000000947,
"bytes": 26671
},
"count": 100,
"p50": 5.453166000006604,
"p95": 10.431250000008731,
"p99": 13.670707999990555,
"max": 14.3512919999921,
"maxBytes": 26671,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=all",
"cold": {
"ms": 16.60329099999217,
"bytes": 389548
},
"count": 100,
"p50": 21.239707999993698,
"p95": 52.85929200000828,
"p99": 68.00795900000958,
"max": 74.7853749999922,
"maxBytes": 389548,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/coupons",
"cold": {
"ms": 2.7520840000070166,
"bytes": 394
},
"count": 100,
"p50": 3.7073750000126893,
"p95": 6.169125000000349,
"p99": 12.892957999996725,
"max": 13.2072499999922,
"maxBytes": 394,
"rpc": {
"accounts": 0,
"portfolio": 0,
"positions": 0,
"metadata": 0,
"operations": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"share": 0,
"findBonds": 0,
"bond": 0,
"lastPrice": 0,
"tradingStatus": 0,
"candles": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
}
],
"totalRpc": {
"accounts": 816,
"portfolio": 713,
"positions": 309,
"metadata": 400,
"operations": 33,
"dividends": 101,
"moexEvents": 40400,
"findShares": 31,
"lastPrices": 2,
"share": 35,
"findBonds": 2,
"bond": 30,
"lastPrice": 2,
"tradingStatus": 2,
"candles": 32,
"assetDescription": 1,
"fundamentals": 1,
"coupons": 1,
"moexYield": 1,
"ncrRating": 1
},
"baselineCommit": "0862d56c4e4d9dd554556c3992cc073193020ff1"
}
+831
View File
@@ -0,0 +1,831 @@
{
"phase": "persistent",
"runtime": "v24.21.0",
"hardware": {
"cpuQuota": "400000 100000",
"memoryLimit": "8589934592",
"cpu": "unknown",
"cpus": 10,
"memoryBytes": 8214851584
},
"profile": {
"accounts": 3,
"operationsPerAccount": 100000,
"instruments": 200,
"candlesPerInstrument": 5000,
"upstream": "synthetic failing upstream after population; transport limiter excluded",
"timing": "backend middleware ingress to response finish; client p95 separately",
"limitation": "Synthetic TbankClient boundary counts; no real broker credentials/network. Docker CPU quota 4 and memory limit 8 GiB; VM reports 7.65 GiB available on Apple M1 Pro SSD host. Baseline ran on the host, so compare RPC counts, not cross-host latency.",
"restarts": "Nest application/provider restart against the same SQLite; separate-process crash/restart proof belongs to durability suite"
},
"evidence": [
{
"route": "/brokerage-accounts",
"cold": {
"ms": 71.20033399999375,
"bytes": 1847
},
"count": 100,
"clientP95": 456.3885420000006,
"p50": 10.983833000005689,
"p95": 11.914583999998285,
"p99": 12.181209000002127,
"max": 17.939458000008017,
"maxBytes": 1847,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/portfolio",
"cold": {
"ms": 57.45741599999019,
"bytes": 124193
},
"count": 100,
"clientP95": 512.7660000000033,
"p50": 48.60737499999232,
"p95": 51.47862599999644,
"p99": 60.675208000000566,
"max": 120.15199999998731,
"maxBytes": 124193,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/all/portfolio",
"cold": {
"ms": 54.8744590000133,
"bytes": 273364
},
"count": 100,
"clientP95": 559.750875999991,
"p50": 51.72225000000617,
"p95": 53.97833399999945,
"p99": 58.30312500000582,
"max": 72.22691699999268,
"maxBytes": 273364,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50",
"cold": {
"ms": 12.598417000001064,
"bytes": 35183
},
"count": 100,
"clientP95": 76.05224999999336,
"p50": 6.594332999986364,
"p95": 7.303584000008414,
"p99": 7.574791000006371,
"max": 7.955499999996391,
"maxBytes": 35183,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50",
"cold": {
"ms": 14.296457999997074,
"bytes": 28194
},
"count": 100,
"clientP95": 111.77812499999709,
"p50": 10.184041000000434,
"p95": 11.09041599999182,
"p99": 11.860207999998238,
"max": 11.948709000003873,
"maxBytes": 28194,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31",
"cold": {
"ms": 63.14766599998984,
"bytes": 115083
},
"count": 100,
"clientP95": 544.346916999988,
"p50": 52.75404200000048,
"p95": 59.81816599999729,
"p99": 62.35066699999152,
"max": 70.59137499998906,
"maxBytes": 115083,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31",
"cold": {
"ms": 185.02116700001352,
"bytes": 324998
},
"count": 100,
"clientP95": 1801.453708999994,
"p50": 169.87987499999872,
"p95": 189.0762079999986,
"p99": 198.06604099999822,
"max": 243.05916600000637,
"maxBytes": 324998,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31",
"cold": {
"ms": 235.33958299999358,
"bytes": 267101
},
"count": 100,
"clientP95": 1667.5415419999918,
"p50": 160.61579200002598,
"p95": 181.49058300000615,
"p99": 191.35262499999953,
"max": 359.32620899999165,
"maxBytes": 267101,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31",
"cold": {
"ms": 210.63354200002505,
"bytes": 407858
},
"count": 100,
"clientP95": 3556.4018769999966,
"p50": 186.65695800000685,
"p95": 382.6084169999813,
"p99": 501.64795900002355,
"max": 561.9104580000276,
"maxBytes": 407858,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks?query=SYNTH",
"cold": {
"ms": 101.64125000001513,
"bytes": 34746
},
"count": 100,
"clientP95": 334.57700099999784,
"p50": 24.851041999994777,
"p95": 48.12766699999338,
"p99": 76.38724999999977,
"max": 118.02925000002142,
"maxBytes": 34746,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds?query=SYNTH",
"cold": {
"ms": 30.85858400000143,
"bytes": 42274
},
"count": 100,
"clientP95": 260.1413750000065,
"p50": 22.27804199999082,
"p95": 34.133499999996275,
"p99": 38.02966700002435,
"max": 46.303750000020955,
"maxBytes": 42274,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000",
"cold": {
"ms": 17.718416000017896,
"bytes": 1350
},
"count": 100,
"clientP95": 107.89837499998976,
"p50": 6.436707999993814,
"p95": 12.759125000011409,
"p99": 14.605541999975685,
"max": 27.046000000002095,
"maxBytes": 1350,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/quote",
"cold": {
"ms": 15.562084000004688,
"bytes": 2018
},
"count": 100,
"clientP95": 64.02249999999185,
"p50": 3.446416999999201,
"p95": 7.937167000025511,
"p99": 9.401416000007885,
"max": 15.561542000010377,
"maxBytes": 2018,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/company",
"cold": {
"ms": 26.042874999984633,
"bytes": 1672
},
"count": 100,
"clientP95": 68.60291600000346,
"p50": 3.0005000000237487,
"p95": 8.493166000000201,
"p99": 11.896458000002895,
"max": 14.47575000001234,
"maxBytes": 1672,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=year",
"cold": {
"ms": 30.428416999988258,
"bytes": 25061
},
"count": 100,
"clientP95": 426.01245799998287,
"p50": 9.576167000021087,
"p95": 19.52770900001633,
"p99": 112.54608400000143,
"max": 147.24612500000512,
"maxBytes": 25061,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=all",
"cold": {
"ms": 88.14845799998147,
"bytes": 341904
},
"count": 100,
"clientP95": 825.1791249999951,
"p50": 59.42475000000559,
"p95": 93.359375,
"p99": 124.13929199997801,
"max": 182.59262500001932,
"maxBytes": 341904,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/stocks/00000000-0000-4000-8000-000000000000/dividends",
"cold": {
"ms": 189.298208000022,
"bytes": 66776
},
"count": 100,
"clientP95": 784.9464589999989,
"p50": 60.04541699998663,
"p95": 106.37391699998989,
"p99": 196.45424999998068,
"max": 197.1254590000026,
"maxBytes": 66776,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001",
"cold": {
"ms": 12.731124999991152,
"bytes": 1619
},
"count": 100,
"clientP95": 106.46695800000452,
"p50": 3.108875000005355,
"p95": 24.462375000002794,
"p99": 28.717292000015732,
"max": 30.378624999983003,
"maxBytes": 1619,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/quote",
"cold": {
"ms": 87.12529200001154,
"bytes": 2120
},
"count": 100,
"clientP95": 117.9898749999993,
"p50": 3.311249999998836,
"p95": 20.9320000000007,
"p99": 26.06058399999165,
"max": 37.378333000000566,
"maxBytes": 2120,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/metrics",
"cold": {
"ms": 46.56666700000642,
"bytes": 3104
},
"count": 100,
"clientP95": 53.023125000006985,
"p50": 2.992292000009911,
"p95": 5.0856670000066515,
"p99": 5.71316700000898,
"max": 9.648791999992682,
"maxBytes": 3104,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=year",
"cold": {
"ms": 24.397041999996873,
"bytes": 25059
},
"count": 100,
"clientP95": 162.17016700000386,
"p50": 8.241167000000132,
"p95": 25.821332999999868,
"p99": 31.409500000008848,
"max": 43.631125000014435,
"maxBytes": 25059,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=all",
"cold": {
"ms": 81.4910830000008,
"bytes": 341902
},
"count": 100,
"clientP95": 962.9355839999916,
"p50": 56.31050000002142,
"p95": 126.877416000003,
"p99": 147.04279199999291,
"max": 187.77900000000955,
"maxBytes": 341902,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
},
{
"route": "/bonds/00000000-0000-4000-8000-000000000001/coupons",
"cold": {
"ms": 94.6564579999831,
"bytes": 66804
},
"count": 100,
"clientP95": 1000.0864169999841,
"p50": 68.95912499999395,
"p95": 139.95691700000316,
"p99": 235.2280000000028,
"max": 284.38629199998104,
"maxBytes": 66804,
"rpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
}
}
],
"totalRpc": {
"portfolio": 0,
"positions": 0,
"metadata": 0,
"share": 0,
"candles": 0,
"bond": 0,
"dividends": 0,
"moexEvents": 0,
"findShares": 0,
"lastPrices": 0,
"findBonds": 0,
"tradingStatus": 0,
"assetDescription": 0,
"fundamentals": 0,
"coupons": 0,
"moexYield": 0,
"ncrRating": 0
},
"warmupRpc": {
"portfolio": 3,
"positions": 3,
"metadata": 200,
"share": 100,
"candles": 200,
"bond": 101,
"dividends": 101,
"moexEvents": 100,
"findShares": 2,
"lastPrices": 2,
"findBonds": 2,
"tradingStatus": 2,
"assetDescription": 1,
"fundamentals": 1,
"coupons": 2,
"moexYield": 1,
"ncrRating": 1
},
"databaseBytes": 619343872,
"oldRpc": 42913,
"newRpc": 0,
"reduction": 1,
"staleResponsesMs": 1716.9662929999758,
"staleLogicalPass": {
"portfolio": 1,
"positions": 1,
"retries": 0
},
"coldImportMs": 18.279999999998836
}
+85
View File
@@ -0,0 +1,85 @@
// This process is deliberately killed by the SQLite suite at durable boundaries.
require('../e2e-env.cjs');
const { ConfigService } = require('@nestjs/config');
const { PrismaService } = require('../../dist/src/prisma/prisma.service');
const {
TbankTokenService,
} = require('../../dist/src/integrations/tbank/tbank-token.service');
const {
TbankAccessService,
} = require('../../dist/src/integrations/tbank/tbank-access.service');
const {
BrokerageOperationsRepository,
} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository');
const {
BrokerageOperationsSyncService,
} = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service');
const mode = process.argv[2];
const prisma = new PrismaService(new ConfigService(process.env));
const repository = new BrokerageOperationsRepository(prisma);
const tokens = new TbankTokenService(new ConfigService(process.env));
const stopped = new Promise(() => {});
let handle;
let pages = 0;
const checkpoint = () => process.send({ checkpoint: mode, handle });
const upstream = {
async getOperationsByCursor() {
pages += 1;
if (mode === 'between-pages' && pages === 2) {
checkpoint();
return stopped;
}
return {
hasNext: mode === 'between-pages',
nextCursor: mode === 'between-pages' ? 'discard-after-restart' : '',
items: [
{
id: 'crash-row',
date: { seconds: '1768000000' },
payment: { units: '10', nano: 1, currency: 'rub' },
},
],
};
},
};
const publish = repository.publish.bind(repository);
repository.publish = async (...args) => {
if (mode === 'before-publish') {
checkpoint();
return stopped;
}
const result = await publish(...args);
if (mode === 'after-publish') {
checkpoint();
return stopped;
}
return result;
};
const service = new BrokerageOperationsSyncService(
repository,
new TbankAccessService(prisma, tokens, upstream),
upstream,
{
event() {
process.send({ error: 'Unexpected worker persistence failure' });
},
},
);
process.on('message', () => {});
(async () => {
await prisma.$connect();
handle = await service.request(
'owner',
[
{
accountId: 'account',
from: new Date('2026-01-01Z'),
to: new Date('2026-02-01Z'),
},
],
true,
);
})().catch((error) => {
process.send({ error: error.message });
process.exitCode = 1;
});
+41 -30
View File
@@ -4,40 +4,23 @@ import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { setTimeout as delay } from 'node:timers/promises';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
describe('Portfolio structure HTTP contract', () => {
let app: INestApplication<Server>;
const context = {
token: 'fixture',
generation: 'fixture',
accounts: [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
],
};
const tbank = {
validateToken: jest.fn().mockResolvedValue([
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
{ id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' },
]),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn().mockResolvedValue(null),
getInstrumentByUid: jest.fn().mockResolvedValue(null),
};
const access = {
withConnection: (_user: string, action: (value: unknown) => unknown) =>
action(context),
withSupportedAccounts: (
_user: string,
action: (value: unknown) => unknown,
) => action(context),
withAccountForConnection: (
_context: unknown,
_id: string,
action: () => unknown,
) => action(),
};
const token = new JwtService().sign(
{ sub: 'structure-user', type: 'access' },
{ secret: process.env.JWT_SECRET },
@@ -45,17 +28,26 @@ describe('Portfolio structure HTTP contract', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue(access)
.overrideProvider(TbankClient)
.useValue(tbank)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.get(PrismaService).user.create({
data: {
id: 'structure-user',
email: 'structure@example.invalid',
passwordHash: 'fixture',
},
});
});
beforeEach(() => {
app.get(PortfolioService).invalidate('structure-user');
beforeEach(async () => {
await request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', `Bearer ${token}`)
.send({ token: 'fixture' })
.expect(200);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
@@ -70,15 +62,32 @@ describe('Portfolio structure HTTP contract', () => {
});
});
afterAll(() => app.close());
async function load(id: string) {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
for (let index = 0; index < 200; index += 1) {
if (
!(await app
.get(PrismaService)
.tbankSyncWork.count({ where: { state: 'running' } }))
)
return;
await delay(5);
}
throw new Error('Portfolio did not settle');
}
it.each(['a', 'all'])(
'serializes exact structure including cash for %s',
async (id) => {
await load(id);
const response = await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
expect((response.body as { data: unknown }).data).toMatchObject({
structure: {
unvaluedCategories: [],
currencies: [
@@ -111,11 +120,12 @@ describe('Portfolio structure HTTP contract', () => {
tbank.getPortfolio.mockResolvedValue({
positions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
});
await load('a');
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/a/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
expect((response.body as { data: unknown }).data).toMatchObject({
structure: {
unvaluedCategories: [{ type: 'BOND', count: 1 }],
currencies: [
@@ -136,11 +146,12 @@ describe('Portfolio structure HTTP contract', () => {
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
money: [{ currency: 'RUB', units: id === 'a' ? -10 : 40 }],
}));
await load('all');
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
expect((response.body as { data: unknown }).data).toMatchObject({
structure: {
currencies: [
{
+20 -6
View File
@@ -39,10 +39,14 @@ describe('Stock HTTP contract', () => {
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
});
beforeEach(() => {
jest.clearAllMocks();
stocks.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
stocks.get.mockResolvedValue({
data: { instrumentUid: uid, name: 'Test' },
meta: { availability: 'complete' },
});
stocks.search.mockResolvedValue([]);
stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' });
});
@@ -72,7 +76,7 @@ describe('Stock HTTP contract', () => {
.get(`/api/v1/stocks/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid);
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false);
});
it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])(
'resolves ticker %s through the authenticated endpoint',
@@ -87,7 +91,11 @@ describe('Stock HTTP contract', () => {
instrumentUid: uid,
slug: 'SBERP',
});
expect(stocks.getByTicker).toHaveBeenCalledWith('stock-reader', ticker);
expect(stocks.getByTicker).toHaveBeenCalledWith(
'stock-reader',
ticker,
false,
);
},
);
it('returns an explicit conflict for an ambiguous ticker', async () => {
@@ -110,13 +118,19 @@ describe('Stock HTTP contract', () => {
price: { currency: 'RUB', value: '306.12' },
priceAt: null,
};
stocks.search.mockResolvedValue([item]);
stocks.search.mockResolvedValue({
data: [item],
meta: { availability: 'complete' },
});
const response = await request(app.getHttpServer())
.get('/api/v1/stocks?query=SBER')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER');
expect(response.body).toEqual([item]);
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false);
expect(response.body).toEqual({
data: [item],
meta: { availability: 'complete' },
});
});
it('preserves safe not-found and upstream errors', async () => {
stocks.get.mockRejectedValueOnce(new NotFoundException());
+379
View File
@@ -0,0 +1,379 @@
import { setTimeout as delay } from 'node:timers/promises';
import { CandleService } from '../src/modules/investments/instruments/application/candle.service';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../src/prisma/prisma.service';
import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository';
import {
candleGaps,
type CandleRange,
} from '../src/modules/investments/instruments/domain/candle-coverage';
describe('atomic shared candle reconciliation (SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const repository = new CandleRepository(prisma);
const owner = { userId: 'owner', revision: 'revision' };
const tokens = new TbankTokenService(
new ConfigService({
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}) as never,
);
const upstream = { getCandles: jest.fn() };
const access = new TbankAccessService(prisma, tokens, upstream as never);
const instrument = {
uid: 'uid',
currency: 'rub',
first_1dayCandleDate: { seconds: String(Date.parse('2020-01-01Z') / 1000) },
};
const sources = {
ensureDetail: jest.fn().mockResolvedValue(instrument),
readMany: jest.fn().mockResolvedValue([
{
input: { uid: 'uid', instrumentType: 'share', kind: 'detail' },
instrument,
meta: {
availability: 'complete',
freshness: 'fresh',
lastSuccessfulAt: null,
resourceKey: 'detail/uid',
dataRevision: '1',
stateVersion: 'detail-v1',
sync: { id: null, state: 'idle', errorCode: null },
},
},
]),
};
const createService = () =>
new CandleService(
repository,
sources as never,
access,
upstream as never,
{ event: jest.fn() } as never,
);
async function settled() {
for (let i = 0; i < 500; i++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
return;
await delay(5);
}
throw new Error('Candle work did not settle');
}
const range = { from: new Date('2026-01-01Z'), to: new Date('2026-01-04Z') };
const candle = {
time: '2026-01-02T00:00:00.000Z',
close: '100.000000001',
isComplete: true,
};
async function reserve(ranges: CandleRange[] = [range]) {
const result = await repository.reserve(
owner,
'candles:uid',
{ uid: 'uid', ranges },
'run',
new Set(),
);
if (!result) throw new Error('Missing attempt');
return { ...owner, id: result.work.id, attemptId: result.work.attemptId };
}
beforeAll(async () => {
await prisma.user.create({
data: {
id: 'owner',
email: 'owner@example.invalid',
passwordHash: 'synthetic',
},
});
});
beforeEach(async () => {
jest.clearAllMocks();
upstream.getCandles.mockReset().mockResolvedValue([]);
await prisma.tbankConnection.deleteMany();
await prisma.tbankInstrument.deleteMany();
await prisma.tbankConnection.create({
data: {
...owner,
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: new Date(),
},
});
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'uid',
instrumentType: 'share',
metadata: {},
fetchedAt: new Date(),
},
});
});
afterAll(async () => prisma.$disconnect());
it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => {
const attempt = await reserve();
await repository.stage(
attempt,
range,
[candle, candle, { ...candle, time: range.to.toISOString() }],
new Date('2026-01-05Z'),
);
expect((await repository.read('uid', range)).data).toEqual([]);
await repository.publish(attempt, { uid: 'uid', ranges: [range] });
await prisma.$disconnect();
const read = await new CandleRepository(prisma).read('uid', range);
expect(read.data).toEqual([candle]);
expect(candleGaps(range, read.coverage, new Date('2030-01-01Z'))).toEqual(
[],
);
const emptyRange = { from: range.to, to: new Date('2026-01-06Z') };
const empty = await reserve([emptyRange]);
await repository.stage(empty, emptyRange, [], new Date('2026-01-07Z'));
await repository.publish(empty, { uid: 'uid', ranges: [emptyRange] });
const readEmpty = await repository.read('uid', emptyRange);
expect(readEmpty.data).toEqual([]);
expect(
candleGaps(emptyRange, readEmpty.coverage, new Date('2030-01-01Z')),
).toEqual([]);
});
it('keeps the entire previous snapshot and timestamps when a later refresh window fails', async () => {
const initial = await reserve();
await repository.stage(initial, range, [candle], new Date('2026-01-05Z'));
await repository.publish(initial, { uid: 'uid', ranges: [range] });
const before = await repository.read('uid', range);
const windows = [
{ from: range.from, to: new Date('2026-01-03Z') },
{ from: new Date('2026-01-03Z'), to: range.to },
];
const retry = await reserve(windows);
await repository.stage(
retry,
windows[0],
[{ ...candle, close: '2' }],
new Date('2026-01-06Z'),
);
await expect(
repository.publish(retry, { uid: 'uid', ranges: windows }),
).rejects.toThrow('Incomplete candle reconciliation');
expect(await repository.read('uid', range)).toEqual(before);
await repository.fail(retry, 'TBANK_UNAVAILABLE');
expect(await repository.read('uid', range)).toEqual(before);
expect(await prisma.tbankSyncStage.count()).toBe(0);
});
it('reconciles deleted rows atomically and preserves coverage outside the requested period', async () => {
const initial = await reserve();
await repository.stage(initial, range, [candle], new Date('2026-01-05Z'));
await repository.publish(initial, { uid: 'uid', ranges: [range] });
const middle = {
from: new Date('2026-01-02Z'),
to: new Date('2026-01-03Z'),
};
const retry = await reserve([middle]);
await repository.stage(retry, middle, [], new Date('2026-01-06Z'));
expect((await repository.read('uid', range)).data).toEqual([candle]);
await repository.publish(retry, { uid: 'uid', ranges: [middle] });
const after = await repository.read('uid', range);
expect(after.data).toEqual([]);
expect(candleGaps(range, after.coverage, new Date('2030-01-01Z'))).toEqual(
[],
);
});
it('fences superseded and deleted attempts including shared coverage timestamps', async () => {
const old = await reserve();
await repository.stage(old, range, [candle], new Date('2026-01-05Z'));
const current = await reserve();
expect(await repository.publish(old, { uid: 'uid', ranges: [range] })).toBe(
false,
);
expect(
await repository.stage(old, range, [candle], new Date('2026-01-06Z')),
).toBe(false);
await repository.stage(current, range, [candle], new Date('2026-01-06Z'));
await prisma.tbankConnection.deleteMany();
expect(
await repository.publish(current, { uid: 'uid', ranges: [range] }),
).toBe(false);
expect((await repository.read('uid', range)).coverage).toEqual([]);
});
it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => {
const service = createService();
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
let release: (value: unknown[]) => void = () => {};
const blocked = new Promise<unknown[]>((resolve) => {
release = resolve;
});
upstream.getCandles.mockReturnValueOnce(blocked);
const cold = await Promise.all(
Array.from({ length: 20 }, () => service.read('owner', input)),
);
expect(cold.every((row) => row.data === null)).toBe(true);
expect(await prisma.tbankSyncWork.count()).toBe(1);
await service.read('owner', input, true);
release([]);
await settled();
const count = upstream.getCandles.mock.calls.length;
expect(count).toBeGreaterThan(1);
for (const call of upstream.getCandles.mock.calls as [
string,
string,
Date,
Date,
][])
expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual(
365 * 86400000,
);
await prisma.$disconnect();
const reopened = createService();
expect((await reopened.read('owner', input)).data?.candles).toEqual([]);
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
await prisma.tbankCandleCoverage.updateMany({
where: { isComplete: false },
data: { fetchedAt: new Date(0) },
});
const stale = await reopened.read('owner', input, true);
expect(stale.meta.availability).toBe('complete');
expect(stale.meta.freshness).toBe('stale');
expect(stale.meta.coverage?.missingRanges).toEqual([]);
expect(stale.meta.coverage?.completeRanges.length).toBeGreaterThan(0);
expect(stale.meta.coverage?.historyState).toBe('complete');
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
await reopened.read('owner', input);
await settled();
expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1);
});
it('recovers abandoned work only on a new ordinary request', async () => {
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'month' as const,
};
const abandoned = await repository.reserve(
owner,
'candles:uid:month',
{ uid: 'uid', ranges: [] },
'old-process',
new Set(),
);
const reopened = createService();
await reopened.read('owner', input, true);
expect(upstream.getCandles).not.toHaveBeenCalled();
expect(
(await repository.work('owner', 'revision', 'candles:uid:month'))
?.attemptId,
).toBe(abandoned?.work.attemptId);
await reopened.read('owner', input);
await settled();
expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1);
expect(
(await repository.work('owner', 'revision', 'candles:uid:month'))
?.attemptId,
).not.toBe(abandoned?.work.attemptId);
});
it('retains full shown history when a forced reread fails in its second window', async () => {
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
const service = createService();
await service.read('owner', input);
await settled();
const before = await prisma.tbankCandleCoverage.findMany();
upstream.getCandles
.mockResolvedValueOnce([
{
time: { seconds: '1577923200' },
close: { units: '123', nano: 1 },
isComplete: true,
},
])
.mockRejectedValueOnce(new Error('second window unavailable'));
await service.request('owner', 'revision', input, new Date(), true);
await settled();
expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before);
expect(await prisma.tbankCandle.count()).toBe(0);
const read = await service.read('owner', input, true);
expect(read.meta.sync.state).toBe('failed');
expect(read.data?.candles).toEqual([]);
});
it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => {
const now = new Date();
now.setUTCHours(0, 0, 0, 0);
upstream.getCandles.mockResolvedValue([
{
time: { seconds: String(now.valueOf() / 1000) },
close: { units: '99', nano: 123456789 },
isComplete: false,
},
]);
const service = createService();
const input = {
uid: 'uid',
instrumentType: 'bond' as const,
period: 'month' as const,
};
await service.read('owner', input);
await settled();
const result = await service.read('owner', input, true);
expect(result.data?.currency).toBeNull();
expect(result.data?.candles).toEqual([
{ time: now.toISOString(), close: '99.123456789', isComplete: false },
]);
expect(
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
).toBe(1);
});
it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => {
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'opened',
instrumentType: 'share',
metadata: {},
fetchedAt: new Date(),
},
});
const service = createService();
let release: (value: unknown[]) => void = () => {};
const blocked = new Promise<unknown[]>((resolve) => {
release = resolve;
});
upstream.getCandles.mockReturnValueOnce(blocked);
await service.prefetch('owner', 'revision', [
{ instrumentUid: 'uid', type: 'STOCK' },
{ instrumentUid: 'uid', type: 'STOCK' },
{ instrumentUid: 'not-supported', type: 'FUND' },
{ instrumentUid: null, type: 'BOND' },
]);
for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++)
await delay(5);
expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid');
await service.read('owner', {
uid: 'opened',
instrumentType: 'share',
period: 'month',
});
release([]);
await settled();
expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe(
'opened',
);
expect(
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
).toEqual(new Set(['uid', 'opened']));
const count = upstream.getCandles.mock.calls.length;
await service.prefetch('owner', 'revision', [
{ instrumentUid: 'uid', type: 'STOCK' },
]);
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
expect(
(await repository.work('owner', 'revision', 'candles:opened:all'))?.state,
).toBe('complete');
});
});
+334
View File
@@ -0,0 +1,334 @@
import assert from 'node:assert/strict';
import { ConfigService } from '@nestjs/config';
import { setTimeout as delay } from 'node:timers/promises';
import { PrismaService } from '../src/prisma/prisma.service';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service';
import {
CatalogDiscoveryRepository,
type CatalogInstrument,
type CatalogQuery,
} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
// Real transactions prove query coverage, private work ownership and late-publication fences.
describe('shared catalog discovery (SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const tokens = new TbankTokenService(
new ConfigService({
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}) as never,
);
const upstream = { findShares: jest.fn(), findBonds: jest.fn() };
const access = new TbankAccessService(prisma, tokens, upstream as never);
const repository = new CatalogDiscoveryRepository(prisma);
const createService = () =>
new CatalogDiscoveryService(
repository,
access,
upstream as never,
{ event: jest.fn() } as never,
{ requestMany: jest.fn().mockResolvedValue([]) } as never,
);
let service: CatalogDiscoveryService;
const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' };
const instrument = {
uid: 'uid',
name: 'Synthetic share',
ticker: 'SBER',
isin: 'TEST',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
};
const seed = (userId: string) =>
prisma.tbankConnection.create({
data: {
userId,
revision: `${userId}-revision`,
encryptedToken: tokens.encrypt(`synthetic-${userId}`),
connectedAt: new Date(),
},
});
async function settled() {
for (let i = 0; i < 300; i++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
return;
await delay(5);
}
throw new Error('Catalog work did not settle');
}
beforeAll(async () => {
await prisma.$connect();
for (const id of ['owner', 'other'])
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
});
afterAll(async () => prisma.$disconnect());
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
await prisma.tbankCatalogDiscovery.deleteMany();
await prisma.tbankInstrument.deleteMany();
await seed('owner');
await seed('other');
jest.resetAllMocks();
upstream.findShares.mockResolvedValue([instrument]);
upstream.findBonds.mockResolvedValue([
{ ...instrument, uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
]);
service = createService();
});
it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => {
const cold = await service.read('owner', { ...query, query: ' sber ' });
expect(cold.data).toBeNull();
expect(cold.meta.availability).toBe('missing');
await settled();
await prisma.$disconnect();
service = createService();
const decrypt = jest.spyOn(tokens, 'decrypt');
const warm = await service.read('other', query);
expect(warm.data).toEqual([instrument]);
expect(warm.meta.availability).toBe('complete');
expect(warm.meta.sync.id).toBeNull();
expect(decrypt).not.toHaveBeenCalled();
expect(upstream.findShares).toHaveBeenCalledTimes(1);
expect(
(await service.read('other', { ...query, query: 'GAZP' }, true)).data,
).toBeNull();
upstream.findShares.mockResolvedValue([]);
await service.read('other', { ...query, query: 'GAZP' });
await settled();
expect(
(await service.read('owner', { ...query, query: 'GAZP' })).data,
).toEqual([]);
expect((await service.read('owner', query)).data).toEqual([instrument]);
expect(upstream.findShares).toHaveBeenCalledTimes(2);
decrypt.mockRestore();
await service.read('owner', { ...query, instrumentType: 'bond' });
await settled();
expect(
(await service.read('other', { ...query, instrumentType: 'bond' }))
.data?.[0].uid,
).toBe('bond');
});
it('keeps selection ordering, deduplicates before limiting, and retains identity without enrichment or private fields', async () => {
upstream.findShares.mockResolvedValue([
{ ...instrument, uid: 'unavailable', apiTradeAvailableFlag: false },
{
...instrument,
uid: 'preferred',
apiTradeAvailableFlag: true,
classCode: 'TQBR',
quantity: '999',
estimatedAmount: 'secret',
},
...Array.from({ length: 40 }, (_, i) => ({
...instrument,
uid: `other-${String(i).padStart(2, '0')}`,
isin: undefined,
ticker: 'OTHER',
})),
{
...instrument,
uid: 'wrong-kind',
instrumentKind: 'INSTRUMENT_TYPE_BOND',
},
]);
await service.read('owner', query);
await settled();
const saved = await service.read('other', query);
expect(saved.data).toHaveLength(30);
expect(saved.data?.[0].uid).toBe('preferred');
expect(
saved.data?.some((row) =>
['unavailable', 'wrong-kind'].includes(row.uid),
),
).toBe(false);
expect(
JSON.stringify(await prisma.tbankInstrumentSource.findMany()),
).not.toMatch(
/quantity|estimatedAmount|secret|owner-revision|synthetic-owner/,
);
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
});
it('does not overwrite detailed metadata or its timestamp while refreshing discovery', async () => {
const detailed = { ...instrument, sector: 'financial', lot: 10 };
const fetchedAt = new Date('2026-01-01');
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'uid',
instrumentType: 'share',
metadata: detailed,
fetchedAt,
},
});
await service.read('owner', query);
await settled();
const row = await prisma.tbankInstrument.findUniqueOrThrow({
where: { provider_uid: { provider: 'tbank', uid: 'uid' } },
});
expect(row.metadata).toEqual(detailed);
expect(row.fetchedAt).toEqual(fetchedAt);
});
it('coalesces twenty concurrent requests and forces with private handles and one upstream call', async () => {
const blocked = deferred<CatalogInstrument[]>();
upstream.findShares.mockReturnValue(blocked.promise);
const reads = await Promise.all(
Array.from({ length: 20 }, (_, i) =>
service.read(i % 2 ? 'owner' : 'other', query),
),
);
await service.request('owner', 'owner-revision', query, true);
await delay(10);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
expect(new Set(reads.map((read) => read.meta.sync.id)).size).toBe(2);
blocked.resolve([instrument]);
await settled();
expect(
await prisma.tbankSyncWork.count({ where: { state: 'complete' } }),
).toBe(2);
});
it.each(['delete', 'replace'])(
'discards late %s publication and does not borrow observer credentials',
async (action) => {
const blocked = deferred<CatalogInstrument[]>();
upstream.findShares.mockReturnValueOnce(blocked.promise);
await service.read('owner', query);
await service.read('other', query);
await delay(10);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace')
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'replacement',
encryptedToken: tokens.encrypt('replacement'),
connectedAt: new Date(),
},
});
blocked.resolve([instrument]);
await settled();
expect(await prisma.tbankCatalogDiscovery.count()).toBe(0);
expect(await prisma.tbankInstrument.count()).toBe(0);
expect(
(await service.read('other', query, true)).meta.sync.errorCode,
).toBe('TBANK_UNAVAILABLE');
expect(upstream.findShares).toHaveBeenCalledTimes(1);
await service.read('other', query);
await settled();
expect((await service.read('other', query, true)).data).toEqual([
instrument,
]);
},
);
it('preserves a successful result and its timestamp after a failed force without observe retry', async () => {
await service.read('owner', query);
await settled();
const before = await repository.read(query);
upstream.findShares.mockRejectedValue(new Error('outage'));
await service.request('owner', 'owner-revision', query, true);
await settled();
for (let i = 0; i < 5; i++) await service.read('owner', query, true);
expect(await repository.read(query)).toEqual(before);
expect((await service.read('owner', query, true)).meta.sync.state).toBe(
'failed',
);
expect(upstream.findShares).toHaveBeenCalledTimes(2);
expect((await service.read('other', query)).meta.sync.errorCode).toBeNull();
});
it('isolates invalid credentials from shared empty lookup and another user', async () => {
const blocked = deferred<CatalogInstrument[]>();
upstream.findShares.mockReturnValueOnce(blocked.promise);
await service.read('owner', query);
await service.read('other', query);
blocked.reject(new Error('INVALID_TBANK_TOKEN'));
await settled();
expect(await prisma.tbankCatalogDiscovery.count()).toBe(0);
expect(
(
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).invalidToken,
).toBe(true);
expect((await service.read('other', query, true)).meta.sync.errorCode).toBe(
'TBANK_UNAVAILABLE',
);
await service.read('other', query);
await settled();
expect((await service.read('other', query, true)).data).toEqual([
instrument,
]);
});
it('recovers abandoned work only on the next ordinary request and rejects superseded attempts', async () => {
const abandoned = await repository.reserve(
{ userId: 'owner', revision: 'owner-revision' },
query,
'old-process',
new Set(),
);
assert(abandoned);
service = createService();
await service.read('owner', query, true);
await delay(10);
expect(upstream.findShares).not.toHaveBeenCalled();
await service.read('owner', query);
await settled();
expect(
await repository.publish(
{
userId: 'owner',
revision: 'owner-revision',
id: abandoned.work.id,
attemptId: abandoned.work.attemptId,
},
query,
[],
new Date(),
),
).toBe(false);
expect((await service.read('owner', query, true)).data).toEqual([
instrument,
]);
});
it('refreshes at the daily boundary while stale observe and fresh reads perform zero RPC', async () => {
await service.read('owner', query);
await settled();
const now = Date.now();
const clock = jest.spyOn(Date, 'now').mockReturnValue(now);
try {
await prisma.tbankCatalogDiscovery.updateMany({
data: { fetchedAt: new Date(now - 86_399_000) },
});
await service.read('other', query);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
await prisma.tbankCatalogDiscovery.updateMany({
data: { fetchedAt: new Date(now - 86_400_000) },
});
expect((await service.read('other', query, true)).meta.freshness).toBe(
'stale',
);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
await service.read('other', query);
await settled();
expect(upstream.findShares).toHaveBeenCalledTimes(2);
} finally {
clock.mockRestore();
}
});
});
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
let reject: (error: Error) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
+903
View File
@@ -0,0 +1,903 @@
import { MoexClient } from '../src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
import type {
StockCandlesResourceDto,
StockCompanyResourceDto,
StockQuoteResourceDto,
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import type {
BondMetricsResourceDto,
BondQuoteResourceDto,
} from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import assert from 'node:assert/strict';
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 { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import type {
StockResourceDto,
StockSearchResourceDto,
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races.
describe('local stock/bond search (SQLite HTTP)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
const upstream = {
findShares: jest.fn(),
findBonds: jest.fn(),
getShare: jest.fn(),
getBond: jest.fn(),
getLastPrices: jest.fn(),
getOpenAccounts: jest.fn(),
getAssetDescription: jest.fn(),
getFundamentals: jest.fn(),
getTradingStatus: jest.fn(),
getCandles: jest.fn(),
getBondCoupons: jest.fn(),
};
const moex = { bondYield: jest.fn() };
const ratings = { rating: jest.fn() };
const share = {
uid: 'share',
ticker: 'SBER',
name: 'Synthetic share',
isin: 'SHARE',
classCode: 'TQBR',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
apiTradeAvailableFlag: true,
};
const bond = {
uid: 'bond',
ticker: 'BOND',
name: 'Synthetic bond',
isin: 'BOND',
classCode: 'TQOB',
instrumentKind: 'INSTRUMENT_TYPE_BOND',
};
const auth = (user = 'owner') =>
`Bearer ${new JwtService().sign({ sub: user, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const get = (path = 'stocks?query=SBER', user = 'owner') =>
request(app.getHttpServer())
.get(`/api/v1/${path}`)
.set('Authorization', auth(user))
.timeout(1500);
const read = async (observe = true, user = 'owner') =>
(
await get(
`stocks?query=SBER${observe ? '&sync=observe' : ''}`,
user,
).expect(200)
).body as StockSearchResourceDto;
async function settled() {
for (let i = 0; i < 400; 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('Catalog sources did not settle');
}
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(MoexClient)
.useValue(moex)
.overrideProvider(NcrRatingsClient)
.useValue(ratings)
.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();
await prisma.tbankInstrumentLookup.deleteMany();
await prisma.tbankInstrument.deleteMany();
await prisma.tbankCatalogDiscovery.deleteMany();
jest.resetAllMocks();
upstream.getAssetDescription.mockResolvedValue('Synthetic company');
upstream.getFundamentals.mockResolvedValue({
assetUid: 'asset',
currency: 'rub',
peRatioTtm: 6,
netIncomeTtm: 10,
});
upstream.getTradingStatus.mockResolvedValue(
'SECURITY_TRADING_STATUS_NORMAL_TRADING',
);
upstream.getCandles.mockResolvedValue([]);
upstream.getBondCoupons.mockResolvedValue([]);
moex.bondYield.mockResolvedValue(null);
ratings.rating.mockResolvedValue(null);
upstream.findShares.mockResolvedValue([share]);
upstream.findBonds.mockResolvedValue([bond]);
upstream.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
...share,
uid,
currency: 'rub',
sector: 'financial',
assetUid: 'asset',
lot: 10,
}),
);
upstream.getBond.mockResolvedValue({
...bond,
currency: 'rub',
nominal: { currency: 'rub', units: '800' },
aciValue: { currency: 'rub', units: '12', nano: 1 },
});
upstream.getLastPrices.mockImplementation(
(_token: string, uids: string[]) =>
Promise.resolve(
uids.map((uid) => ({
instrumentUid: uid,
price: { units: '99', nano: 100000001 },
time: { seconds: '1700000000' },
})),
),
);
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(),
},
});
});
afterAll(async () => app?.close());
it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => {
const found = deferred<(typeof share)[]>();
const detail = deferred<typeof share>();
upstream.findShares.mockReturnValueOnce(found.promise);
upstream.getShare.mockReturnValueOnce(detail.promise);
expect((await read()).data).toBeNull();
expect(upstream.findShares).not.toHaveBeenCalled();
const cold = await read(false);
expect(cold.data).toBeNull();
expect(cold.meta.availability).toBe('missing');
found.resolve([share]);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++)
await delay(5);
const partial = await read();
expect(partial.data?.[0].instrumentUid).toBe('share');
expect(partial.meta.availability).toBe('partial');
expect(partial.data?.[0].price).toBeNull();
expect(partial.meta.resourceKey).toBe(cold.meta.resourceKey);
expect(
partial.meta.dependencies?.some(
(dependency) => dependency.sync.state === 'running',
),
).toBe(true);
detail.resolve({ ...share });
await settled();
expect((await read()).meta.availability).toBe('complete');
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('reuses shared success after SQLite reopen and across users without decrypt, GetAccounts or broker RPC', async () => {
await read(false);
await settled();
const before = await read();
await prisma.$disconnect();
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
const after = await read(false, 'other');
expect(after.data).toEqual(before.data);
expect(after.data?.[0].price).toEqual({
currency: 'RUB',
value: '99.100000001',
});
expect(
after.meta.dependencies?.every(
(dependency) => dependency.sync.id === null,
),
).toBe(true);
expect(decrypt).not.toHaveBeenCalled();
decrypt.mockRestore();
const metadata = await app
.get(InstrumentMetadataService)
.read('other', { instrumentUid: 'share' });
expect(metadata?.complete).toBe(true);
expect(metadata?.data?.name).toBe(share.name);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('uses one price batch for thirty deduplicated selected UIDs, matching reversed prices by UID', async () => {
upstream.findShares.mockResolvedValue([
{ ...share, uid: 'duplicate', apiTradeAvailableFlag: false },
...Array.from({ length: 35 }, (_, i) => ({
...share,
uid: `s${String(i).padStart(2, '0')}`,
isin: i ? undefined : 'SHARE',
})),
]);
upstream.getLastPrices.mockImplementation(
(_token: string, uids: string[]) =>
Promise.resolve(
uids.toReversed().map((uid, i) => ({
instrumentUid: uid,
price: { units: String(i + 1) },
})),
),
);
await read(false);
await settled();
const response = await read();
expect(response.data).toHaveLength(30);
expect(response.data?.[0].instrumentUid).toBe('s00');
expect(response.data?.[0].price?.value).toBe('30');
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.getShare).toHaveBeenCalledTimes(30);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('keeps confirmed empty separate from unknown queries and does not enrich an empty result', async () => {
upstream.findShares.mockResolvedValue([]);
expect((await read(false)).data).toBeNull();
await settled();
expect((await read()).data).toEqual([]);
const other = (await get('stocks?query=GAZP&sync=observe').expect(200))
.body as StockSearchResourceDto;
expect(other.data).toBeNull();
expect(other.meta.availability).toBe('missing');
expect(upstream.getShare).not.toHaveBeenCalled();
expect(upstream.getLastPrices).not.toHaveBeenCalled();
});
it('retains catalog identity on enrichment failures, rejects mismatched detail UIDs and never returns fake prices', async () => {
upstream.getShare.mockResolvedValue({ ...share, uid: 'another' });
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await read(false);
await settled();
const result = await read();
expect(result.data?.[0]).toMatchObject({
instrumentUid: 'share',
ticker: 'SBER',
price: null,
});
expect(result.meta.availability).toBe('partial');
expect(
result.meta.dependencies?.filter((item) => item.sync.state === 'failed'),
).toHaveLength(2);
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
});
it('keeps bond percent separate from money and preserves exact nominal and ACI', async () => {
await get('bonds?query=BOND').expect(200);
await settled();
const result = (await get('bonds?query=BOND&sync=observe').expect(200))
.body as BondSearchResourceDto;
expect(result.data?.[0]).toMatchObject({
pricePercent: '99.100000001',
nominal: { currency: 'RUB', value: '800' },
aci: { currency: 'RUB', value: '12.000000001' },
});
expect(result.data?.[0]).not.toHaveProperty('price');
expect(upstream.findShares).not.toHaveBeenCalled();
});
it('refreshes only prices at the minute boundary and preserves old prices after failure', async () => {
await read(false);
await settled();
const before = await read();
await prisma.tbankInstrumentSource.updateMany({
where: { kind: 'last-price' },
data: { fetchedAt: new Date(Date.now() - 60_000) },
});
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await read();
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
await read(false);
await settled();
const failed = await read();
expect(failed.data).toEqual(before.data);
expect(failed.meta.freshness).toBe('stale');
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(2);
});
it('coalesces sources across different queries and users, with private handles and no private fields in shared rows', async () => {
const detail = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(detail.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++)
await delay(5);
await get('stocks?query=OTHER', 'other').expect(200);
for (
let i = 0;
i < 100 &&
(await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3;
i++
)
await delay(5);
detail.resolve({
...share,
currency: 'rub',
quantity: 'private',
estimatedAmount: 'private',
} as typeof share);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
const owner = await read();
const other = (await get('stocks?query=OTHER&sync=observe', 'other'))
.body as StockSearchResourceDto;
expect(owner.meta.dependencies?.at(-1)?.sync.id).not.toBe(
other.meta.dependencies?.at(-1)?.sync.id,
);
expect(
JSON.stringify(await prisma.tbankInstrumentSource.findMany()),
).not.toMatch(/quantity|estimatedAmount|private|encryptedToken/);
});
it.each(['delete', 'replace'])(
'discards delayed enrichment after connection %s',
async (action) => {
const detail = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(detail.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++)
await delay(5);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace')
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'new',
encryptedToken: app.get(TbankTokenService).encrypt('new'),
connectedAt: new Date(),
},
});
detail.resolve(share);
await settled();
expect(
await prisma.tbankInstrumentSource.count({ where: { kind: 'detail' } }),
).toBe(0);
expect((await read(true, 'other')).data?.[0].instrumentUid).toBe('share');
},
);
it('recovers abandoned enrichment only on an ordinary request, and fences the superseded attempt', async () => {
await read(false);
await settled();
await prisma.tbankInstrumentSource.deleteMany({
where: { kind: 'detail' },
});
const repository = app.get(CatalogDiscoveryRepository);
const input = {
instrumentType: 'share',
uid: 'share',
kind: 'detail',
} as const;
const old = await repository.reserve(
{ userId: 'owner', revision: 'owner-revision' },
input,
'old-process',
new Set(),
);
assert(old);
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
await read(false);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(2);
expect(
await repository.publishSource(
{
userId: 'owner',
revision: 'owner-revision',
id: old.work.id,
attemptId: old.work.attemptId,
},
input,
{ instrument: { ...share, name: 'Late' } },
new Date(),
),
).toBe(false);
});
it('preserves fresh successful sources on failed force and isolates credential failures', async () => {
await read(false);
await settled();
const before = await read();
upstream.getShare.mockRejectedValue(new Error('outage'));
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', ['share'], true);
await settled();
const after = await read();
expect(after.data).toEqual(before.data);
expect(after.meta.freshness).toBe('fresh');
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(
(await read(true, 'other')).meta.dependencies?.every(
(item) => item.sync.errorCode === null,
),
).toBe(true);
});
it('rejects every price in a batch if its initiating attempt is superseded during the RPC', async () => {
const prices =
deferred<{ instrumentUid: string; price: { units: string } }[]>();
upstream.findShares.mockResolvedValue([
share,
{ ...share, uid: 'second', isin: 'SECOND' },
]);
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++)
await delay(5);
const initiating = await prisma.tbankSyncWork.findFirstOrThrow({
where: { userId: 'owner', scopeKey: { contains: 'last-price' } },
orderBy: { createdAt: 'asc' },
});
await prisma.tbankSyncWork.update({
where: { id: initiating.id },
data: { attemptId: 'superseding-attempt', state: 'failed' },
});
prices.resolve([
{ instrumentUid: 'share', price: { units: '100' } },
{ instrumentUid: 'second', price: { units: '200' } },
]);
await settled();
expect(
await prisma.tbankInstrumentSource.count({
where: { kind: 'last-price' },
}),
).toBe(0);
expect((await read()).data?.every((item) => item.price === null)).toBe(
true,
);
});
it('refreshes stale detail independently of still-fresh prices and discovery', async () => {
await read(false);
await settled();
await prisma.tbankInstrumentSource.updateMany({
where: { kind: 'detail' },
data: { fetchedAt: new Date(Date.now() - 86_400_000) },
});
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
await read(false);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(2);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('reads exact UID locally without waiting, batches twenty reads and keeps UID accessible when alias is ambiguous', async () => {
const uid = '00000000-0000-4000-8000-000000000001';
const blocked = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(blocked.promise);
upstream.findShares.mockResolvedValue([
{ ...share, uid },
{ ...share, uid: 'other-listing', isin: 'OTHER' },
]);
expect(
((await get(`stocks/${uid}?sync=observe`)).body as StockResourceDto).data,
).toBeNull();
expect(upstream.getShare).not.toHaveBeenCalled();
const reads = await Promise.all(
Array.from({ length: 20 }, () => get(`stocks/${uid}`).expect(200)),
);
expect(
reads.every(
(response) => (response.body as StockResourceDto).data === null,
),
).toBe(true);
expect(upstream.getLastPrices).not.toHaveBeenCalled();
blocked.resolve({ ...share, uid });
await settled();
const first = (await get(`stocks/${uid}`).expect(200))
.body as StockResourceDto;
expect(first.data?.instrumentUid).toBe(uid);
await settled();
const exact = (await get(`stocks/${uid}?sync=observe`).expect(200))
.body as StockResourceDto;
expect(exact.data?.slug).toBe(uid);
await get('stocks/by-ticker/SBER?sync=observe').expect(409);
expect(upstream.getShare).toHaveBeenCalledTimes(1);
});
it('resolves exact ticker before ISIN selection and does not mistake search completeness for ticker coverage', async () => {
const uid = '00000000-0000-4000-8000-000000000002';
upstream.findShares.mockResolvedValue([
{ ...share, uid: 'preferred', ticker: 'SBERP' },
{ ...share, uid, ticker: 'SBER', apiTradeAvailableFlag: false },
]);
await read(false);
await settled();
expect(
(
(await get('stocks/by-ticker/SBER?sync=observe'))
.body as StockResourceDto
).data,
).toBeNull();
const cold = (await get('stocks/by-ticker/sber').expect(200))
.body as StockResourceDto;
expect(cold.data).toBeNull();
await settled();
const resolved = (
await get('stocks/by-ticker/SBER?sync=observe', 'other').expect(200)
).body as StockResourceDto;
expect(resolved.data).toMatchObject({ instrumentUid: uid, slug: 'SBER' });
expect(upstream.findShares).toHaveBeenCalledTimes(2);
upstream.findShares.mockResolvedValue([]);
await get('stocks/by-ticker/NONE').expect(200);
await settled();
await get('stocks/by-ticker/NONE?sync=observe').expect(404);
});
it('keeps known typed UID misses separate from unknown and from generic UID lookup', async () => {
const uid = '00000000-0000-4000-8000-000000000003';
upstream.getBond.mockResolvedValue(null);
const cold = (await get(`bonds/${uid}`).expect(200))
.body as StockResourceDto;
expect(cold.data).toBeNull();
await settled();
await get(`bonds/${uid}?sync=observe`).expect(404);
expect(
await prisma.tbankInstrumentLookup.count({ where: { identifier: uid } }),
).toBe(0);
expect(upstream.getLastPrices).not.toHaveBeenCalled();
});
it('loads company and fundamentals locally, shares successes and keeps old metrics on failed refresh', async () => {
const uid = '00000000-0000-4000-8000-000000000011';
const company = deferred<string>();
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
const cold = (await get(`stocks/${uid}/company`).expect(200))
.body as StockCompanyResourceDto;
expect(cold.data).toBeNull();
for (
let i = 0;
i < 100 && !upstream.getAssetDescription.mock.calls.length;
i++
)
await delay(5);
const partial = (
await get(`stocks/${uid}/company?sync=observe`).expect(200)
).body as StockCompanyResourceDto;
expect(partial.meta.availability).toBe('partial');
company.resolve('Synthetic company');
await settled();
const warm = (await get(`stocks/${uid}/company`, 'other').expect(200))
.body as StockCompanyResourceDto;
expect(warm.data?.description).toBe('Synthetic company');
expect(warm.data?.metrics.map((item) => item.key)).toEqual([
'peRatioTtm',
'netIncomeTtm',
]);
expect(upstream.getFundamentals).toHaveBeenCalledTimes(1);
expect(upstream.getAssetDescription).toHaveBeenCalledTimes(1);
upstream.getFundamentals.mockRejectedValue(new Error('outage'));
upstream.getAssetDescription.mockRejectedValue(new Error('outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
'description',
'fundamentals',
]);
await settled();
const failed = (await get(`stocks/${uid}/company?sync=observe`).expect(200))
.body as StockCompanyResourceDto;
expect(failed.data?.metrics).toEqual(warm.data?.metrics);
expect(failed.data?.description).toBe(warm.data?.description);
expect(failed.data?.observedAt).toBe(warm.data?.observedAt);
expect(failed.data?.unavailable).toEqual(['description', 'fundamentals']);
});
it('reads stock quotes without waiting for blocked prices and reuses quote components across users', async () => {
const uid = '00000000-0000-4000-8000-000000000012';
const prices = deferred<
{
instrumentUid: string;
price: { units: string };
time: { seconds: string };
}[]
>();
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
upstream.getCandles.mockResolvedValue([
{
time: { seconds: '1699833600' },
close: { units: '90' },
isComplete: true,
},
]);
const cold = (await get(`stocks/${uid}/quote`).expect(200))
.body as StockQuoteResourceDto;
expect(cold.meta.availability).toBe('missing');
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++)
await delay(5);
const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200))
.body as StockQuoteResourceDto;
expect(pending.data?.price).toBeNull();
prices.resolve([
{
instrumentUid: uid,
price: { units: '99' },
time: { seconds: '1700000000' },
},
]);
await settled();
const warm = (await get(`stocks/${uid}/quote`, 'other').expect(200))
.body as StockQuoteResourceDto;
expect(warm.data).toMatchObject({
price: { currency: 'RUB', value: '99' },
previousClose: { currency: 'RUB', value: '90' },
change: { currency: 'RUB', value: '9' },
changePercent: '10',
});
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.getTradingStatus).toHaveBeenCalledTimes(1);
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
expect(
await prisma.tbankCandle.count({ where: { instrumentUid: uid } }),
).toBe(1);
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
'comparison',
]);
await settled();
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
});
it('keeps bond quote money exact and coupon metrics available when optional MOEX/NCR fail', async () => {
const uid = '00000000-0000-4000-8000-000000000013';
upstream.getBond.mockResolvedValue({
...bond,
uid,
currency: 'rub',
nominal: { currency: 'rub', units: '800' },
aciValue: { currency: 'rub', units: '12', nano: 1 },
riskLevel: 'RISK_LEVEL_LOW',
});
upstream.getBondCoupons.mockResolvedValue([
{
couponDate: {
seconds: String(Math.floor(Date.now() / 1000) + 86400 * 7),
},
payOneBond: { currency: 'rub', units: '30' },
couponPeriod: 90,
},
]);
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
ratings.rating.mockRejectedValue(new Error('NCR outage'));
await get(`bonds/${uid}/metrics`).expect(200);
await settled();
const metrics = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
.body as BondMetricsResourceDto;
expect(metrics.meta.availability).toBe('partial');
expect(metrics.data).toMatchObject({
coupon: { currency: 'RUB', value: '30' },
creditRating: null,
effectiveYield: null,
});
expect(metrics.data?.couponYieldPercent).not.toBeNull();
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'BOND', 'TQOB');
expect(ratings.rating).toHaveBeenCalledWith('BOND');
const quote = (await get(`bonds/${uid}/quote?sync=observe`).expect(200))
.body as BondQuoteResourceDto;
expect(quote.data).toMatchObject({
pricePercent: '99.100000001',
cleanPrice: { currency: 'RUB', value: '792.800000008' },
dirtyPrice: { currency: 'RUB', value: '804.800000009' },
});
expect(
await prisma.tbankInstrumentSource.count({ where: { source: 'moex' } }),
).toBe(0);
expect(upstream.getBond).toHaveBeenCalledTimes(1);
});
it('serves saved candle envelopes without waiting for RPC and never runs observe work', async () => {
const uid = '00000000-0000-4000-8000-000000000017';
upstream.getShare.mockResolvedValue({
...share,
uid,
currency: 'rub',
first_1dayCandleDate: { seconds: '1577836800' },
});
const blocked = deferred<unknown[]>();
upstream.getCandles.mockReturnValueOnce(blocked.promise);
const cold = (await get(`stocks/${uid}/candles?period=all`).expect(200))
.body as StockCandlesResourceDto;
expect(cold.data).toBeNull();
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200);
blocked.resolve([]);
await settled();
const warm = (
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200)
).body as StockCandlesResourceDto;
expect(warm.data).toMatchObject({
candles: [],
currency: 'RUB',
historyFrom: '2020-01-01T00:00:00.000Z',
});
expect(warm.meta.availability).toBe('complete');
const count = upstream.getCandles.mock.calls.length;
await get(`stocks/${uid}/candles?period=all`, 'other').expect(200);
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
await get(`stocks/${uid}/candles?sync=invalid`).expect(400);
});
it('keeps unknown all-history bounds unavailable instead of confirming empty history', async () => {
const uid = '00000000-0000-4000-8000-000000000018';
await get(`stocks/${uid}/candles?period=all`).expect(200);
await settled();
const response = await get(
`stocks/${uid}/candles?period=all&sync=observe`,
).expect(503);
expect(JSON.stringify(response.body)).toContain(
'STOCK_HISTORY_START_UNAVAILABLE',
);
expect(upstream.getCandles).not.toHaveBeenCalled();
expect(await prisma.tbankCandleCoverage.count()).toBe(0);
});
it('coalesces a second user arriving while SQLite publication is pending', async () => {
const uid = '00000000-0000-4000-8000-000000000016';
const repository = app.get(CatalogDiscoveryRepository);
const original = repository.publishSource.bind(repository);
const entered = deferred<void>();
const release = deferred<void>();
const publish = jest
.spyOn(repository, 'publishSource')
.mockImplementationOnce(async (...args) => {
entered.resolve();
await release.promise;
return original(...args);
});
const service = app.get(CatalogEnrichmentService);
try {
await service.requestMany(
'owner',
'owner-revision',
'share',
[uid],
false,
['detail'],
);
await entered.promise;
await service.requestMany(
'other',
'other-revision',
'share',
[uid],
false,
['detail'],
);
release.resolve();
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
const work = await prisma.tbankSyncWork.findMany();
expect(work).toHaveLength(2);
expect(work.every((row) => row.state === 'complete')).toBe(true);
} finally {
release.resolve();
publish.mockRestore();
}
});
it('persists MOEX/NCR independently and retains their exact values after failed force', async () => {
const uid = '00000000-0000-4000-8000-000000000015';
upstream.getBond.mockResolvedValue({ ...bond, uid });
const effectiveYield = {
value: '13.123456789',
pricePercent: '99.100000001',
date: '2028-01-01',
dateType: 'MATDATE',
tradeAt: '2026-09-25T10:00:00.000Z',
};
const creditRating = {
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-09-25',
sourceUrl: 'https://ratings.ru/ratings/issues/',
observedAt: '2026-09-25T10:00:00.000Z',
};
moex.bondYield.mockResolvedValue(effectiveYield);
ratings.rating.mockResolvedValue(creditRating);
await get(`bonds/${uid}/metrics`).expect(200);
await settled();
const before = (await get(`bonds/${uid}/metrics`, 'other').expect(200))
.body as BondMetricsResourceDto;
expect(before.data).toMatchObject({ effectiveYield, creditRating });
expect(moex.bondYield).toHaveBeenCalledTimes(1);
expect(ratings.rating).toHaveBeenCalledTimes(1);
const saved = await prisma.tbankInstrumentSource.findMany({
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
});
expect(saved).toHaveLength(2);
await prisma.$disconnect();
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
ratings.rating.mockRejectedValue(new Error('NCR outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'bond', [uid], true, [
'yield',
'rating',
]);
await settled();
const after = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
.body as BondMetricsResourceDto;
expect(after.data).toMatchObject({ effectiveYield, creditRating });
expect(
await prisma.tbankInstrumentSource.findMany({
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
}),
).toEqual(saved);
});
it('discards a company response from a deleted connection and does not disclose its failure to another user', async () => {
const uid = '00000000-0000-4000-8000-000000000014';
const company = deferred<string>();
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
await get(`stocks/${uid}/company`).expect(200);
for (
let i = 0;
i < 100 && !upstream.getAssetDescription.mock.calls.length;
i++
)
await delay(5);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
company.resolve('Late company');
await settled();
expect(
await prisma.tbankInstrumentSource.count({
where: { kind: 'description' },
}),
).toBe(0);
const other = (
await get(`stocks/${uid}/company?sync=observe`, 'other').expect(200)
).body as StockCompanyResourceDto;
expect(
other.meta.dependencies?.every((item) => item.sync.errorCode === null),
).toBe(true);
});
it('enforces access and query validation without broker calls', async () => {
await request(app.getHttpServer())
.get('/api/v1/stocks?query=SBER')
.expect(401);
await get('stocks?query=x').expect(400);
await get('bonds?query=BOND&sync=force').expect(400);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await get().expect(409);
await prisma.tbankConnection.update({
where: { userId: 'other' },
data: { invalidToken: true },
});
await get('stocks?query=SBER', 'other').expect(400);
expect(upstream.findShares).not.toHaveBeenCalled();
expect(upstream.findBonds).not.toHaveBeenCalled();
});
});
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
+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);
});
});
+272
View File
@@ -0,0 +1,272 @@
import assert from 'node:assert/strict';
import { INestApplication } 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 { moscowDate } from '../src/modules/investments/investment-calendar';
import {
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto,
} from '../src/modules/investments/income/income-analytics.dto';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
// Financial proof uses real persisted rows/coverage and the configured HTTP boundary.
describe('local income completeness and exact money (SQLite)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
let revision: string;
const upstream = {
validateToken: jest.fn(),
getOpenAccounts: jest.fn(),
getOperationsByCursor: jest.fn(),
};
const from = new Date('2024-12-31T21:00:00Z');
const to = new Date('2025-01-31T21:00:00Z');
const authorization = `Bearer ${new JwtService().sign({ sub: 'income-owner', type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const get = (
account = 'all',
period = 'from=2025-01-01&to=2025-01-31',
observe = true,
) =>
request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`,
)
.set('Authorization', authorization)
.timeout(2000);
const read = async (account = 'all', period?: string) =>
(await get(account, period).expect(200)).body as IncomeAnalyticsResourceDto;
const coverage = async (accountId: string, start = from, end = to) =>
prisma.tbankOperationCoverage.create({
data: {
userId: 'income-owner',
revision,
accountId,
from: start,
to: end,
asOf: end,
fetchedAt: new Date('2025-02-01Z'),
},
});
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);
await prisma.user.create({
data: {
id: 'income-owner',
email: 'income@example.invalid',
passwordHash: 'synthetic',
},
});
});
afterAll(async () => app?.close());
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
jest.clearAllMocks();
upstream.validateToken.mockResolvedValue(
['a', 'b'].map((id) => ({
id,
name: id,
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: '1577836800' },
})),
);
upstream.getOperationsByCursor.mockImplementation(
() => new Promise(() => undefined),
);
await request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', authorization)
.send({ token: 'synthetic' })
.expect(200);
revision = (
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'income-owner' },
})
).revision;
for (const accountId of ['a', 'b']) {
await coverage(accountId);
for (const [id, units, nano, currency, type] of [
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'],
] as const)
await prisma.tbankOperation.create({
data: {
userId: 'income-owner',
revision,
accountId,
brokerOperationId: id,
category: 'income',
occurredAt: new Date('2025-01-15Z'),
payload: {
id,
type,
date: { seconds: String(Date.parse('2025-01-15Z') / 1000) },
payment: { units, nano, currency },
},
},
});
}
});
it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => {
for (let index = 0; index < 2; index++) {
const result = await read();
assert(result.data);
expect(result.meta.availability).toBe('complete');
expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([
{ currency: 'RUB', value: '15.000000002' },
{ currency: 'USD', value: '6' },
]);
expect(result.data.entries).toHaveLength(6);
expect(
result.data.entries
.filter((row) => row.id === 'same')
.map((row) => row.account?.id),
).toEqual(['a', 'b']);
expect(result.data.previous).toBeNull();
expect(result.data.yearComparison).toBeNull();
expect(result.meta.dependencies?.[1].availability).toBe('missing');
expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
await prisma.$disconnect();
}
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('requires every account and separates missing, partial and confirmed empty periods', async () => {
await prisma.tbankOperationCoverage.deleteMany({
where: { accountId: 'b' },
});
expect((await read()).data).toBeNull();
expect((await read()).meta.availability).toBe('partial');
expect((await read('a')).data).not.toBeNull();
expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull();
await coverage(
'a',
new Date('2025-02-28T21:00:00Z'),
new Date('2025-03-31T21:00:00Z'),
);
expect(
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
).toEqual([]);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('completes previous/yearAgo independently and counts corrected identities once', async () => {
for (const account of ['a', 'b']) {
await coverage(account, new Date('2024-11-30T21:00:00Z'), from);
await coverage(
account,
new Date('2023-12-31T21:00:00Z'),
new Date('2024-01-31T21:00:00Z'),
);
}
await prisma.tbankOperation.updateMany({
where: { accountId: 'a', brokerOperationId: 'same' },
data: {
payload: {
id: 'same',
type: 'OPERATION_TYPE_COUPON',
date: { seconds: String(Date.parse('2025-01-15Z') / 1000) },
payment: { units: '20', nano: 1, currency: 'rub' },
},
},
});
const result = await read();
assert(result.data?.previous && result.data.yearComparison);
expect(result.data.entries).toHaveLength(6);
expect(
result.data.previous.totalsByCurrency[0].difference.total.value,
).toBe('25.000000002');
expect(
result.data.yearComparison.totalsByCurrency[0].compared.total.value,
).toBe('0');
});
it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => {
const before = await read();
const repo = app.get(BrokerageOperationsRepository);
const attempt = await repo.request(
'income-owner',
revision,
['a', 'b'].map((accountId) => ({ accountId, from, to })),
'test',
true,
new Set(),
1,
);
assert(attempt);
const fence = {
id: attempt.work.id,
attemptId: attempt.work.attemptId,
userId: 'income-owner',
revision,
};
const pending = await repo.pending(fence);
assert(pending);
await repo.stagePage(fence, pending.intervals[0], undefined, {
hasNext: true,
nextCursor: 'unfinished',
items: [
{
id: 'new',
type: 'OPERATION_TYPE_COUPON',
date: { seconds: String(pending.intervals[0].from.valueOf() / 1000) },
payment: { currency: 'rub', units: '999' },
},
],
});
expect((await read()).data).toEqual(before.data);
await repo.fail(fence, false);
const after = await read();
expect(after.data).toEqual(before.data);
expect(after.meta.sync.state).toBe('failed');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('retains current-month observed completeness and does not renew source timestamps', async () => {
const now = new Date();
const today = moscowDate(now);
const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`);
const asOf = new Date(now.valueOf() - 10_000);
for (const accountId of ['a', 'b']) {
await coverage(accountId, start, asOf);
}
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/income-summary?sync=observe')
.set('Authorization', authorization)
.expect(200);
const result = response.body as IncomeSummaryResourceDto;
expect(result.data?.period).toEqual({
from: `${today.slice(0, 7)}-01`,
to: today,
});
expect(result.meta.availability).toBe('complete');
expect(result.meta.coverage?.asOf).toBe(asOf.toISOString());
expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => {
await get('absent', undefined, false).expect(404);
await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
const response = await get('all', undefined, false).expect(200);
expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull();
});
it('distinguishes unavailable calendar comparison without scheduling it', async () => {
const result = await read('a', 'from=2024-02-29&to=2024-02-29');
expect(result.meta.dependencies?.at(-1)?.unavailableReason).toBe(
'CALENDAR_OR_ACCOUNT_HISTORY',
);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
});
+342
View File
@@ -0,0 +1,342 @@
import assert from 'node:assert/strict';
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 { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
const uid = '11111111-1111-4111-8111-111111111111';
const period = 'from=2025-01-01&to=2025-01-31';
describe('persistent ledger HTTP and cursor (SQLite)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
const upstream = {
validateToken: jest.fn(),
getOpenAccounts: jest.fn(),
getOperationsByCursor: jest.fn(),
};
const auth = (owner = 'owner') =>
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const get = (query = '', account = 'all', owner = 'owner') =>
request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`,
)
.set('Authorization', auth(owner))
.timeout(2000);
const put = (owner = 'owner') =>
request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', auth(owner))
.send({ token: 'synthetic' });
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' },
});
});
afterAll(async () => app?.close());
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
jest.clearAllMocks();
upstream.validateToken.mockResolvedValue(
['a', 'b'].map((id) => ({
id,
name: id,
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: '1577836800' },
})),
);
upstream.getOperationsByCursor
.mockReset()
.mockResolvedValue({ hasNext: false, items: [] });
await put().expect(200);
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
for (const accountId of ['a', 'b']) {
await prisma.tbankOperationCoverage.create({
data: {
userId: 'owner',
revision,
accountId,
from: new Date('2020-01-01Z'),
to: new Date(),
asOf: new Date(),
fetchedAt: new Date(),
},
});
for (const id of ['1', '2', '3'])
await prisma.tbankOperation.create({
data: {
userId: 'owner',
revision,
accountId,
brokerOperationId: id,
occurredAt: new Date('2025-01-02Z'),
category: id === '3' ? 'trade' : 'income',
instrumentUid: uid,
payload: {
id,
instrumentUid: uid,
date: { seconds: '1735776000' },
type: id === '3' ? 'OPERATION_TYPE_BUY' : 'OPERATION_TYPE_COUPON',
payment: { currency: 'rub', units: '1', nano: 1 },
},
},
});
}
});
it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => {
let cursor: string | null = null;
const ids: string[] = [];
do {
const response = await get(
`limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
).expect(200);
const result = response.body as BrokerageOperationsResourceDto;
expect(result.meta.availability).toBe('complete');
assert(result.data);
ids.push(
...result.data.items.map((item) => `${item.account?.id}/${item.id}`),
);
expect(result.data.items[0].payment).toEqual({
currency: 'RUB',
value: '1.000000001',
});
cursor = result.data.nextCursor;
if (cursor) await prisma.$disconnect();
} while (cursor);
expect(ids).toEqual(['a/1', 'a/2', 'a/3', 'b/1', 'b/2', 'b/3']);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it.each(['default', 'today', 'today timestamp'])(
'keeps observed current coverage complete through %s period pagination without RPC',
async (periodKind) => {
const observedAt = new Date(Date.now() - 1000);
await prisma.tbankOperationCoverage.updateMany({
data: {
to: observedAt,
asOf: observedAt,
fetchedAt: observedAt,
},
});
await prisma.tbankOperation.updateMany({
data: { occurredAt: new Date(observedAt.valueOf() - 1000) },
});
const query =
periodKind === 'default'
? ''
: `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
const page = (cursor?: string, observe = false) =>
request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
)
.set('Authorization', auth());
const first = (await page().expect(200))
.body as BrokerageOperationsResourceDto;
expect(first.meta.availability).toBe('complete');
assert(first.data?.nextCursor);
const second = (await page(first.data.nextCursor).expect(200))
.body as BrokerageOperationsResourceDto;
expect(second.meta.availability).toBe('complete');
expect(second.meta.coverage?.missingRanges).toEqual([]);
const observed = (await page(first.data.nextCursor, true).expect(200))
.body as BrokerageOperationsResourceDto;
expect(observed.meta.availability).toBe('complete');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
},
);
it('keeps status versions stable between observational reads of a moving period', async () => {
const observe = () =>
request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
.set('Authorization', auth());
const first = (await observe().expect(200))
.body as BrokerageOperationsResourceDto;
await delay(20);
const second = (await observe().expect(200))
.body as BrokerageOperationsResourceDto;
expect(second.meta.coverage?.requested).not.toEqual(
first.meta.coverage?.requested,
);
expect(second.meta.stateVersion).toBe(first.meta.stateVersion);
await prisma.tbankOperationCoverage.updateMany({
data: {
to: new Date(Date.now() - 60_000),
asOf: new Date(Date.now() - 60_000),
},
});
const changed = (await observe().expect(200))
.body as BrokerageOperationsResourceDto;
expect(changed.meta.stateVersion).not.toBe(second.meta.stateVersion);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('reads shared metadata from SQLite and changes stateVersion without invalidating a row cursor', async () => {
const before = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto;
assert(before.data?.nextCursor);
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const metadata = new InstrumentMetadataRepository(prisma);
const reservation = await metadata.reserve(
{ userId: 'owner', revision },
{ instrumentUid: uid },
'metadata-test',
new Set(),
);
assert(reservation);
await metadata.publish(
{
userId: 'owner',
revision,
id: reservation.work.id,
attemptId: reservation.work.attemptId,
},
[
{
type: 'uid',
id: uid,
data: { uid, name: 'Synthetic', brand: { logoName: 'saved.png' } },
},
],
new Date(),
);
await prisma.$disconnect();
const after = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto;
expect(after.data?.items[0].logoName).toBe('saved.png');
expect(after.meta.stateVersion).not.toBe(before.meta.stateVersion);
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
await get(
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
).expect(200);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => {
const first = (
await get('limit=1&categories=trade,income&sync=observe').expect(200)
).body as BrokerageOperationsResourceDto;
assert(first.data?.nextCursor);
const cursor = encodeURIComponent(first.data.nextCursor);
const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`;
await prisma.tbankOperationCoverage.updateMany({
data: { fetchedAt: new Date(0) },
});
await get(next).expect(200);
await get(next.replace('limit=1', 'limit=2')).expect(400);
await get(`${next}x`).expect(400);
await put('other').expect(200);
await get(next, 'all', 'other').expect(400);
await prisma.tbankAccount.updateMany({
where: { userId: 'owner', accountId: 'b' },
data: { operationsRevision: { increment: 1 } },
});
await get(next).expect(400);
const single = (await get('limit=1&sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto;
assert(single.data?.nextCursor);
const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`;
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { membershipRevision: { increment: 1 } },
});
await get(singleNext, 'a').expect(200);
await put().expect(200);
await get(singleNext, 'a').expect(400);
});
it('does not confuse pagination/categories/instrument subset with interval completeness', async () => {
await prisma.tbankOperationCoverage.deleteMany();
const { revision } = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankOperationCoverage.create({
data: {
userId: 'owner',
revision,
accountId: 'a',
instrumentPredicate: uid,
from: new Date('2025-01-01Z'),
to: new Date('2025-01-04Z'),
asOf: new Date('2025-01-04Z'),
fetchedAt: new Date(),
},
});
const partial = (
await get(
`instrumentUid=${uid}&categories=income&limit=100&sync=observe`,
'a',
).expect(200)
).body as BrokerageOperationsResourceDto;
expect(partial.meta.availability).toBe('partial');
expect(partial.data?.hasNext).toBe(false);
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
const whole = (await get('sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto;
expect(whole.meta.availability).toBe('missing');
expect(whole.data).toBeNull();
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('keeps an unknown history boundary explicit even when the shown interval is complete', async () => {
await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
const result = (await get('sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto;
expect(result.meta.availability).toBe('complete');
expect(result.meta.coverage?.historyBoundary).toBe('unknown');
expect(result.meta.coverage?.historyState).toBe('partial');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('returns cold missing while upstream is blocked, then confirms an empty interval; observe never retries failure', async () => {
await prisma.tbankOperation.deleteMany();
await prisma.tbankOperationCoverage.deleteMany();
let unblock: (value: { hasNext: boolean; items: never[] }) => void = () => {
throw new Error('not initialized');
};
upstream.getOperationsByCursor
.mockReturnValueOnce(
new Promise((resolve) => {
unblock = resolve;
}),
)
.mockRejectedValue(new Error('synthetic outage'));
const cold = (await get('', 'a').expect(200))
.body as BrokerageOperationsResourceDto;
expect(cold.data).toBeNull();
expect(cold.meta.availability).toBe('missing');
await get('sync=observe', 'a').expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
unblock({ hasNext: false, items: [] });
for (let index = 0; index < 300; index += 1) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
break;
await delay(5);
}
const count = upstream.getOperationsByCursor.mock.calls.length;
const result = (await get('sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto;
expect(result.data?.items).toEqual([]);
expect(result.meta.availability).toBe('complete');
await get('sync=observe', 'a').expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(count);
});
});
+441
View File
@@ -0,0 +1,441 @@
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);
});
});
+392
View File
@@ -0,0 +1,392 @@
import assert from 'node:assert/strict';
import { ConfigService } from '@nestjs/config';
import { setTimeout as delay } from 'node:timers/promises';
import { PrismaService } from '../src/prisma/prisma.service';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import {
InstrumentMetadataRepository,
metadataKey,
} from '../src/integrations/tbank/instrument-metadata.repository';
import type { TbankInstrument } from '../src/integrations/tbank/tbank.client';
// Shared publication is proven against real FK/transaction/connection boundaries.
describe('durable shared instrument metadata (SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const tokens = new TbankTokenService(
new ConfigService({
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}) as never,
);
const upstream = {
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const access = new TbankAccessService(prisma, tokens, upstream as never);
const repository = new InstrumentMetadataRepository(prisma);
const logger = { event: jest.fn() };
const createService = () =>
new InstrumentMetadataService(
upstream as never,
logger as never,
repository,
access,
);
let service: InstrumentMetadataService;
const identity = { instrumentUid: 'uid', figi: 'FIGI' };
const known = {
uid: 'uid',
figi: 'FIGI',
ticker: 'TEST',
name: 'Synthetic instrument',
brand: { logoName: 'test.png' },
};
const seedConnection = (userId: string) =>
prisma.tbankConnection.create({
data: {
userId,
revision: `${userId}-revision`,
encryptedToken: tokens.encrypt(`synthetic-${userId}`),
connectedAt: new Date(),
},
});
async function settled() {
for (let index = 0; index < 300; index++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
return;
await delay(5);
}
throw new Error('Metadata work did not settle');
}
beforeAll(async () => {
await prisma.$connect();
for (const id of ['owner', 'other'])
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
});
afterAll(async () => prisma.$disconnect());
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
await prisma.tbankInstrumentLookup.deleteMany();
await prisma.tbankInstrument.deleteMany();
await seedConnection('owner');
await seedConnection('other');
jest.resetAllMocks();
upstream.getInstrumentByUid.mockResolvedValue(known);
upstream.getInstrumentByFigi.mockResolvedValue(known);
service = createService();
});
it('reuses durable successful metadata across users and reopen without decrypt or RPC', async () => {
const cold = await service.read('owner', identity);
expect(cold?.data).toBeNull();
expect(cold?.work?.state).toBe('running');
await settled();
await prisma.$disconnect();
service = createService();
const decrypt = jest.spyOn(tokens, 'decrypt');
const other = await service.read('other', identity);
expect(other?.data).toEqual(known);
expect(other?.fresh).toBe(true);
expect(other?.work).toBeNull();
expect((await service.read('other', { figi: 'FIGI' }))?.data).toEqual(
known,
);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
expect(decrypt).not.toHaveBeenCalled();
decrypt.mockRestore();
const rows = await prisma.tbankInstrument.findMany();
expect(JSON.stringify(rows)).not.toMatch(
/synthetic-owner|owner-revision|quantity|estimatedAmount/,
);
});
it('persists UID miss separately from successful FIGI fallback and confirmed empty lookups', async () => {
upstream.getInstrumentByUid.mockResolvedValue(null);
await service.read('owner', identity);
await settled();
const result = await service.read('other', identity);
expect(result?.data).toEqual(known);
expect(result?.complete).toBe(true);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(1);
upstream.getInstrumentByFigi.mockResolvedValue(null);
const missing = { figi: 'UNKNOWN' };
await service.read('owner', missing);
await settled();
const empty = await service.read('other', missing);
expect(empty?.data).toBeNull();
expect(empty?.complete).toBe(true);
expect(empty?.fresh).toBe(true);
expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(2);
});
it('coalesces twenty requests across users while giving each user a private handle', async () => {
const blocked = deferred<TbankInstrument>();
upstream.getInstrumentByUid.mockReturnValue(blocked.promise);
const responses = await Promise.all(
Array.from({ length: 20 }, (_, i) =>
service.read(i % 2 ? 'owner' : 'other', identity),
),
);
await delay(10);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
const ids = new Set(responses.map((response) => response?.work?.id));
expect(ids.size).toBe(2);
expect(responses[0]?.work?.id).not.toBe(responses[1]?.work?.id);
blocked.resolve(known);
await settled();
expect(await prisma.tbankInstrument.count()).toBe(1);
expect(
await prisma.tbankSyncWork.count({ where: { state: 'complete' } }),
).toBe(2);
});
it.each(['delete', 'replace'])(
'discards a producer after %s, including shared freshness, without borrowing another user token',
async (action) => {
const blocked = deferred<TbankInstrument>();
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
await service.read('owner', identity);
await delay(10);
await service.read('other', identity);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace')
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'replacement',
encryptedToken: tokens.encrypt('replacement-token'),
connectedAt: new Date(),
},
});
blocked.resolve(known);
await settled();
expect(await prisma.tbankInstrument.count()).toBe(0);
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
expect(
(await service.read('other', identity, true))?.work?.errorCode,
).toBe('TBANK_UNAVAILABLE');
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
await service.read('other', identity);
await settled();
expect((await service.read('other', identity, true))?.data).toEqual(
known,
);
expect(upstream.getInstrumentByUid).toHaveBeenLastCalledWith(
'synthetic-other',
'uid',
);
},
);
it('does not turn initiating credentials failure into a shared negative lookup or a foreign credentials error', async () => {
const blocked = deferred<TbankInstrument>();
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
await service.read('owner', identity);
await delay(10);
await service.read('other', identity);
blocked.reject(new Error('INVALID_TBANK_TOKEN'));
await settled();
await expect(service.read('owner', identity, true)).rejects.toMatchObject({
response: { code: 'INVALID_TBANK_TOKEN' },
});
expect((await service.read('other', identity, true))?.work?.errorCode).toBe(
'TBANK_UNAVAILABLE',
);
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
await service.read('other', identity);
await settled();
expect((await service.read('other', identity, true))?.data).toEqual(known);
});
it('keeps last good metadata and source time after refresh failure; stale observation does not restart work', async () => {
await service.read('owner', identity);
await settled();
const old = new Date(Date.now() - 86_400_000);
await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } });
await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } });
const calls = upstream.getInstrumentByUid.mock.calls.length;
expect((await service.read('owner', identity, true))?.fresh).toBe(false);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls);
upstream.getInstrumentByUid.mockRejectedValueOnce(
new Error('upstream unavailable'),
);
upstream.getInstrumentByFigi.mockRejectedValueOnce(
new Error('upstream unavailable'),
);
await service.read('owner', identity);
await settled();
const failed = await service.read('owner', identity, true);
expect(failed?.data).toEqual(known);
expect(failed?.fetchedAt).toEqual(old);
expect(failed?.work?.state).toBe('failed');
await delay(20);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1);
});
it('coalesces forced refresh and retains freshness/timestamp when it fails before the TTL', async () => {
await service.read('owner', identity);
await settled();
const before = await service.read('owner', identity, true);
const blocked = deferred<TbankInstrument>();
upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise);
upstream.getInstrumentByFigi.mockRejectedValueOnce(
new Error('also unavailable'),
);
const handles = await Promise.all(
Array.from({ length: 20 }, () =>
service.request('owner', 'owner-revision', identity, true),
),
);
expect(new Set(handles).size).toBe(1);
blocked.reject(new Error('unavailable'));
await settled();
const after = await service.read('owner', identity, true);
expect(after?.data).toEqual(before?.data);
expect(after?.fetchedAt).toEqual(before?.fetchedAt);
expect(after?.fresh).toBe(true);
expect(after?.work?.state).toBe('failed');
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2);
});
it('uses the exact daily TTL boundary and does not count another instrument as covered', async () => {
await service.read('owner', identity);
await settled();
const timestamp = new Date('2026-09-01Z');
await prisma.tbankInstrumentLookup.updateMany({
data: { fetchedAt: timestamp },
});
const clock = jest
.spyOn(Date, 'now')
.mockReturnValue(timestamp.valueOf() + 86_400_000 - 1);
try {
expect((await service.read('owner', identity, true))?.fresh).toBe(true);
clock.mockReturnValue(timestamp.valueOf() + 86_400_000);
expect((await service.read('owner', identity, true))?.fresh).toBe(false);
expect(
(await service.read('owner', { instrumentUid: 'other-uid' }, true))
?.complete,
).toBe(false);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
} finally {
clock.mockRestore();
}
});
it('recovers abandoned work only on a new request after reopen, with a new attempt', async () => {
const old = await repository.reserve(
{ userId: 'owner', revision: 'owner-revision' },
identity,
'previous-process',
new Set(),
);
assert(old);
await prisma.$disconnect();
service = createService();
await service.read('owner', identity, true);
await delay(10);
expect(upstream.getInstrumentByUid).not.toHaveBeenCalled();
await service.read('owner', identity);
await settled();
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: old.work.id },
});
expect(work.attemptId).not.toBe(old.work.attemptId);
expect(work.state).toBe('complete');
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('rejects publication by a superseded attempt without changing payload or successful timestamp', async () => {
const owner = { userId: 'owner', revision: 'owner-revision' };
const old = await repository.reserve(owner, identity, 'old', new Set());
assert(old);
const current = await repository.reserve(
owner,
identity,
'current',
new Set(),
);
assert(current);
const fence = (work: typeof old.work) => ({
...owner,
id: work.id,
attemptId: work.attemptId,
});
expect(
await repository.publish(
fence(current.work),
[{ type: 'uid', id: 'uid', data: known }],
new Date(1000),
),
).toBe(true);
expect(
await repository.publish(
fence(old.work),
[{ type: 'uid', id: 'uid', data: { ...known, name: 'late' } }],
new Date(2000),
),
).toBe(false);
const saved = (await repository.readMany([identity])).get(
metadataKey(identity),
);
expect(saved?.data).toEqual(known);
expect(saved?.fetchedAt).toEqual(new Date(1000));
});
it('uses a verified FIGI fallback after transient UID failure without persisting a false UID miss', async () => {
upstream.getInstrumentByUid.mockRejectedValueOnce(
new Error('temporarily unavailable'),
);
await service.read('owner', identity);
await settled();
const result = await service.read('other', identity, true);
expect(result?.data).toEqual(known);
expect(result?.complete).toBe(true);
const lookup = await prisma.tbankInstrumentLookup.findFirstOrThrow({
where: { identifierType: 'uid', identifier: 'uid' },
});
expect(lookup.matchedUids).toEqual(['uid']);
});
it('reads a full 1000-row ledger metadata batch without SQLite expression-depth overflow', async () => {
await service.read('owner', identity);
await settled();
const identities = Array.from({ length: 1000 }, (_, index) =>
index
? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` }
: identity,
);
const saved = await service.savedMany(identities);
expect(saved.size).toBe(1000);
expect(saved.get(metadataKey(identity))?.data).toEqual(known);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('does not cache a sparse malformed UID result as complete metadata', async () => {
upstream.getInstrumentByUid.mockResolvedValue({ uid: 'uid', name: ' ' });
await service.read('owner', { instrumentUid: 'uid' });
await settled();
const result = await service.read('owner', { instrumentUid: 'uid' }, true);
expect(result?.complete).toBe(false);
expect(result?.work?.state).toBe('failed');
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
});
it('does not fabricate a provider UID from a malformed FIGI response', async () => {
upstream.getInstrumentByFigi.mockResolvedValue({
name: 'No provider identity',
});
await service.read('owner', { figi: 'FIGI' });
await settled();
expect(await prisma.tbankInstrument.count()).toBe(0);
expect(await prisma.tbankInstrumentLookup.count()).toBe(0);
expect(
(await service.read('owner', { figi: 'FIGI' }, true))?.work?.state,
).toBe('failed');
});
});
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
let reject: (error: Error) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
+253
View File
@@ -0,0 +1,253 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../generated/prisma/client';
const migration = '20260924170000_persist_tbank_data';
const migrations = join(__dirname, '../prisma/migrations');
const preserved = [
'User',
'Session',
'BankCard',
'ExpenseCategory',
'BankCardOperation',
] as const;
describe('T-Bank additive migration on real SQLite', () => {
it.each([false, true])(
'preserves existing data (populated=%s), keys and decimal strings after reopen',
async (populated) => {
const directory = process.env.WL_E2E_DIRECTORY;
if (!directory) throw new Error('Isolated SQLite runner required');
const file = join(directory, `tbank-migration-${populated}.db`);
let db = new DatabaseSync(file);
db.exec('PRAGMA foreign_keys=ON');
for (const name of readdirSync(migrations)
.filter((name) => name < migration && /^\d/.test(name))
.sort())
db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8'));
if (populated) {
db.exec(`
INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES
('owner','owner@example.invalid','synthetic',0,0),
('other','other@example.invalid','synthetic',0,0);
INSERT INTO Session (id,userId,refreshTokenHash,expiresAt,createdAt,updatedAt)
VALUES ('session','owner','synthetic-hash',9000000000000,0,0);
INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES
('owner','synthetic-ciphertext-preserve-exactly',0),
('other','different-synthetic-ciphertext',0);
INSERT INTO BankCard (id,userId,name,openingBalanceKopecks,openingDate,createFingerprint,createdAt,updatedAt)
VALUES ('card','owner','Synthetic',9007199254740993,'2026-01-01','card-intent',0,0);
INSERT INTO ExpenseCategory (id,userId,name,nameKey) VALUES ('category','owner','Synthetic','synthetic');
INSERT INTO BankCardOperation (id,userId,kind,cardId,categoryId,amountKopecks,date,createFingerprint,createdAt,updatedAt)
VALUES ('operation','owner','expense','card','category',12345,'2026-08-01','operation-intent',0,0);
`);
}
const snapshot = () =>
preserved.map((table) => {
const statement = db.prepare(`SELECT * FROM "${table}" ORDER BY id`);
statement.setReadBigInts(true);
return statement.all();
});
const before = snapshot();
const connectionsBefore = db
.prepare(
'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId',
)
.all();
db.exec(
readFileSync(join(migrations, migration, 'migration.sql'), 'utf8'),
);
db.close();
db = new DatabaseSync(file);
db.exec('PRAGMA foreign_keys=ON');
expect(snapshot()).toEqual(before);
expect(
db
.prepare(
'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId',
)
.all(),
).toEqual(connectionsBefore);
expect(db.prepare('PRAGMA integrity_check').get()).toEqual({
integrity_check: 'ok',
});
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
const revisions = db
.prepare('SELECT revision FROM TbankConnection')
.all()
.map((row) => row.revision);
expect(new Set(revisions).size).toBe(revisions.length);
expect(
revisions.every(
(value) => typeof value === 'string' && value.length > 0,
),
).toBe(true);
db.close();
const prisma = new PrismaClient({
adapter: new PrismaBetterSqlite3({ url: `file:${file}` }),
});
try {
if (!populated)
await prisma.user.create({
data: {
id: 'owner',
email: 'owner@example.invalid',
passwordHash: 'synthetic',
tbankConnection: {
create: {
encryptedToken: 'synthetic',
connectedAt: new Date(0),
},
},
},
});
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const scope = {
userId: 'owner',
revision: connection.revision,
accountId: 'account',
};
await prisma.tbankAccount.create({
data: {
...scope,
type: 'BROKERAGE',
name: 'Synthetic',
upstreamOrder: 0,
},
});
const exact = { currency: 'RUB', value: '-9007199254740993.123456789' };
const data = {
...scope,
brokerOperationId: 'broker-id',
occurredAt: new Date('2026-08-01'),
category: 'income',
payload: { payment: exact },
};
await prisma.tbankOperation.create({ data });
await prisma.tbankAccount.create({
data: {
...scope,
accountId: 'second',
type: 'BROKERAGE',
name: 'Second',
upstreamOrder: 1,
},
});
await prisma.tbankOperation.create({
data: { ...data, accountId: 'second' },
});
await expect(
prisma.tbankOperation.create({ data }),
).rejects.toMatchObject({ code: 'P2002' });
await expect(
prisma.tbankOperation.create({
data: {
...data,
brokerOperationId: 'foreign',
revision: 'wrong-connection',
},
}),
).rejects.toMatchObject({ code: 'P2003' });
// A revision update cannot silently move private rows to another connection.
await expect(
prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { revision: 'replacement' },
}),
).rejects.toMatchObject({ code: 'P2003' });
const saved = await prisma.tbankOperation.findFirstOrThrow({
where: scope,
});
expect(saved.payload).toEqual({ payment: exact });
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'synthetic-uid',
instrumentType: 'bond',
metadata: { name: 'Synthetic' },
fetchedAt: new Date(),
},
});
await prisma.tbankCandle.create({
data: {
provider: 'tbank',
instrumentUid: 'synthetic-uid',
interval: 'day',
time: new Date('2026-08-01'),
close: exact.value,
payload: { close: exact.value },
isComplete: true,
fetchedAt: new Date(),
},
});
await prisma.tbankSyncWork.create({
data: {
id: 'work',
userId: scope.userId,
revision: scope.revision,
scopeKey: 'operations',
intent: {},
state: 'running',
attemptId: 'new-attempt',
processRunId: 'run',
priority: 0,
progress: {},
},
});
const staged = {
workId: 'work',
attemptId: 'new-attempt',
sourceKey: 'operations',
itemKey: 'broker-id',
payload: { payment: exact },
};
await prisma.tbankSyncStage.create({ data: staged });
await expect(
prisma.tbankSyncStage.create({
data: { ...staged, attemptId: 'old-attempt' },
}),
).rejects.toMatchObject({ code: 'P2003' });
await prisma.$disconnect();
await prisma.$connect();
expect(
(await prisma.tbankOperation.findFirstOrThrow({ where: scope }))
.payload,
).toEqual({ payment: exact });
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
expect(await prisma.tbankOperation.count()).toBe(0);
expect(await prisma.tbankAccount.count()).toBe(0);
expect(await prisma.tbankSyncWork.count()).toBe(0);
expect(await prisma.tbankSyncStage.count()).toBe(0);
expect((await prisma.tbankCandle.findFirstOrThrow()).close).toBe(
exact.value,
);
expect(await prisma.bankCard.count()).toBe(populated ? 1 : 0);
expect(await prisma.bankCardOperation.count()).toBe(populated ? 1 : 0);
} finally {
await prisma.$disconnect();
}
db = new DatabaseSync(file);
const plan = db
.prepare(
'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50',
)
.all('owner', 'revision', 'account');
expect(
plan.some((row) => String(row.detail).includes('USING INDEX')),
).toBe(true);
expect(
plan.some((row) => String(row.detail).includes('TEMP B-TREE')),
).toBe(false);
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
expect(db.prepare('PRAGMA integrity_check').get()).toEqual({
integrity_check: 'ok',
});
db.close();
},
);
});
@@ -0,0 +1,350 @@
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../src/prisma/prisma.service';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
// Real transactions are the boundary: staged pages never become financial
// results, and a failed multi-account publication must preserve every old row.
describe('Operations staging and reconciliation (isolated SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const repository = new BrokerageOperationsRepository(prisma);
const attempt = {
userId: 'owner',
revision: 'revision',
id: 'work',
attemptId: 'attempt',
};
const interval = {
accountId: 'a',
from: new Date('2026-01-01Z'),
to: new Date('2026-02-01Z'),
};
const second = { ...interval, accountId: 'b' };
const operation = (id: string, value = '10') => ({
id,
date: { seconds: '1768000000' },
type: 'OPERATION_TYPE_COUPON',
payment: { units: value, nano: 1, currency: 'rub' },
});
beforeAll(async () => {
await prisma.$connect();
await prisma.user.create({
data: {
id: 'owner',
email: 'owner@example.invalid',
passwordHash: 'synthetic',
},
});
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'revision',
encryptedToken: 'synthetic',
connectedAt: new Date(),
membershipAt: new Date(),
accounts: {
create: ['a', 'b'].map((accountId, upstreamOrder) => ({
accountId,
upstreamOrder,
name: accountId,
type: 'BROKERAGE',
})),
},
},
});
await prisma.tbankSyncWork.create({
data: {
...attempt,
scopeKey: 'scope',
intent: {},
progress: {},
state: 'running',
processRunId: 'run',
priority: 1,
},
});
});
afterAll(async () => prisma.$disconnect());
it('exposes only old or new financial groups to a separate SQLite reader and survives reopen', async () => {
const reader = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
await reader.$connect();
try {
for (const scope of [interval, second]) {
await repository.stagePage(attempt, scope, undefined, {
hasNext: false,
items: [operation('row', '10')],
});
}
await repository.publish(attempt, [interval, second], new Date(1000));
for (const scope of [interval, second]) {
await repository.stagePage(attempt, scope, undefined, {
hasNext: false,
items: [operation('row', '20')],
});
}
const snapshots: string[][] = [];
await Promise.all([
repository.publish(attempt, [interval, second], new Date(2000)),
(async () => {
for (let index = 0; index < 20; index += 1) {
const rows = await reader.tbankOperation.findMany({
orderBy: { accountId: 'asc' },
});
snapshots.push(
rows.map(
(row) =>
(row.payload as { payment: { units: string } }).payment.units,
),
);
}
})(),
]);
for (const snapshot of snapshots)
expect([
['10', '10'],
['20', '20'],
]).toContainEqual(snapshot);
await reader.$disconnect();
await reader.$connect();
expect(
(await reader.tbankOperation.findMany()).map(
(row) =>
(row.payload as { payment: { units: string } }).payment.units,
),
).toEqual(['20', '20']);
} finally {
await reader.$disconnect();
}
});
it('limits instrument reconciliation and refuses conflicting versions across a publication group', async () => {
const instrumentOperation = {
...operation('instrument'),
instrumentUid: 'uid',
};
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [instrumentOperation, operation('unrelated')],
});
await repository.publish(attempt, [interval], new Date(1000));
const subset = { ...interval, instrumentUid: 'uid' };
await repository.stagePage(attempt, subset, undefined, {
hasNext: false,
items: [],
});
await repository.publish(attempt, [subset], new Date(2000));
expect(
(await prisma.tbankOperation.findMany()).map(
(row) => row.brokerOperationId,
),
).toEqual(['unrelated']);
expect(
(
await prisma.tbankOperationCoverage.findFirstOrThrow({
where: { instrumentPredicate: '' },
})
).fetchedAt,
).toEqual(new Date(1000));
const february = {
...interval,
from: interval.to,
to: new Date('2026-03-01Z'),
};
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [operation('moved')],
});
await repository.stagePage(attempt, february, undefined, {
hasNext: false,
items: [
{
...operation('moved'),
date: { seconds: String(february.from.valueOf() / 1000) },
},
],
});
await expect(
repository.publish(attempt, [interval, february], new Date(3000)),
).rejects.toThrow('Conflicting');
expect(
(await prisma.tbankOperation.findMany()).map(
(row) => row.brokerOperationId,
),
).toEqual(['unrelated']);
expect(await prisma.tbankOperationCoverage.count()).toBe(2);
});
it('keeps pages private, replaces the full group atomically, and changes revision only for row changes', async () => {
for (const scope of [interval, second]) {
await repository.stagePage(attempt, scope, undefined, {
hasNext: false,
items: [operation('same-id')],
});
}
await repository.publish(attempt, [interval, second], new Date(1000));
expect(await prisma.tbankOperation.count()).toBe(2);
expect(
(await prisma.tbankAccount.findMany()).map(
(row) => row.operationsRevision,
),
).toEqual([1, 1]);
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [operation('same-id', '-20')],
});
await expect(
repository.publish(attempt, [interval, second], new Date(2000)),
).rejects.toThrow('Incomplete');
expect(
(await prisma.tbankOperation.findMany()).every((row) =>
JSON.stringify(row.payload).includes('"10"'),
),
).toBe(true);
expect(
(await prisma.tbankOperationCoverage.findMany()).every(
(row) => row.fetchedAt.valueOf() === 1000,
),
).toBe(true);
await repository.stagePage(attempt, second, undefined, {
hasNext: false,
items: [],
});
await repository.publish(attempt, [interval, second], new Date(3000));
expect(await prisma.tbankOperation.count()).toBe(1);
expect(
(await prisma.tbankAccount.findMany()).map(
(row) => row.operationsRevision,
),
).toEqual([2, 2]);
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [operation('same-id', '-20')],
});
await repository.publish(attempt, [interval], new Date(4000));
expect(
(
await prisma.tbankAccount.findFirstOrThrow({
where: { accountId: 'a' },
})
).operationsRevision,
).toBe(2);
expect(
(
await prisma.tbankOperationCoverage.findFirstOrThrow({
where: { accountId: 'a' },
})
).fetchedAt,
).toEqual(new Date(4000));
});
it('reconciles a changed date and removes cancelled operations without changing another account', async () => {
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [operation('moved'), operation('cancelled')],
});
await repository.stagePage(attempt, second, undefined, {
hasNext: false,
items: [operation('cancelled')],
});
await repository.publish(attempt, [interval, second], new Date(1000));
await expect(
repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [
operation('cancelled'),
{ ...operation('cancelled'), state: 'OPERATION_STATE_CANCELED' },
],
}),
).rejects.toThrow('Conflicting');
const moved = {
...operation('moved', '-123'),
date: { seconds: '1769000000' },
state: 'OPERATION_STATE_EXECUTED',
};
await repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [
moved,
{ ...operation('cancelled'), state: 'OPERATION_STATE_CANCELED' },
],
});
expect(await prisma.tbankOperation.count()).toBe(3);
await repository.publish(attempt, [interval], new Date(2000));
const rows = await prisma.tbankOperation.findMany({
orderBy: { accountId: 'asc' },
});
expect(rows).toHaveLength(2);
expect(rows[0].payload).toEqual(moved);
expect(rows[0].occurredAt).toEqual(new Date(1769000000000));
expect(rows[1].accountId).toBe('b');
expect(rows[1].brokerOperationId).toBe('cancelled');
});
it('rejects malformed pagination and conflicting identities, filters exact boundaries, and fences superseded work', async () => {
await expect(
repository.stagePage(attempt, interval, undefined, {
hasNext: true,
items: [],
}),
).rejects.toThrow('continuation');
await expect(
repository.stagePage(attempt, interval, undefined, {
hasNext: false,
items: [{ ...operation(''), cursor: 'not-an-id' }],
}),
).rejects.toThrow('identity');
expect(await prisma.tbankSyncStage.count()).toBe(0);
const edge = {
...operation('edge'),
date: { seconds: String(interval.to.valueOf() / 1000) },
};
await repository.stagePage(attempt, interval, undefined, {
hasNext: true,
nextCursor: 'next',
items: [operation('id'), operation('id'), edge],
});
expect(await prisma.tbankOperation.count()).toBe(0);
await expect(
repository.stagePage(attempt, interval, 'next', {
hasNext: true,
nextCursor: 'next',
items: [],
}),
).rejects.toThrow('continuation');
await expect(
repository.stagePage(attempt, interval, 'next', {
hasNext: false,
items: [operation('id', '11')],
}),
).rejects.toThrow('Conflicting');
await repository.stagePage(attempt, interval, 'next', {
hasNext: false,
items: [operation('id')],
});
await repository.publish(attempt, [interval], new Date());
expect(
(await prisma.tbankOperation.findMany()).map(
(row) => row.brokerOperationId,
),
).toEqual(['id']);
await prisma.tbankSyncWork.update({
where: { id: attempt.id },
data: { attemptId: 'replacement' },
});
expect(
await repository.stagePage(attempt, second, undefined, {
hasNext: false,
items: [],
}),
).toBe(false);
expect(await repository.publish(attempt, [second], new Date())).toBe(false);
expect(await prisma.tbankOperation.count()).toBe(1);
});
});
+714
View File
@@ -0,0 +1,714 @@
import { ConfigService } from '@nestjs/config';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { PrismaService } from '../src/prisma/prisma.service';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import type {
TbankOperationsByCursorRequest,
TbankOperationsByCursorResponse,
} from '../src/integrations/tbank/tbank.client';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service';
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Deferred not initialized');
};
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
// External RPCs alone are controlled. Work claims, recovery, staging and
// financial publication use the actual migrated temporary SQLite.
describe('Request-driven operations synchronization (SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const repository = new BrokerageOperationsRepository(prisma);
const upstream = {
getOperationsByCursor: jest.fn<
Promise<TbankOperationsByCursorResponse>,
[string, TbankOperationsByCursorRequest]
>(),
getOpenAccounts: jest.fn(),
};
const tokens = new TbankTokenService(
new ConfigService({
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}),
);
const access = new TbankAccessService(prisma, tokens, upstream as never);
const logger = { event: jest.fn() };
const createService = () =>
new BrokerageOperationsSyncService(
repository,
access,
upstream as never,
logger as never,
);
let service: BrokerageOperationsSyncService;
const january = {
accountId: 'account',
from: new Date('2026-01-01Z'),
to: new Date('2026-02-01Z'),
};
const february = {
...january,
from: january.to,
to: new Date('2026-03-01Z'),
};
const operation = (value = '10', seconds = '1768000000') => ({
id: 'id',
date: { seconds },
payment: { units: value, nano: 1, currency: 'rub' },
});
const complete = { hasNext: false, items: [operation()] };
async function state(id: string, expected: string) {
for (let index = 0; index < 500; index += 1) {
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id },
});
if (work.state === expected) return work;
await delay(2);
}
throw new Error(`Sync did not reach ${expected}`);
}
beforeAll(async () => {
await prisma.$connect();
await prisma.user.create({
data: {
id: 'owner',
email: 'owner@example.invalid',
passwordHash: 'synthetic',
},
});
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
jest.clearAllMocks();
upstream.getOperationsByCursor.mockReset();
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'revision',
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: new Date(),
membershipAt: new Date(),
accounts: {
create: {
accountId: 'account',
upstreamOrder: 0,
name: 'Synthetic',
type: 'BROKERAGE',
},
},
},
});
service = createService();
});
afterAll(async () => prisma.$disconnect());
it('joins overlapping account and instrument refresh into one atomic publication group', async () => {
const gate = deferred<TbankOperationsByCursorResponse>();
upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise)
.mockResolvedValue({
hasNext: false,
items: [{ ...operation(), instrumentUid: 'uid' }],
});
const subset = requiredHandle(
await service.request(
'owner',
[{ ...january, instrumentUid: 'uid' }],
true,
),
);
await delay(5);
const whole = requiredHandle(
await service.request('owner', [january], true),
);
expect(whole).toBe(subset);
expect(await prisma.tbankOperation.count()).toBe(0);
gate.resolve({
hasNext: false,
items: [{ ...operation(), instrumentUid: 'uid' }],
});
await state(whole, 'complete');
expect(await prisma.tbankOperation.count()).toBe(1);
expect(await prisma.tbankOperationCoverage.count()).toBe(2);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('publishes the forced visible period before unrelated pending history and retains it if history fails', async () => {
const gate = deferred<TbankOperationsByCursorResponse>();
const march = {
...january,
from: new Date('2026-03-01Z'),
to: new Date('2026-04-01Z'),
};
upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise)
.mockImplementation((_token, request) => {
if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000)
return Promise.resolve({
hasNext: false,
items: [operation('20', '1773000000')],
});
return Promise.reject(new Error('unrelated history unavailable'));
});
const history = requiredHandle(
await service.request('owner', [february, march], false, 'history'),
);
await delay(5);
const screen = requiredHandle(
await service.request('owner', [march], true),
);
expect(screen).toBe(history);
gate.resolve({ hasNext: false, items: [] });
await state(screen, 'failed');
expect(
(await prisma.tbankOperation.findMany()).map((row) => row.payload),
).toEqual([operation('20', '1773000000')]);
const coverage = await repository.coverage('owner', 'revision', [
'account',
]);
expect(coverage.map((range) => [range.from, range.to])).toEqual([
[march.from, march.to],
]);
});
it('moves a newly visible interval ahead of pending backfill and records an unknown requested floor', async () => {
const gate = deferred<TbankOperationsByCursorResponse>();
upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise)
.mockResolvedValue({ hasNext: false, items: [] });
const march = {
...january,
from: new Date('2026-03-01Z'),
to: new Date('2026-04-01Z'),
};
const history = requiredHandle(
await service.request(
'owner',
[january, february, march],
false,
'history',
),
);
await delay(5);
await service.request('owner', [march], false);
gate.resolve({ hasNext: false, items: [] });
await state(history, 'complete');
expect(
upstream.getOperationsByCursor.mock.calls.map(
([, call]) => Number(call.from?.seconds) + 1,
),
).toEqual([
january.from.valueOf() / 1000,
march.from.valueOf() / 1000,
february.from.valueOf() / 1000,
]);
const account = await prisma.tbankAccount.findFirstOrThrow();
expect(account.historyBoundary).toBe('unknown');
expect(account.historyFrom).toEqual(new Date(0));
});
it('never publishes coverage beyond the fixed request time', async () => {
const before = new Date();
const from = new Date(
Date.UTC(before.getUTCFullYear(), before.getUTCMonth(), 1),
);
const future = new Date(
Date.UTC(before.getUTCFullYear(), before.getUTCMonth() + 1, 1),
);
upstream.getOperationsByCursor.mockResolvedValue({
hasNext: false,
items: [],
});
const handle = requiredHandle(
await service.request(
'owner',
[{ accountId: 'account', from, to: future }],
false,
),
);
const accepted = new Date();
await state(handle, 'complete');
const saved = await prisma.tbankOperationCoverage.findFirstOrThrow();
expect(saved.to.valueOf()).toBeGreaterThanOrEqual(before.valueOf());
expect(saved.to.valueOf()).toBeLessThanOrEqual(accepted.valueOf());
expect(saved.asOf).toEqual(saved.to);
});
it('uses persistent coverage without RPC, observes stale data without starting work, and refreshes on request', async () => {
const now = new Date();
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const asOf = new Date(
now.valueOf() - Math.min(1000, (now.valueOf() - from.valueOf()) / 2),
);
await prisma.tbankOperationCoverage.create({
data: {
userId: 'owner',
revision: 'revision',
accountId: 'account',
from,
to: asOf,
asOf,
fetchedAt: now,
},
});
const target = { accountId: 'account', from, to: now };
const warm = await service.readPlan('owner', [target], { movingEnd: true });
expect(warm.handle).toBeNull();
expect(warm.plan?.intervals[0]).toMatchObject({
availability: 'complete',
asOf,
stale: false,
});
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
await prisma.tbankOperationCoverage.updateMany({
data: { fetchedAt: new Date(now.valueOf() - 300_000) },
});
const observed = await service.readPlan('owner', [target], {
movingEnd: true,
observe: true,
});
expect(observed.handle).toBeNull();
expect(observed.plan?.intervals[0]).toMatchObject({
availability: 'complete',
asOf,
stale: true,
});
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
upstream.getOperationsByCursor.mockResolvedValue({
hasNext: false,
items: [],
});
const refresh = await service.readPlan('owner', [target], {
movingEnd: true,
});
await state(requiredHandle(refresh.handle), 'complete');
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
expect(
(
await service.readPlan('owner', [{ ...target, to: new Date() }], {
movingEnd: true,
})
).handle,
).toBeNull();
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
const forced = await service.readPlan(
'owner',
[{ ...target, to: new Date() }],
{ movingEnd: true, force: true },
);
await state(requiredHandle(forced.handle), 'complete');
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('preserves the entire complete quarter across accounts when the second month fails', async () => {
await prisma.tbankAccount.create({
data: {
userId: 'owner',
revision: 'revision',
accountId: 'second',
name: 'Second',
type: 'BROKERAGE',
upstreamOrder: 1,
},
});
const quarter = [
{ ...january, to: new Date('2026-04-01Z') },
{ ...january, accountId: 'second', to: new Date('2026-04-01Z') },
];
let amount = '10';
let failFebruary = false;
upstream.getOperationsByCursor.mockImplementation((_token, request) => {
if (
failFebruary &&
request.from?.seconds === String(february.from.valueOf() / 1000 - 1)
)
return Promise.reject(new Error('Second month unavailable'));
return Promise.resolve({
hasNext: false,
items: [
{
...operation(amount, String(Number(request.from?.seconds) + 2)),
id: `payment-${request.from?.seconds}`,
},
],
});
});
const handle = requiredHandle(
await service.request('owner', quarter, false),
);
await state(handle, 'complete');
const before = await prisma.tbankOperation.findMany({
orderBy: [{ accountId: 'asc' }, { brokerOperationId: 'asc' }],
});
const coverage = await prisma.tbankOperationCoverage.findMany();
expect(before).toHaveLength(6);
amount = '-20';
failFebruary = true;
await service.request('owner', quarter, false);
await state(handle, 'failed');
expect(
await prisma.tbankOperation.findMany({
orderBy: [{ accountId: 'asc' }, { brokerOperationId: 'asc' }],
}),
).toEqual(before);
expect(await prisma.tbankOperationCoverage.findMany()).toEqual(coverage);
failFebruary = false;
await service.request('owner', quarter, false);
await state(handle, 'complete');
const after = await prisma.tbankOperation.findMany();
expect(after).toHaveLength(6);
expect(
after.every(
(row) =>
(row.payload as { payment: { units: string } }).payment.units ===
'-20',
),
).toBe(true);
expect(
(await prisma.tbankAccount.findMany()).map(
(account) => account.operationsRevision,
),
).toEqual([4, 4]);
});
it.each(['between-pages', 'before-publish', 'after-publish'])(
'survives SIGKILL %s without startup recovery or lost publication',
async (checkpoint) => {
const child = fork(
join(__dirname, 'fixtures/tbank-sync-crash.cjs'),
[checkpoint],
{ silent: true },
);
let stderr = '';
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
const exited = once(child, 'exit');
try {
const handle = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Crash checkpoint timeout: ${stderr}`)),
5000,
);
child.once('exit', () => {
clearTimeout(timer);
reject(new Error(`Worker exited early: ${stderr}`));
});
child.on('message', (message: unknown) => {
if (
message &&
typeof message === 'object' &&
'handle' in message &&
typeof message.handle === 'string'
) {
clearTimeout(timer);
resolve(message.handle);
}
if (message && typeof message === 'object' && 'error' in message) {
clearTimeout(timer);
reject(new Error(String(message.error)));
}
});
});
child.kill('SIGKILL');
await exited;
service = createService();
const saved = await service.observe('owner', handle);
expect(saved.state).toBe(
checkpoint === 'after-publish' ? 'complete' : 'running',
);
await delay(10);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
if (checkpoint === 'after-publish') {
expect(await prisma.tbankOperation.count()).toBe(1);
expect(await prisma.tbankSyncStage.count()).toBe(0);
} else {
expect(await prisma.tbankOperation.count()).toBe(0);
expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0);
upstream.getOperationsByCursor.mockResolvedValueOnce(complete);
await service.request('owner', [january], true);
await state(handle, 'complete');
expect(
upstream.getOperationsByCursor.mock.calls[0][1].cursor,
).toBeUndefined();
expect(await prisma.tbankOperation.count()).toBe(1);
}
} finally {
if (child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
await exited;
}
},
);
it('joins a request bridging active groups and keeps both original handles observable', async () => {
const blocked = deferred<typeof complete>();
upstream.getOperationsByCursor
.mockReturnValueOnce(blocked.promise)
.mockResolvedValue({ hasNext: false, items: [] });
const early = { ...january, to: new Date('2026-01-10Z') };
const late = { ...january, from: new Date('2026-01-20Z') };
const first = requiredHandle(await service.request('owner', [early], true));
await delay(5);
const second = requiredHandle(await service.request('owner', [late], true));
const merged = requiredHandle(
await service.request('owner', [january], true),
);
expect([first, second]).toContain(merged);
blocked.resolve({ hasNext: false, items: [] });
await state(merged, 'complete');
expect((await service.observe('owner', first)).state).toBe('complete');
expect((await service.observe('owner', second)).state).toBe('complete');
expect(await prisma.tbankOperationCoverage.count()).toBe(1);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('extends an overlapping atomic group without rereading its intersection or publishing a partial result', async () => {
const first = deferred<typeof complete>();
const remainder = deferred<typeof complete>();
const early = { ...january, to: new Date('2026-01-20Z') };
const late = { ...january, from: new Date('2026-01-10Z') };
upstream.getOperationsByCursor
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(remainder.promise);
const handle = requiredHandle(
await service.request('owner', [early], true),
);
await delay(5);
expect(await service.request('owner', [late], true)).toBe(handle);
first.resolve({ hasNext: false, items: [] });
for (
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
)
await delay(2);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({
seconds: String(early.to.valueOf() / 1000 - 1),
});
expect(await prisma.tbankOperationCoverage.count()).toBe(0);
remainder.resolve({ hasNext: false, items: [] });
await state(handle, 'complete');
expect(await prisma.tbankOperationCoverage.count()).toBe(2);
});
it('lets new screen work overtake history between provider pages', async () => {
const first = deferred<{
hasNext: boolean;
nextCursor: string;
items: never[];
}>();
upstream.getOperationsByCursor
.mockReturnValueOnce(first.promise)
.mockResolvedValue({ hasNext: false, items: [] });
const history = requiredHandle(
await service.request('owner', [january], false, 'history'),
);
await delay(5);
const screen = requiredHandle(
await service.request('owner', [february], true),
);
first.resolve({ hasNext: true, nextCursor: 'next', items: [] });
await state(screen, 'complete');
await state(history, 'complete');
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(3);
expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({
seconds: String(february.from.valueOf() / 1000 - 1),
});
expect(upstream.getOperationsByCursor.mock.calls[2][1].cursor).toBe('next');
});
it('keeps published initial windows on recovery and restarts only the unfinished month', async () => {
const blocked = deferred<typeof complete>();
upstream.getOperationsByCursor
.mockResolvedValueOnce(complete)
.mockReturnValueOnce(blocked.promise);
const handle = requiredHandle(
await service.request('owner', [january, february], false),
);
for (
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
)
await delay(2);
expect(await prisma.tbankOperation.count()).toBe(1);
const before = await prisma.tbankOperationCoverage.findMany();
expect(before).toHaveLength(1);
service = createService();
upstream.getOperationsByCursor.mockResolvedValueOnce({
hasNext: false,
items: [],
});
await service.request('owner', [january, february], false);
await state(handle, 'complete');
expect(upstream.getOperationsByCursor.mock.calls[2][1]).toMatchObject({
from: { seconds: String(february.from.valueOf() / 1000 - 1) },
});
expect(
(
await prisma.tbankOperationCoverage.findFirstOrThrow({
where: { from: january.from },
})
).fetchedAt,
).toEqual(before[0].fetchedAt);
blocked.resolve(complete);
await delay(10);
expect(await prisma.tbankOperation.count()).toBe(1);
expect(await prisma.tbankOperationCoverage.count()).toBe(2);
});
it('persists current invalid credentials and rejects work for an inaccessible account before RPC', async () => {
await expect(
service.request('owner', [{ ...january, accountId: 'other' }], true),
).rejects.toMatchObject({ status: 404 });
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
upstream.getOperationsByCursor.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
const handle = requiredHandle(
await service.request('owner', [january], true),
);
expect((await state(handle, 'failed')).errorCode).toBe(
'INVALID_TBANK_TOKEN',
);
expect(
(
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).invalidToken,
).toBe(true);
await expect(
service.request('owner', [january], true),
).rejects.toMatchObject({ status: 400 });
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('coalesces twenty requests, observes without RPC and completes only after the last page', async () => {
const blocked = deferred<typeof complete>();
upstream.getOperationsByCursor.mockReturnValueOnce(blocked.promise);
const handles = await Promise.all(
Array.from({ length: 20 }, () =>
service.request('owner', [january], true),
),
);
expect(new Set(handles).size).toBe(1);
const handle = requiredHandle(handles[0]);
await delay(5);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
expect((await service.observe('owner', handle)).state).toBe('running');
expect(await prisma.tbankOperation.count()).toBe(0);
blocked.resolve(complete);
await state(handle, 'complete');
expect(await prisma.tbankOperation.count()).toBe(1);
await Promise.all(
Array.from({ length: 20 }, () => service.observe('owner', handle)),
);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getOperationsByCursor.mock.calls[0][1]).toMatchObject({
from: { seconds: String(january.from.valueOf() / 1000 - 1) },
to: { seconds: String(january.to.valueOf() / 1000 + 1) },
state: 'OPERATION_STATE_EXECUTED',
limit: 1000,
});
});
it('keeps the full old result on a second-month failure, has no retry loop and resumes only on request', async () => {
upstream.getOperationsByCursor
.mockResolvedValueOnce(complete)
.mockResolvedValueOnce({ hasNext: false, items: [] });
const handle = requiredHandle(
await service.request('owner', [january, february], false),
);
await state(handle, 'complete');
const oldCoverage = await prisma.tbankOperationCoverage.findMany();
upstream.getOperationsByCursor
.mockResolvedValueOnce({ hasNext: false, items: [operation('-20')] })
.mockRejectedValueOnce(new Error('synthetic outage'));
await service.request('owner', [january, february], false);
await state(handle, 'failed');
expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual(
operation(),
);
expect(await prisma.tbankOperationCoverage.findMany()).toEqual(oldCoverage);
const calls = upstream.getOperationsByCursor.mock.calls.length;
await service.observe('owner', handle);
await delay(10);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(calls);
upstream.getOperationsByCursor
.mockResolvedValueOnce({ hasNext: false, items: [operation('-20')] })
.mockResolvedValueOnce({ hasNext: false, items: [] });
await service.request('owner', [january, february], false);
await state(handle, 'complete');
expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual(
operation('-20'),
);
});
it('starts an unfinished interval from page one after a new run request and ignores late old pages', async () => {
const blocked = deferred<typeof complete>();
upstream.getOperationsByCursor
.mockResolvedValueOnce({
hasNext: true,
nextCursor: 'provider-cursor',
items: [operation()],
})
.mockReturnValueOnce(blocked.promise);
const handle = requiredHandle(
await service.request('owner', [january], true),
);
for (
let index = 0;
index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2;
index += 1
)
await delay(2);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
const before = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: handle },
});
service = createService();
await service.observe('owner', handle);
await delay(5);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2);
upstream.getOperationsByCursor.mockResolvedValueOnce({
hasNext: false,
items: [operation('30')],
});
await service.request('owner', [january], true);
await state(handle, 'complete');
const after = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: handle },
});
expect(after.attemptId).not.toBe(before.attemptId);
expect(after.processRunId).not.toBe(before.processRunId);
expect(
upstream.getOperationsByCursor.mock.calls[2][1].cursor,
).toBeUndefined();
blocked.resolve(complete);
await delay(10);
expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual(
operation('30'),
);
expect(await prisma.tbankSyncStage.count()).toBe(0);
});
});
function requiredHandle(value: string | null): string {
if (!value) throw new Error('Expected sync handle');
return value;
}
+569
View File
@@ -0,0 +1,569 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import { cpus, totalmem } from 'node:os';
import { writeFileSync, statSync, readFileSync } from 'node:fs';
import { setTimeout as delay } from 'node:timers/promises';
import type { Request, Response, NextFunction } from 'express';
import { performance } from 'node:perf_hooks';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import {
TbankClient,
TbankOperationsByCursorRequest,
} from '../src/integrations/tbank/tbank.client';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
// Same navigation and synthetic sources as the saved baseline; isolated SQLite only.
describe('T-Bank persisted read budget', () => {
let app: INestApplication<Server>;
const owner = 'synthetic-baseline-owner';
const accounts = Array.from({ length: 3 }, (_, index) => ({
id: `baseline-${index}`,
name: `Synthetic ${index}`,
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: Date.UTC(2016, 8, 1) / 1000 },
}));
const instruments = Array.from({ length: 200 }, (_, index) => ({
uid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
ticker: `SYNTH${index}`,
name: `Synthetic instrument ${index}`,
assetUid: `asset-${index}`,
isin: `SYNTHETIC${index}`,
currency: 'rub',
instrumentKind:
index % 2 ? 'INSTRUMENT_TYPE_BOND' : 'INSTRUMENT_TYPE_SHARE',
classCode: index % 2 ? 'TQOB' : 'TQBR',
apiTradeAvailableFlag: true,
first_1dayCandleDate: { seconds: Date.UTC(2026, 7, 31 - 4999) / 1000 },
nominal: { currency: 'rub', units: '1000' },
aciValue: { currency: 'rub', units: '12', nano: 345678900 },
}));
const candles = Array.from({ length: 5000 }, (_, index) => ({
time: { seconds: Date.UTC(2026, 7, 31 - 4999 + index) / 1000 },
close: { units: '100', nano: index },
isComplete: true,
}));
const operations = Array.from({ length: 100_000 }, (_, index) => {
// 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years.
const time =
index < 1000
? Date.UTC(2026, 7, 1) + index * 2_500_000
: Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000;
const instrument = instruments[index % instruments.length];
return {
id: `operation-${index}`,
date: { seconds: String(Math.floor(time / 1000)) },
type: index % 5 === 0 ? 'OPERATION_TYPE_COUPON' : 'OPERATION_TYPE_BUY',
instrumentUid: instrument.uid,
ticker: instrument.ticker,
instrumentType: 'bond',
payment: { currency: 'rub', units: '123', nano: 123456789 },
quantity: '1',
quantityDone: '1',
};
}).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds));
const rpc: Record<string, number> = {};
let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready';
const serverTimes: number[] = [];
async function counted<T>(name: string, value: T): Promise<T> {
rpc[name] = (rpc[name] ?? 0) + 1;
if (upstreamMode === 'delayed') await delay(2000);
if (upstreamMode === 'failed')
return Promise.reject(new Error('Synthetic outage'));
return Promise.resolve(value);
}
const upstream = {
getOpenAccounts: () => counted('accounts', accounts),
getPortfolio: () =>
counted('portfolio', {
positions: instruments.map((instrument, index) => ({
instrumentUid: instrument.uid,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: '1' },
currentPrice: { currency: 'rub', units: '1000' },
})),
}),
getPositions: () =>
counted('positions', { money: [{ currency: 'rub', units: '100' }] }),
getInstrumentByUid: (_token: string, uid: string) =>
counted(
'metadata',
instruments.find((instrument) => instrument.uid === uid),
),
findShares: (_token: string, query: string) =>
counted(
'findShares',
instruments.filter(
(item) =>
item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' &&
item.ticker.includes(query),
),
),
findBonds: (_token: string, query: string) =>
counted(
'findBonds',
instruments.filter(
(item) =>
item.instrumentKind === 'INSTRUMENT_TYPE_BOND' &&
item.ticker.includes(query),
),
),
getShare: (_token: string, uid: string) =>
counted(
'share',
instruments.find((item) => item.uid === uid),
),
getBond: (_token: string, uid: string) =>
counted(
'bond',
instruments.find((item) => item.uid === uid),
),
getLastPrice: (_token: string, uid: string) =>
counted('lastPrice', {
instrumentUid: uid,
price: { units: '100' },
time: { seconds: Date.UTC(2026, 7, 31) / 1000 },
}),
getLastPrices: (_token: string, uids: string[]) =>
counted(
'lastPrices',
uids.map((uid) => ({ instrumentUid: uid, price: { units: '100' } })),
),
getTradingStatus: () =>
counted('tradingStatus', 'SECURITY_TRADING_STATUS_NORMAL_TRADING'),
getCandles: (_token: string, _uid: string, from: Date, to: Date) =>
counted(
'candles',
candles.filter(
(row) =>
row.time.seconds * 1000 >= from.valueOf() &&
row.time.seconds * 1000 <= to.valueOf(),
),
),
getAssetDescription: () => counted('assetDescription', 'Synthetic company'),
getFundamentals: () =>
counted('fundamentals', {
currency: 'rub',
revenueTtm: 1_000_000,
peRatioTtm: 5,
}),
getDividends: () =>
counted('dividends', [
{
dividendNet: { currency: 'rub', units: '10' },
recordDate: { seconds: Date.UTC(2026, 9, 1) / 1000 },
},
]),
getBondCoupons: () =>
counted('coupons', [
{
payOneBond: { currency: 'rub', units: '10' },
couponDate: { seconds: Date.UTC(2026, 9, 1) / 1000 },
fixDate: { seconds: Date.UTC(2026, 8, 30) / 1000 },
couponPeriod: 30,
},
]),
getOperationsByCursor: (
_token: string,
query: TbankOperationsByCursorRequest,
) => {
const rows = operations.filter(
(row) =>
Number(row.date.seconds) >= Number(query.from?.seconds ?? 0) &&
Number(row.date.seconds) <= Number(query.to?.seconds ?? Infinity),
);
const offset = Number(query.cursor ?? 0);
const end = offset + (query.limit ?? 50);
return counted('operations', {
items: rows.slice(offset, end),
hasNext: end < rows.length,
nextCursor: end < rows.length ? String(end) : '',
});
},
};
const token = new JwtService().sign(
{ sub: owner, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
async function start() {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(MoexClient)
.useValue({
bondEvents: () =>
counted('moexEvents', [
{
type: 'coupon',
date: '2026-10-01',
value: '10',
currency: 'RUB',
},
]),
bondYield: () => counted('moexYield', null),
})
.overrideProvider(NcrRatingsClient)
.useValue({ rating: () => counted('ncrRating', null) })
.compile();
app = module.createNestApplication();
app.use((_req: Request, res: Response, next: NextFunction) => {
const start = performance.now();
res.once('finish', () => serverTimes.push(performance.now() - start));
next();
});
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
}
beforeAll(async () => {
await start();
const prisma = app.get(PrismaService);
await prisma.user.create({
data: {
id: owner,
email: 'baseline@example.invalid',
passwordHash: 'unused',
},
});
const connection = await prisma.tbankConnection.create({
data: {
userId: owner,
encryptedToken: app.get(TbankTokenService).encrypt('synthetic-token'),
connectedAt: new Date(),
membershipAt: new Date(),
membershipRevision: 1,
},
});
const now = new Date();
for (const [index, account] of accounts.entries()) {
const scope = {
userId: owner,
revision: connection.revision,
accountId: account.id,
};
await prisma.tbankAccount.create({
data: {
...scope,
name: account.name,
type: 'BROKERAGE',
openedOn: '2016-09-01',
upstreamOrder: index,
historyFrom: new Date('2016-09-01Z'),
historyBoundary: 'openedOn',
},
});
for (let offset = 0; offset < operations.length; offset += 1000)
await prisma.tbankOperation.createMany({
data: operations.slice(offset, offset + 1000).map((row) => ({
...scope,
brokerOperationId: row.id,
occurredAt: new Date(Number(row.date.seconds) * 1000),
instrumentUid: row.instrumentUid,
category: row.type === 'OPERATION_TYPE_COUPON' ? 'income' : 'trade',
payload: row,
})),
});
await prisma.tbankOperationCoverage.create({
data: {
...scope,
from: new Date('2016-08-31T21:00:00Z'),
to: now,
fetchedAt: now,
asOf: now,
},
});
}
for (const instrument of instruments) {
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: instrument.uid,
ticker: instrument.ticker,
instrumentType:
instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE'
? 'share'
: 'bond',
metadata: instrument,
fetchedAt: now,
},
});
await prisma.$executeRawUnsafe(
`WITH RECURSIVE days(n) AS (SELECT 0 UNION ALL SELECT n+1 FROM days WHERE n<4999)
INSERT INTO TbankCandle(provider,instrumentUid,interval,time,close,payload,isComplete,fetchedAt)
SELECT 'tbank',?,'day',strftime('%Y-%m-%dT%H:%M:%fZ', (?+n*86400000)/1000.0, 'unixepoch'),'100','{}',1,? FROM days`,
instrument.uid,
candles[0].time.seconds * 1000,
now.toISOString(),
);
const today = new Date(now);
today.setUTCHours(0, 0, 0, 0);
await prisma.tbankCandleCoverage.createMany({
data: [
{
provider: 'tbank',
instrumentUid: instrument.uid,
interval: 'day',
from: new Date(candles[0].time.seconds * 1000),
to: today,
isComplete: true,
fetchedAt: now,
},
{
provider: 'tbank',
instrumentUid: instrument.uid,
interval: 'day',
from: today,
to: new Date(today.valueOf() + 86400000),
isComplete: false,
fetchedAt: now,
},
],
});
}
expect(await prisma.tbankOperation.count()).toBe(300_000);
expect(await prisma.tbankCandle.count()).toBe(1_000_000);
}, 300_000);
afterAll(async () => app?.close());
it('measures the same navigation with 10 clients and 100 reads per family', async () => {
expect(operations).toHaveLength(100_000);
expect(candles).toHaveLength(5000);
const routes = [
'/brokerage-accounts',
'/brokerage-accounts/baseline-0/portfolio',
'/brokerage-accounts/all/portfolio',
'/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50',
'/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50',
'/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31',
'/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31',
'/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31',
'/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31',
'/stocks?query=SYNTH',
'/bonds?query=SYNTH',
...[
['stocks', instruments[0].uid, 'dividends'],
['bonds', instruments[1].uid, 'coupons'],
].flatMap(([kind, uid, payout]) => [
`/${kind}/${uid}`,
`/${kind}/${uid}/quote`,
`/${kind}/${uid}/${kind === 'stocks' ? 'company' : 'metrics'}`,
`/${kind}/${uid}/candles?period=year`,
`/${kind}/${uid}/candles?period=all`,
`/${kind}/${uid}/${payout}`,
]),
];
async function settled() {
for (let index = 0; index < 6000; index++) {
await delay(10);
if (
!(await app
.get(PrismaService)
.tbankSyncWork.count({ where: { state: 'running' } }))
)
return;
}
throw new Error('Import did not settle');
}
async function fresh() {
const db = app.get(PrismaService);
const fetchedAt = new Date();
await db.tbankConnection.updateMany({
data: { membershipAt: fetchedAt },
});
await db.tbankPortfolio.updateMany({ data: { fetchedAt } });
await db.tbankInstrumentSource.updateMany({ data: { fetchedAt } });
await db.tbankCandleCoverage.updateMany({
where: { isComplete: false },
data: { fetchedAt },
});
}
for (let pass = 0; pass < 3; pass++) {
for (const route of routes) {
await request(app.getHttpServer())
.get(`/api/v1${route}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
await settled();
}
await fresh();
}
const warmupRpc = { ...rpc };
for (const key of Object.keys(rpc)) rpc[key] = 0;
await app.close();
await start();
upstreamMode = 'failed';
const evidence = [];
const responses: Record<string, unknown> = {};
for (const route of routes) {
const read = async () => {
const start = performance.now();
const response = await request(app.getHttpServer())
.get(`/api/v1${route}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
const body = response.body as {
meta: { availability: string };
data: unknown;
};
if (route.includes('candles?period=all'))
expect((body.data as { candles: unknown[] }).candles).toHaveLength(
5000,
);
expect({ route, availability: body.meta.availability }).toEqual({
route,
availability: 'complete',
});
if (!responses[route]) {
responses[route] = body;
writeFileSync(
'/tmp/wealthledger-browser-responses.json',
JSON.stringify(responses),
);
}
return {
ms: performance.now() - start,
bytes: Buffer.byteLength(response.text),
};
};
await fresh();
const cold = await read();
serverTimes.length = 0;
const before = { ...rpc };
const samples = [];
for (let batch = 0; batch < 10; batch += 1)
samples.push(...(await Promise.all(Array.from({ length: 10 }, read))));
const clientTimes = samples
.map((sample) => sample.ms)
.sort((a, b) => a - b);
const times = [...serverTimes].sort((a, b) => a - b);
expect(times).toHaveLength(100);
evidence.push({
route,
cold,
count: samples.length,
clientP95: clientTimes[94],
p50: times[49],
p95: times[94],
p99: times[98],
max: times[99],
maxBytes: Math.max(...samples.map((sample) => sample.bytes)),
rpc: Object.fromEntries(
Object.entries(rpc).map(([key, count]) => [
key,
count - (before[key] ?? 0),
]),
),
});
}
const result = {
phase: 'persistent',
runtime: process.version,
hardware: {
cpuQuota:
process.platform === 'linux'
? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim()
: null,
memoryLimit:
process.platform === 'linux'
? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim()
: null,
cpu: cpus()[0]?.model,
cpus: cpus().length,
memoryBytes: totalmem(),
},
profile: {
accounts: 3,
operationsPerAccount: 100_000,
instruments: 200,
candlesPerInstrument: 5000,
upstream:
'synthetic failing upstream after population; transport limiter excluded',
timing:
'backend middleware ingress to response finish; client p95 separately',
limitation:
'Synthetic transport boundary; cgroup fields report constraints when run in Docker. Cross-host latency is not compared to baseline.',
},
evidence,
totalRpc: { ...rpc },
warmupRpc,
databaseBytes: statSync(
String(process.env.DATABASE_URL).replace('file:', ''),
).size,
};
const baseline = JSON.parse(
readFileSync('test/fixtures/tbank-read-baseline.json', 'utf8'),
) as { totalRpc: Record<string, number> };
const oldRpc = Object.values(baseline.totalRpc).reduce(
(sum, n) => sum + n,
0,
);
const newRpc = Object.values(rpc).reduce((sum, n) => sum + n, 0);
const output = {
...result,
oldRpc,
newRpc,
reduction: 1 - newRpc / oldRpc,
};
writeFileSync(
'/tmp/wealthledger-persistent-budget.json',
JSON.stringify(output, null, 2),
);
console.log(JSON.stringify(output));
// Delayed dependency cannot hold a data response or multiply one stale pass.
await fresh();
await app.get(PrismaService).tbankPortfolio.updateMany({
where: { accountId: 'baseline-0' },
data: { fetchedAt: new Date(0) },
});
const beforeStale = { ...rpc };
upstreamMode = 'delayed';
const startStale = performance.now();
await Promise.all(
Array.from({ length: 20 }, () =>
request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/baseline-0/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200),
),
);
const staleResponsesMs = performance.now() - startStale;
expect(staleResponsesMs).toBeLessThan(2000);
await settled();
expect(rpc.portfolio - beforeStale.portfolio).toBe(1);
expect(rpc.positions - beforeStale.positions).toBe(1);
await app
.get(PrismaService)
.tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } });
const coldStart = performance.now();
const missing = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/baseline-0/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect((missing.body as { data: unknown }).data).toBeNull();
const coldImportMs = performance.now() - coldStart;
expect(coldImportMs).toBeLessThan(2000);
await settled();
Object.assign(output, {
staleResponsesMs,
staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 },
coldImportMs,
});
writeFileSync(
'/tmp/wealthledger-persistent-budget.json',
JSON.stringify(output, null, 2),
);
expect(newRpc).toBe(0);
expect(evidence.filter((group) => group.p95 > 500)).toEqual([]);
expect(await app.get(PrismaService).tbankOperation.count()).toBe(300_000);
const idleRpc = { ...rpc };
await delay(1100);
expect(rpc).toEqual(idleRpc);
}, 300_000);
});
+247
View File
@@ -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();
});
});
+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();
});
});