chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 16:37:01 +03:00
committed by ksv741
parent 53b2794963
commit a970eaaf7a
179 changed files with 4485 additions and 2716 deletions
+9 -12
View File
@@ -1,13 +1,15 @@
import type { Server } from 'node:http';
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 { PrismaService } from '../src/prisma/prisma.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
describe('All accounts HTTP boundary', () => {
let app: INestApplication<Server>;
@@ -78,15 +80,10 @@ describe('All accounts HTTP boundary', () => {
.expect(200);
expect(response.body).not.toHaveProperty('token');
expect(response.body).not.toHaveProperty('generation');
if (path === 'portfolio')
expect(response.body).toMatchObject({
data: { positions: [] },
meta: { availability: 'complete' },
});
else
expect(response.body).toMatchObject({
meta: { availability: 'complete' },
});
expect(response.body).toMatchObject({
meta: { availability: 'complete' },
...(path === 'portfolio' ? { data: { positions: [] } } : {}),
});
},
);
+3 -2
View File
@@ -4,13 +4,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { StructuredLogger } from '../src/common/logging/structured-logger.service';
import { configureApp } from '../src/main';
import { AuthService } from '../src/modules/auth/application/auth.service';
import { PrismaService } from '../src/prisma/prisma.service';
type Authentication = { user: { email: string }; accessToken: string };
type Authentication = { accessToken: string; user: { email: string } };
type Problem = { code: string };
describe('Authentication (e2e)', () => {
@@ -181,7 +182,7 @@ describe('Authentication (e2e)', () => {
);
barrier.mockRestore();
expect(arrived).toBe(2);
expect(responses.map((response) => response.status).sort()).toEqual([
expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([
200, 401,
]);
const winner = responses.find((response) => response.status === 200);
+183 -93
View File
@@ -1,22 +1,25 @@
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { once } = require('node:events');
const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs');
const { createServer } = require('node:net');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const { once } = require('node:events');
const { createServer } = require('node:net');
const { randomUUID } = require('node:crypto');
const repo = resolve(__dirname, '..');
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
const baseline = resolve(process.argv[2]);
assert.notEqual(baseline, repo, 'Baseline must be a separate historical build');
for (const root of [baseline, repo]) {
assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`);
}
const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort();
const previous = migrations(baseline), current = migrations(repo);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
const migrations = (root) => readdirSync(join(root, 'prisma/migrations')).filter((name) => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort();
const current = migrations(repo), previous = migrations(baseline);
assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations');
assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8'));
const Database = require(join(repo, 'node_modules/better-sqlite3'));
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
@@ -26,97 +29,184 @@ const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'developme
delete env.RUST_LOG;
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
const rub = value => ({ currency: 'RUB', value });
const rub = (value) => ({ currency: 'RUB', value });
async function start(root) {
const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r));
origin=`http://127.0.0.1:${port}`;
const log = openSync(join(dir,`app-${++run}.log`),'w');
child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log);
for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));}
throw new Error('app startup timeout');
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const port = server.address().port;
await new Promise((r) => server.close(r));
origin = `http://127.0.0.1:${port}`;
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
const log = openSync(join(dir, `app-${++run}.log`), 'w');
child = spawn(process.execPath, [join(root, 'dist/src/main.js')], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}`, PORT: String(port) }, stdio: ['ignore', log, log] });
closeSync(log);
for (let i = 0; i < 150; i++) {
if (child.exitCode !== null) throw new Error(`app exited ${child.exitCode}`);
try {
const r = await fetch(`${origin}/api/v1/auth/me`);
if (r.status === 401) return;
} catch { /* Startup connection failures are retried until the deadline. */ }
await new Promise((r) => setTimeout(r, 100));
}
throw new Error('app startup timeout');
}
async function stop() {
if (!child) return;
const c = child;
child = null;
if (c.exitCode !== null) return;
const ended = once(c, 'exit');
c.kill('SIGTERM');
await ended;
}
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) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
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();
}
}
async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;}
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();
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();}
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);
const auth=await http('auth/register','POST',credentials); const owner=auth.user.id;
await stop();
const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close();
const beforeUsers=rows('User'), beforeSessions=rows('Session');
migrate(repo);
assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close();
await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken;
const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')};
let card=await http('bank-cards','POST',intent,token,201);
const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault);
await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token);
const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'};
let expense=await http('bank-cards/operations','POST',expenseIntent,token,201);
const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'};
const refund=await http('bank-cards/operations','POST',refundIntent,token,201);
const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'};
const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201);
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);
assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund);
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(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(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(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:['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;});
(async () => {
try {
migrate(baseline);
await start(baseline);
const auth = await http('auth/register', 'POST', credentials);
const owner = auth.user.id;
await stop();
const db = new Database(file);
db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString());
await db.backup(join(dir, 'before-migration.db'));
db.close();
const beforeSessions = rows('Session'), beforeUsers = rows('User');
migrate(repo);
assert.deepEqual(rows('User'), beforeUsers);
assert.deepEqual(rows('Session'), beforeSessions);
const migratedDb = new Database(file, { readonly: true });
assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used');
migratedDb.close();
await start(repo);
let token = (await http('auth/login', 'POST', credentials)).accessToken;
const intent = { id: randomUUID(), name: 'Долговременная', openingDate: '2026-01-01', openingBalance: rub('1000') };
let card = await http('bank-cards', 'POST', intent, token, 201);
const category = (await http('bank-cards/categories', 'GET', null, token)).find((x) => x.isDefault);
await http(`bank-cards/categories/${category.id}`, 'PATCH', { name: 'Сохранённая категория', version: category.version }, token);
const expenseIntent = { id: randomUUID(), kind: 'expense', cardId: card.id, amount: rub('100'), date: '2026-08-01' };
let expense = await http('bank-cards/operations', 'POST', expenseIntent, token, 201);
const refundIntent = { id: randomUUID(), kind: 'refund', cardId: card.id, originalExpenseId: expense.id, amount: rub('100'), date: '2026-09-01' };
const refund = await http('bank-cards/operations', 'POST', refundIntent, token, 201);
const deletedIntent = { id: randomUUID(), kind: 'topup', cardId: card.id, amount: rub('12.34'), date: '2026-09-01' };
const deleted = await http('bank-cards/operations', 'POST', deletedIntent, token, 201);
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);
assert.deepEqual(await http('bank-cards/operations', 'POST', refundIntent, token, 201), refund);
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(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(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(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();
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
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;
});
+33 -19
View File
@@ -1,10 +1,12 @@
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import {
@@ -28,12 +30,12 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
);
const money = (value: string) => ({ currency: 'RUB', value });
const auth = (
method: 'get' | 'post' | 'patch' | 'delete',
method: 'delete' | 'get' | 'patch' | 'post',
path: string,
user = owner,
) =>
request(app.getHttpServer())
[method]('/api/v1/bank-cards' + path)
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
request(app.getHttpServer())[method]('/api/v1/bank-cards' + path)
.set('Authorization', `Bearer ${token(user)}`);
const createCard = async (value = '1000', user = owner, extra = {}) =>
(
@@ -102,10 +104,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
if (app) await app.close();
});
it('requires auth and reads never seed data or need T-Bank', async () => {
for (const path of ['', '/categories', '/summary?month=2026-08'])
for (const path of ['', '/categories', '/summary?month=2026-08']) {
await request(app.getHttpServer())
.get('/api/v1/bank-cards' + path)
.expect(401);
}
expect((await auth('get', '').expect(200)).body).toEqual([]);
expect(await categories()).toEqual([]);
expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe(
@@ -241,10 +244,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
money('92233720368547758.08'),
{ currency: 'USD', value: '1' },
money('NaN'),
])
]) {
await auth('post', '/operations')
.send({ ...op(card.id), amount })
.expect(400);
}
for (const extra of [
{ date: '2026-02-30' },
{ date: '2099-01-01' },
@@ -252,10 +256,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
{ description: 'x'.repeat(501) },
{ kind: 'topup', categoryId: (await categories())[0].id },
{ kind: 'transfer', targetCardId: card.id },
])
]) {
await auth('post', '/operations')
.send(op(card.id, 'expense', '1', extra))
.expect(400);
}
expect(await db.bankCardOperation.count()).toBe(before);
for (const extra of [
{ name: ' ' },
@@ -264,7 +269,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
{ lastFour: '123' },
{ openingDate: '2026-02-30' },
{ openingBalance: money('-92233720368547758.09') },
])
]) {
await auth('post', '')
.send({
id: randomUUID(),
@@ -274,6 +279,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
...extra,
})
.expect(400);
}
});
it('returns nested money field errors without exposing input or writing a card', async () => {
const before = await db.bankCard.count();
@@ -310,8 +316,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
});
it('scopes every card/category/expense link and edit to owner', async () => {
const own = await createCard(),
foreign = await createCard('0', stranger);
const foreign = await createCard('0', stranger),
own = await createCard();
const foreignCategories = (
await auth('get', '/categories', stranger).expect(200)
).body as ExpenseCategoryDto[];
@@ -326,15 +332,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
'/' + foreign.id + '/expenses',
'/operations/' + foreignOp.id,
'/summary?month=2026-08&cardId=' + foreign.id,
])
]) {
await auth('get', path).expect(404);
}
for (const body of [
op(foreign.id),
op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }),
op(own.id, 'transfer', '1', { targetCardId: foreign.id }),
op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }),
])
]) {
await auth('post', '/operations').send(body).expect(404);
}
await auth('patch', '/' + foreign.id)
.send({ version: 1, archived: true })
.expect(404);
@@ -457,7 +465,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
}),
),
]);
expect(responses.map((r) => r.status).sort()).toEqual([201, 409]);
expect(responses.map((r) => r.status).sort((a, b) => a - b)).toEqual([201, 409]);
const body = op(card.id, 'refund', '400', {
date: '2026-09-01',
originalExpenseId: expense.id,
@@ -484,10 +492,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
{ amount: money('999') },
{ date: '2026-09-02' },
{ cardId: (await createCard()).id },
])
]) {
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, ...patch })
.expect(409);
}
await auth(
'delete',
`/operations/${expense.id}?version=${current.version}`,
@@ -501,6 +510,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
category.id,
);
});
// eslint-disable-next-line sonarjs/assertions-in-tests -- Every rejection is asserted through awaited Supertest .expect(400).
it('rejects invalid refund references, dates, immutable kind and purchase link', async () => {
const card = await createCard(),
other = await createCard();
@@ -513,8 +523,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
originalExpenseId: purchase.id,
date: '2026-07-01',
}),
])
]) {
await auth('post', '/operations').send(body).expect(400);
}
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
@@ -532,7 +543,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
await createOp(op(a.id, 'expense', '20'));
await createOp(op(a.id, 'expense', '30'));
const pages: BankOperationsPageDto[] = [];
for (let page = 1; page <= 3; page++)
for (let page = 1; page <= 3; page++) {
pages.push(
(
await auth(
@@ -541,6 +552,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
).expect(200)
).body as BankOperationsPageDto,
);
}
expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe(
3,
);
@@ -564,8 +576,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
for (const body of [
op(card.id),
op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }),
])
]) {
await auth('post', '/operations').send(body).expect(409);
}
await auth('patch', '/operations/' + expense.id)
.send({ version: 1, description: 'no' })
.expect(409);
@@ -625,7 +638,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
});
expect(
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <=
source.amountKopecks,
source.amountKopecks,
).toBe(true);
});
it('serializes opening-date changes and validates archived previous transfer sides', async () => {
@@ -755,8 +768,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
it('filters journal and summary inclusively across months and rejects invalid periods', async () => {
const card = await createCard();
const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02'];
for (const date of dates)
for (const date of dates) {
await createOp(op(card.id, 'expense', '10', { date }));
}
const range = 'from=2026-07-31&to=2026-08-01';
const history = (
await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200)
+4 -2
View File
@@ -1,3 +1,5 @@
import type { Server } from 'node:http';
import {
ConflictException,
INestApplication,
@@ -6,13 +8,13 @@ import {
} 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 { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service';
import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Bond HTTP contract', () => {
+10 -2
View File
@@ -6,6 +6,7 @@ const { randomUUID } = require('node:crypto');
const image = process.argv[2];
assert.ok(image, 'Pass a built backend image reference');
const name = `wealthledger-smoke-${randomUUID()}`;
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
const environment = [
'-e', 'DATABASE_URL=file:/data/wealthledger.db',
@@ -64,6 +65,13 @@ async function ready() {
`);
console.log('PASS image: fresh/existing migration, non-root read-only runtime, auth, persistence, proto assets');
} finally {
try { docker('rm', '-f', name); } finally { docker('volume', 'rm', name); }
try {
docker('rm', '-f', name);
} finally {
docker('volume', 'rm', name);
}
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+1 -1
View File
@@ -1,5 +1,5 @@
const { join, dirname, basename } = require('node:path');
const { tmpdir } = require('node:os');
const { join, dirname, basename } = require('node:path');
const dir = process.env.WL_E2E_DIRECTORY;
if (
!dir ||
+47
View File
@@ -0,0 +1,47 @@
const { ESLint } = require('eslint');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const eslint = new ESLint();
// Protect the quality gate's file scopes when configs or plugin versions change.
test('typed application code enforces async and unsafe-type checks', async () => {
const [result] = await eslint.lintText(
'export const unsafe: any = 1; Promise.resolve(unsafe);',
{ filePath: 'src/main.ts' },
);
const rules = new Set(result.messages.map(({ ruleId }) => ruleId));
assert.ok(rules.has('@typescript-eslint/no-explicit-any'));
assert.ok(rules.has('@typescript-eslint/no-floating-promises'));
});
test('framework and runtime rules reach their intended files', async () => {
const app = await eslint.calculateConfigForFile('src/main.ts');
const spec = await eslint.calculateConfigForFile('test/app.e2e-spec.ts');
const script = await eslint.calculateConfigForFile('scripts/generate-openapi.cjs');
const config = await eslint.calculateConfigForFile('eslint.config.mjs');
assert.equal(app.rules['@darraghor/nestjs-typed/validation-pipe-should-use-forbid-unknown'][0], 2);
assert.equal(spec.rules['jest/no-focused-tests'][0], 2);
assert.equal(app.rules['jest/no-focused-tests'], undefined);
assert.equal(app.languageOptions.globals.expect, undefined);
assert.equal(config.languageOptions.globals.require, 'off');
for (const profile of [app, spec, script, config]) {
assert.equal(profile.rules['n/no-deprecated-api'][0], 2);
assert.equal(profile.rules['security/detect-eval-with-expression'][0], 1);
assert.equal(profile.rules['regexp/no-super-linear-backtracking'][0], 2);
assert.equal(profile.rules['sonarjs/cognitive-complexity'][0], 2);
assert.equal(profile.rules['import-x/no-unresolved'][0], 2);
assert.equal(profile.rules['perfectionist/sort-imports'][0], 2);
assert.equal(profile.rules['@stylistic/semi'][0], 2);
}
});
test('focused tests are rejected and generated code is excluded', async () => {
const [result] = await eslint.lintText(
'it.only(\'example\', () => { expect(1).toBe(1); });',
{ filePath: 'test/app.e2e-spec.ts' },
);
assert.ok(result.messages.some(({ ruleId }) => ruleId === 'jest/no-focused-tests'));
assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts'));
assert.ok(await eslint.isPathIgnored('dist/src/main.js'));
});
+7 -6
View File
@@ -1,19 +1,20 @@
// 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');
TbankTokenService,
} = require('../../dist/src/integrations/tbank/tbank-token.service');
const {
BrokerageOperationsSyncService,
} = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service');
const {
BrokerageOperationsRepository,
} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository');
const { PrismaService } = require('../../dist/src/prisma/prisma.service');
const mode = process.argv[2];
const prisma = new PrismaService(new ConfigService(process.env));
const repository = new BrokerageOperationsRepository(prisma);
+6 -1
View File
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { createOpenApiDocument } from '../src/openapi';
@@ -56,6 +57,7 @@ describe('OpenAPI contract (integration)', () => {
['IncomeInvestmentScreenDto', 'income'],
['EventsInvestmentScreenDto', 'events'],
]) {
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
const schema = schemas?.[model];
assert(schema && !('$ref' in schema));
expect(schema.required).toContain('screen');
@@ -84,7 +86,7 @@ describe('OpenAPI contract (integration)', () => {
const parameters =
document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get
?.parameters;
for (const name of ['from', 'to'])
for (const name of ['from', 'to']) {
expect(
parameters?.find(
(parameter) => 'name' in parameter && parameter.name === name,
@@ -94,6 +96,7 @@ describe('OpenAPI contract (integration)', () => {
in: 'query',
schema: { format },
});
}
}
}
});
@@ -147,6 +150,7 @@ describe('OpenAPI contract (integration)', () => {
const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-'));
try {
const networkGuard = resolve(directory, 'no-network.cjs');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
await writeFile(
networkGuard,
`
@@ -170,6 +174,7 @@ describe('OpenAPI contract (integration)', () => {
},
);
const generated = JSON.parse(
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
await readFile(resolve(directory, 'openapi.json'), 'utf8'),
) as unknown;
expect(generated).toEqual(createOpenApiDocument(app));
+8 -5
View File
@@ -1,13 +1,15 @@
import type { Server } from 'node:http';
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 { PrismaService } from '../src/prisma/prisma.service';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Portfolio structure HTTP contract', () => {
let app: INestApplication<Server>;
@@ -72,8 +74,9 @@ describe('Portfolio structure HTTP contract', () => {
!(await app
.get(PrismaService)
.tbankSyncWork.count({ where: { state: 'running' } }))
)
) {
return;
}
await delay(5);
}
throw new Error('Portfolio did not settle');
+14 -8
View File
@@ -12,7 +12,9 @@ const { join, resolve } = require('node:path');
const cwd = resolve(__dirname, '..');
const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-'));
const sentinel = join(evidence, 'user.db');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
writeFileSync(sentinel, 'fictional user database: must never be opened');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
const before = readFileSync(sentinel);
const databases = new Set();
async function check(name, overrides = {}, signal) {
@@ -43,6 +45,7 @@ async function check(name, overrides = {}, signal) {
child.once('error', reject);
child.once('close', resolve);
});
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
writeFileSync(join(evidence, `${name}.log`), log);
const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map(
(match) => match[1],
@@ -51,21 +54,23 @@ async function check(name, overrides = {}, signal) {
for (const dir of dirs) {
assert.ok(!databases.has(dir), 'runners must never share databases');
databases.add(dir);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
assert.ok(!existsSync(dir), `runner must clean up ${dir}`);
}
assert.deepEqual(
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
readFileSync(sentinel),
before,
'user database must be untouched',
);
const expected =
signal === 'SIGINT'
? 130
: signal === 'SIGTERM'
? 143
: overrides.PRISMA_SCHEMA_ENGINE_BINARY
? 1
: 0;
signal === 'SIGINT' ?
130 :
signal === 'SIGTERM' ?
143 :
overrides.PRISMA_SCHEMA_ENGINE_BINARY ?
1 :
0;
assert.equal(code, expected, `See ${name}.log`);
console.log(`PASS ${name}: ${dirs.length} isolated databases`);
}
@@ -76,8 +81,9 @@ async function check(name, overrides = {}, signal) {
check('parallel-1'),
check('parallel-2', { RUST_LOG: 'error' }),
]);
for (const result of parallel)
for (const result of parallel) {
if (result.status === 'rejected') throw result.reason;
}
await check('migration-failure', {
PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'),
});
+9 -8
View File
@@ -9,13 +9,14 @@ const available = readdirSync(__dirname)
.sort();
const requested = process.argv.slice(2);
const suites =
requested.length === 0
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
: requested.length === 1 && requested[0] === '--all'
? available
: requested;
if (suites.some((suite) => !available.includes(suite)))
requested.length === 0 ?
['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] :
requested.length === 1 && requested[0] === '--all' ?
available :
requested;
if (suites.some((suite) => !available.includes(suite))) {
throw new Error('Unknown e2e suite');
}
let child;
let interrupted;
let killTimer;
@@ -46,9 +47,9 @@ function run(args, env) {
child.once('close', (code, signal) => {
clearTimeout(killTimer);
child = undefined;
if (code !== 0 || signal)
if (code !== 0 || signal) {
reject(new Error(`Child failed: ${signal ?? code}`));
else resolve();
} else resolve();
});
});
}
+4 -2
View File
@@ -1,3 +1,5 @@
import type { Server } from 'node:http';
import {
ConflictException,
INestApplication,
@@ -6,13 +8,13 @@ import {
} 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 { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service';
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Stock HTTP contract', () => {
+23 -12
View File
@@ -1,14 +1,15 @@
import { ConfigService } from '@nestjs/config';
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 { CandleService } from '../src/modules/investments/instruments/application/candle.service';
import {
candleGaps,
type CandleRange,
} from '../src/modules/investments/instruments/domain/candle-coverage';
import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository';
import { PrismaService } from '../src/prisma/prisma.service';
describe('atomic shared candle reconciliation (SQLite)', () => {
const prisma = new PrismaService(
@@ -56,8 +57,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
);
async function settled() {
for (let i = 0; i < 500; i++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) {
return;
}
await delay(5);
}
throw new Error('Candle work did not settle');
@@ -225,10 +227,11 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
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([]);
@@ -353,13 +356,13 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
...sources,
ensureDetail: jest.fn().mockResolvedValue(history),
};
if (phase === 'before loading')
if (phase === 'before loading') {
selectedSources.ensureDetail.mockImplementationOnce(async () => {
entered();
await blocked;
return history;
});
else {
} else {
const publish = repository.publish.bind(repository);
jest
.spyOn(repository, 'publish')
@@ -377,8 +380,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
Awaited<ReturnType<CandleRepository['read']>> | undefined;
upstream.getCandles.mockImplementation(
async (_token, _uid, _from, to: Date) => {
if (laterFailure && forcedStates.length)
if (laterFailure && forcedStates.length) {
throw new Error('Forced reconciliation second window unavailable');
}
if (to.valueOf() === forcedAt.valueOf()) {
beforeForced = await repository.read('uid', fullRange);
const work = await repository.work(
@@ -424,12 +428,16 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
await settled();
if (laterFailure) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(beforeForced).toBeDefined();
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await repository.read('uid', fullRange)).toEqual(beforeForced);
} else
} else {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect((await repository.read('uid', covered)).data).toEqual([
{ ...old, close: '200.000000001' },
]);
}
expect(forcedStates).toEqual(['running']);
expect(
await repository.work(userId, 'revision', 'candles:uid:all'),
@@ -442,7 +450,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
now: forcedAt.toISOString(),
},
});
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
if (userId === owner.userId) expect(forcedId).toBe(ordinaryId);
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
else expect(forcedId).not.toBe(ordinaryId);
},
);
@@ -495,8 +505,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
{ instrumentUid: 'not-supported', type: 'FUND' },
{ instrumentUid: null, type: 'BOND' },
]);
for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++)
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',
@@ -510,7 +521,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
);
expect(
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
).toEqual(new Set(['uid', 'opened']));
).toEqual(new Set(['opened', 'uid']));
const count = upstream.getCandles.mock.calls.length;
await service.prefetch('owner', 'revision', [
{ instrumentUid: 'uid', type: 'STOCK' },
+9 -5
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { ConfigService } from '@nestjs/config';
import assert from 'node:assert/strict';
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';
@@ -10,6 +10,7 @@ import {
type CatalogInstrument,
type CatalogQuery,
} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import { PrismaService } from '../src/prisma/prisma.service';
// Real transactions prove query coverage, private work ownership and late-publication fences.
describe('shared catalog discovery (SQLite)', () => {
@@ -52,18 +53,20 @@ describe('shared catalog discovery (SQLite)', () => {
});
async function settled() {
for (let i = 0; i < 300; i++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
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'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
afterAll(async () => prisma.$disconnect());
beforeEach(async () => {
@@ -199,7 +202,7 @@ describe('shared catalog discovery (SQLite)', () => {
await service.read('other', query);
await delay(10);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace')
if (action === 'replace') {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
@@ -208,6 +211,7 @@ describe('shared catalog discovery (SQLite)', () => {
connectedAt: new Date(),
},
});
}
blocked.resolve([instrument]);
await settled();
expect(await prisma.tbankCatalogDiscovery.count()).toBe(0);
+44 -33
View File
@@ -1,34 +1,33 @@
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 type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
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 { MoexClient } from '../src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import {
type BondMetricsResourceDto,
type BondQuoteResourceDto, type BondSearchResourceDto,
} from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import type {
StockResourceDto,
StockSearchResourceDto,
import {
type StockCandlesResourceDto,
type StockCompanyResourceDto,
type StockQuoteResourceDto,
type StockResourceDto,
type StockSearchResourceDto,
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import { PrismaService } from '../src/prisma/prisma.service';
// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races.
describe('local stock/bond search (SQLite HTTP)', () => {
@@ -89,8 +88,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
await delay(10);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
)
) {
return;
}
}
}
throw new Error('Catalog sources did not settle');
@@ -109,10 +109,11 @@ describe('local stock/bond search (SQLite HTTP)', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
@@ -162,7 +163,7 @@ describe('local stock/bond search (SQLite HTTP)', () => {
})),
),
);
for (const userId of ['owner', 'other'])
for (const userId of ['owner', 'other']) {
await prisma.tbankConnection.create({
data: {
userId,
@@ -173,6 +174,7 @@ describe('local stock/bond search (SQLite HTTP)', () => {
connectedAt: new Date(),
},
});
}
});
afterAll(async () => app?.close());
@@ -187,8 +189,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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++)
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');
@@ -329,16 +332,18 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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++)
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',
@@ -364,10 +369,11 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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++)
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')
if (action === 'replace') {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
@@ -376,6 +382,7 @@ describe('local stock/bond search (SQLite HTTP)', () => {
connectedAt: new Date(),
},
});
}
detail.resolve(share);
await settled();
expect(
@@ -451,8 +458,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
]);
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++)
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' },
@@ -575,8 +583,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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;
@@ -628,8 +637,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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++)
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();
@@ -858,8 +868,9 @@ describe('local stock/bond search (SQLite HTTP)', () => {
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();
+11 -7
View File
@@ -1,14 +1,16 @@
import type { Server } from 'node:http';
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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
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>;
@@ -35,10 +37,11 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
@@ -57,7 +60,7 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
.expect(401);
expect(upstream.validateToken).not.toHaveBeenCalled();
const response = await put(' synthetic-token ').expect(200);
expect(Object.keys(response.body as object).sort()).toEqual([
expect(Object.keys(response.body as object).sort((a, b) => a.localeCompare(b))).toEqual([
'connected',
'connectedAt',
]);
@@ -145,11 +148,12 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
await expect(
prisma.tbankOperation.create({ data: operation }),
).rejects.toMatchObject({ code: 'P2003' });
for (let index = 0; index < 2; index += 1)
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 },
+9 -6
View File
@@ -1,19 +1,21 @@
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import assert from 'node:assert/strict';
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 { configureApp } from '../src/main';
import {
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto,
} from '../src/modules/investments/income/income-analytics.dto';
import { moscowDate } from '../src/modules/investments/investment-calendar';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import { PrismaService } from '../src/prisma/prisma.service';
// Financial proof uses real persisted rows/coverage and the configured HTTP boundary.
describe('local income completeness and exact money (SQLite)', () => {
@@ -102,7 +104,7 @@ describe('local income completeness and exact money (SQLite)', () => {
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'],
] as const)
] as const) {
await prisma.tbankOperation.create({
data: {
userId: 'income-owner',
@@ -119,6 +121,7 @@ describe('local income completeness and exact money (SQLite)', () => {
},
},
});
}
}
});
it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => {
+21 -14
View File
@@ -1,18 +1,21 @@
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import type { Server } from 'node:http';
import request from 'supertest';
import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
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';
import { configureApp } from '../src/main';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
import { PrismaService } from '../src/prisma/prisma.service';
const uid = '11111111-1111-4111-8111-111111111111';
const period = 'from=2025-01-01&to=2025-01-31';
@@ -48,10 +51,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
afterAll(async () => app?.close());
beforeEach(async () => {
@@ -84,7 +88,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
fetchedAt: new Date(),
},
});
for (const id of ['1', '2', '3'])
for (const id of ['1', '2', '3']) {
await prisma.tbankOperation.create({
data: {
userId: 'owner',
@@ -103,10 +107,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
},
},
});
}
}
});
it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => {
let cursor: string | null = null;
let cursor: null | string = null;
const ids: string[] = [];
do {
const response = await get(
@@ -186,7 +191,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
})),
).toEqual(expected);
const items: { id: string; occurredAt: string }[] = [];
let cursor: string | null = null;
let cursor: null | string = null;
do {
const response = await request(app.getHttpServer())
.get(
@@ -235,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
data: { occurredAt: new Date(observedAt.valueOf() - 1000) },
});
const query =
periodKind === 'default'
? ''
: `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
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(
@@ -418,10 +423,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(cold.meta.availability).toBe('missing');
await get('sync=observe', 'a').expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
// eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation.
unblock({ hasNext: false, items: [] });
for (let index = 0; index < 300; index += 1) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) {
break;
}
await delay(5);
}
const count = upstream.getOperationsByCursor.mock.calls.length;
+15 -11
View File
@@ -1,20 +1,22 @@
import type { Server } from 'node:http';
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 { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import {
TbankClient,
type TbankAccount,
TbankClient,
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 { configureApp } from '../src/main';
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
import { PrismaService } from '../src/prisma/prisma.service';
function deferred<T>() {
let resolve: (value: T) => void = () => {
@@ -72,10 +74,11 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
@@ -336,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
]);
await put().expect(200);
upstream.getPortfolio.mockImplementation((_token, id) =>
id === 'second'
? Promise.reject(new Error('synthetic unavailable'))
: Promise.resolve({
id === 'second' ?
Promise.reject(new Error('synthetic unavailable')) :
Promise.resolve({
positions: [
{
instrumentUid: uid,
@@ -351,8 +354,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
);
await get('brokerage-accounts').expect(200);
for (let index = 0; index < 200; index += 1) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) {
break;
}
await delay(5);
}
const summaries = (await get('brokerage-accounts?sync=observe').expect(200))
+17 -12
View File
@@ -1,15 +1,17 @@
import assert from 'node:assert/strict';
import { ConfigService } from '@nestjs/config';
import assert from 'node:assert/strict';
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 type { TbankInstrument } from '../src/integrations/tbank/tbank.client';
import {
InstrumentMetadataRepository,
metadataKey,
} from '../src/integrations/tbank/instrument-metadata.repository';
import type { TbankInstrument } from '../src/integrations/tbank/tbank.client';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { PrismaService } from '../src/prisma/prisma.service';
// Shared publication is proven against real FK/transaction/connection boundaries.
describe('durable shared instrument metadata (SQLite)', () => {
@@ -55,18 +57,20 @@ describe('durable shared instrument metadata (SQLite)', () => {
});
async function settled() {
for (let index = 0; index < 300; index++) {
if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } })))
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'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
afterAll(async () => prisma.$disconnect());
beforeEach(async () => {
@@ -152,7 +156,7 @@ describe('durable shared instrument metadata (SQLite)', () => {
await delay(10);
await service.read('other', identity);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace')
if (action === 'replace') {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
@@ -161,6 +165,7 @@ describe('durable shared instrument metadata (SQLite)', () => {
connectedAt: new Date(),
},
});
}
blocked.resolve(known);
await settled();
expect(await prisma.tbankInstrument.count()).toBe(0);
@@ -346,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => {
await service.read('owner', identity);
await settled();
const identities = Array.from({ length: 1000 }, (_, index) =>
index
? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` }
: identity,
index ?
{ instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } :
identity,
);
const saved = await service.savedMany(identities);
expect(saved.size).toBe(1000);
+10 -5
View File
@@ -1,7 +1,9 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- The migration test intentionally uses node:sqlite from the pinned Node 24 runtime.
import { DatabaseSync } from 'node:sqlite';
import { PrismaClient } from '../generated/prisma/client';
const migration = '20260924170000_persist_tbank_data';
@@ -25,8 +27,10 @@ describe('T-Bank additive migration on real SQLite', () => {
db.exec('PRAGMA foreign_keys=ON');
for (const name of readdirSync(migrations)
.filter((name) => name < migration && /^\d/.test(name))
.sort())
.sort((a, b) => a.localeCompare(b))) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8'));
}
if (populated) {
db.exec(`
INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES
@@ -90,7 +94,7 @@ describe('T-Bank additive migration on real SQLite', () => {
adapter: new PrismaBetterSqlite3({ url: `file:${file}` }),
});
try {
if (!populated)
if (!populated) {
await prisma.user.create({
data: {
id: 'owner',
@@ -104,6 +108,7 @@ describe('T-Bank additive migration on real SQLite', () => {
},
},
});
}
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
@@ -1,6 +1,7 @@
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../src/prisma/prisma.service';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import { PrismaService } from '../src/prisma/prisma.service';
// Real transactions are the boundary: staged pages never become financial
// results, and a failed multi-account publication must preserve every old row.
@@ -106,11 +107,12 @@ describe('Operations staging and reconciliation (isolated SQLite)', () => {
}
})(),
]);
for (const snapshot of snapshots)
for (const snapshot of snapshots) {
expect([
['10', '10'],
['20', '20'],
]).toContainEqual(snapshot);
}
await reader.$disconnect();
await reader.$connect();
expect(
+25 -11
View File
@@ -3,15 +3,17 @@ 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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
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';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import { PrismaService } from '../src/prisma/prisma.service';
function deferred<T>() {
let resolve: (value: T) => void = () => {
@@ -154,11 +156,12 @@ describe('Request-driven operations synchronization (SQLite)', () => {
upstream.getOperationsByCursor
.mockReturnValueOnce(gate.promise)
.mockImplementation((_token, request) => {
if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000)
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(
@@ -332,8 +335,9 @@ describe('Request-driven operations synchronization (SQLite)', () => {
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: [
@@ -431,22 +435,29 @@ describe('Request-driven operations synchronization (SQLite)', () => {
await delay(10);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
if (checkpoint === 'after-publish') {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await prisma.tbankOperation.count()).toBe(1);
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await prisma.tbankSyncStage.count()).toBe(0);
} else {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await prisma.tbankOperation.count()).toBe(0);
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0);
upstream.getOperationsByCursor.mockResolvedValueOnce(complete);
await service.request('owner', [january], true);
await state(handle, 'complete');
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(
upstream.getOperationsByCursor.mock.calls[0][1].cursor,
).toBeUndefined();
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(await prisma.tbankOperation.count()).toBe(1);
}
} finally {
if (child.exitCode === null && child.signalCode === null)
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
await exited;
}
},
@@ -492,8 +503,9 @@ describe('Request-driven operations synchronization (SQLite)', () => {
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),
@@ -507,8 +519,8 @@ describe('Request-driven operations synchronization (SQLite)', () => {
it('lets new screen work overtake history between provider pages', async () => {
const first = deferred<{
hasNext: boolean;
nextCursor: string;
items: never[];
nextCursor: string;
}>();
upstream.getOperationsByCursor
.mockReturnValueOnce(first.promise)
@@ -542,8 +554,9 @@ describe('Request-driven operations synchronization (SQLite)', () => {
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);
@@ -675,8 +688,9 @@ describe('Request-driven operations synchronization (SQLite)', () => {
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 },
@@ -708,7 +722,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
});
});
function requiredHandle(value: string | null): string {
function requiredHandle(value: null | string): string {
if (!value) throw new Error('Expected sync handle');
return value;
}
+54 -33
View File
@@ -1,23 +1,29 @@
import type { NextFunction, Request, Response } from 'express';
import type { Server } from 'node:http';
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 assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { cpus, tmpdir, totalmem } from 'node:os';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
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.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
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';
const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-'));
// Same navigation and synthetic sources as the saved baseline; isolated SQLite only.
describe('T-Bank persisted read budget', () => {
@@ -52,9 +58,9 @@ describe('T-Bank persisted read budget', () => {
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;
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}`,
@@ -69,15 +75,17 @@ describe('T-Bank persisted read budget', () => {
};
}).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds));
const rpc: Record<string, number> = {};
let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready';
let upstreamMode: 'delayed' | 'failed' | 'ready' = 'ready';
let sourceGate: Promise<void> | undefined;
let releaseSource = () => {};
const serverTimes: number[] = [];
async function counted<T>(name: string, value: T): Promise<T> {
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
rpc[name] = (rpc[name] ?? 0) + 1;
if (upstreamMode === 'delayed') await sourceGate;
if (upstreamMode === 'failed')
if (upstreamMode === 'failed') {
return Promise.reject(new Error('Synthetic outage'));
}
return Promise.resolve(value);
}
const upstream = {
@@ -261,7 +269,7 @@ describe('T-Bank persisted read budget', () => {
historyBoundary: 'openedOn',
},
});
for (let offset = 0; offset < operations.length; offset += 1000)
for (let offset = 0; offset < operations.length; offset += 1000) {
await prisma.tbankOperation.createMany({
data: operations.slice(offset, offset + 1000).map((row) => ({
...scope,
@@ -272,6 +280,7 @@ describe('T-Bank persisted read budget', () => {
payload: row,
})),
});
}
await prisma.tbankOperationCoverage.create({
data: {
...scope,
@@ -289,9 +298,9 @@ describe('T-Bank persisted read budget', () => {
uid: instrument.uid,
ticker: instrument.ticker,
instrumentType:
instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE'
? 'share'
: 'bond',
instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ?
'share' :
'bond',
metadata: instrument,
fetchedAt: now,
},
@@ -329,8 +338,8 @@ describe('T-Bank persisted read budget', () => {
],
});
}
expect(await prisma.tbankOperation.count()).toBe(300_000);
expect(await prisma.tbankCandle.count()).toBe(1_000_000);
assert.equal(await prisma.tbankOperation.count(), 300_000);
assert.equal(await prisma.tbankCandle.count(), 1_000_000);
}, 300_000);
afterAll(async () => app?.close());
@@ -368,8 +377,9 @@ describe('T-Bank persisted read budget', () => {
!(await app
.get(PrismaService)
.tbankSyncWork.count({ where: { state: 'running' } }))
)
) {
return;
}
}
throw new Error('Import did not settle');
}
@@ -397,6 +407,7 @@ describe('T-Bank persisted read budget', () => {
await fresh();
}
const warmupRpc = { ...rpc };
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
for (const key of Object.keys(rpc)) rpc[key] = 0;
await app.close();
await start();
@@ -411,21 +422,26 @@ describe('T-Bank persisted read budget', () => {
.set('Authorization', `Bearer ${token}`)
.expect(200);
const body = response.body as {
meta: { availability: string };
data: unknown;
meta: { availability: string };
};
if (route.includes('candles?period=all'))
if (route.includes('candles?period=all')) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect((body.data as { candles: unknown[] }).candles).toHaveLength(
5000,
);
}
expect({ route, availability: body.meta.availability }).toEqual({
route,
availability: 'complete',
});
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
if (!responses[route]) {
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
responses[route] = body;
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
writeFileSync(
'/tmp/wealthledger-browser-responses.json',
join(evidenceDirectory, 'browser-responses.json'),
JSON.stringify(responses),
);
}
@@ -439,8 +455,9 @@ describe('T-Bank persisted read budget', () => {
serverTimes.length = 0;
const before = { ...rpc };
const samples = [];
for (let batch = 0; batch < 10; batch += 1)
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);
@@ -459,6 +476,7 @@ describe('T-Bank persisted read budget', () => {
rpc: Object.fromEntries(
Object.entries(rpc).map(([key, count]) => [
key,
// eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input.
count - (before[key] ?? 0),
]),
),
@@ -469,13 +487,13 @@ describe('T-Bank persisted read budget', () => {
runtime: process.version,
hardware: {
cpuQuota:
process.platform === 'linux'
? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim()
: null,
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,
process.platform === 'linux' ?
readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() :
null,
cpu: cpus()[0]?.model,
cpus: cpus().length,
memoryBytes: totalmem(),
@@ -495,6 +513,7 @@ describe('T-Bank persisted read budget', () => {
evidence,
totalRpc: { ...rpc },
warmupRpc,
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds.
databaseBytes: statSync(
String(process.env.DATABASE_URL).replace('file:', ''),
).size,
@@ -513,11 +532,12 @@ describe('T-Bank persisted read budget', () => {
newRpc,
reduction: 1 - newRpc / oldRpc,
};
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
writeFileSync(
'/tmp/wealthledger-persistent-budget.json',
join(evidenceDirectory, 'persistent-budget.json'),
JSON.stringify(output, null, 2),
);
console.log(JSON.stringify(output));
console.log(JSON.stringify({ ...output, evidenceDirectory }));
// Hold the source until HTTP reads finish: proves independence without a
// wall-clock race against the runner's throughput. The p95 gate stays below.
await fresh();
@@ -572,8 +592,9 @@ describe('T-Bank persisted read budget', () => {
staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 },
coldImportMs,
});
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory.
writeFileSync(
'/tmp/wealthledger-persistent-budget.json',
join(evidenceDirectory, 'persistent-budget.json'),
JSON.stringify(output, null, 2),
);
expect(newRpc).toBe(0);
+21 -13
View File
@@ -1,15 +1,17 @@
import type { Server } from 'node:http';
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 { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto';
import { PrismaService } from '../src/prisma/prisma.service';
describe('screen sync HTTP and SQLite', () => {
let app: INestApplication<Server>;
@@ -51,10 +53,11 @@ describe('screen sync HTTP and SQLite', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
@@ -62,7 +65,7 @@ describe('screen sync HTTP and SQLite', () => {
upstream.getOpenAccounts.mockResolvedValue([]);
upstream.getPortfolio.mockResolvedValue({ positions: [] });
upstream.getPositions.mockResolvedValue({ money: [] });
for (const userId of ['owner', 'other'])
for (const userId of ['owner', 'other']) {
await prisma.tbankConnection.create({
data: {
userId,
@@ -72,6 +75,7 @@ describe('screen sync HTTP and SQLite', () => {
membershipRevision: 1,
},
});
}
});
afterAll(async () => app.close());
it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => {
@@ -145,9 +149,9 @@ describe('screen sync HTTP and SQLite', () => {
screen: 'stock',
instrumentUid: '00000000-0000-4000-8000-000000000001',
candleRange: {
from: '2026-02-01',
to: '2026-01-01',
jobs: [],
'from': '2026-02-01',
'to': '2026-01-01',
'jobs': [],
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
},
},
@@ -158,7 +162,7 @@ describe('screen sync HTTP and SQLite', () => {
);
const problem = response.body as {
code: string;
errors: { pointer: string; detail: string }[];
errors: { detail: string; pointer: string }[];
};
expect(problem.code).toBe('VALIDATION_ERROR');
expect(problem.errors.length).toBeGreaterThan(0);
@@ -167,15 +171,19 @@ describe('screen sync HTTP and SQLite', () => {
expect(typeof error.detail).toBe('string');
}
if ('candleRange' in scope) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(
problem.errors.filter(
(error) => error.pointer === '#/candleRange/jobs',
),
).toHaveLength(1);
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(problem.errors).toContainEqual({
pointer: '#/candleRange/~0bad~1path',
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
detail: expect.any(String) as unknown,
});
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(JSON.stringify(response.body)).not.toContain(
'SYNTHETIC_VALIDATION_SECRET',
);
@@ -350,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => {
}),
)
.mockImplementation(() =>
outcome === 'failed'
? Promise.reject(new Error('Synthetic outage'))
: Promise.resolve({ hasNext: false, items: [] }),
outcome === 'failed' ?
Promise.reject(new Error('Synthetic outage')) :
Promise.resolve({ hasNext: false, items: [] }),
);
const accept = async (from: string, to: string) => {
const response = await post({
+27 -18
View File
@@ -1,23 +1,26 @@
import type { Server } from 'node:http';
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 type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
import { MoexClient } from '../src/integrations/moex/moex.api';
import {
TbankClient,
TbankTokenService,
tbankSourceTtlMs,
TbankTokenService,
} from '../src/integrations/tbank/tbank.api';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { configureApp } from '../src/main';
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';
import { PrismaService } from '../src/prisma/prisma.service';
const shareUid = '00000000-0000-4000-8000-000000000001';
const bondUid = '00000000-0000-4000-8000-000000000002';
@@ -86,8 +89,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
await delay(10);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
)
) {
return;
}
}
}
throw new Error('Forecast dependencies did not settle');
@@ -139,10 +143,11 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other'])
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await settled();
@@ -236,8 +241,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
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++)
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
await delay(5);
}
await events().expect(200);
release([]);
await settled();
@@ -290,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
.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([]),
uid === newUid ?
Promise.reject(new Error('new calendar unavailable')) :
Promise.resolve([]),
);
const pending = (await events(false).expect(200))
.body as UpcomingEventsResourceDto;
@@ -450,12 +456,13 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
const path = `${kind}/${uid}/${payout}`;
const read = async () =>
(await get(`${path}?sync=observe`).expect(200)).body as
StockDividendsResourceDto | BondCouponsResourceDto;
BondCouponsResourceDto | StockDividendsResourceDto;
await get(path).expect(200);
await settled();
const before = await read();
if (!before.data?.positionAt)
if (!before.data?.positionAt) {
throw new Error('Expected a saved position');
}
expect(before.data.positionIsStale).toBe(false);
const saved = await prisma.tbankForecast.findFirstOrThrow();
const expiresAt =
@@ -504,8 +511,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
} finally {
publish.mockRestore();
}
for (const rpc of Object.values(upstream))
for (const rpc of Object.values(upstream)) {
expect(rpc).not.toHaveBeenCalled();
}
expect(moex.bondEvents).not.toHaveBeenCalled();
} finally {
clock.mockRestore();
@@ -615,8 +623,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
}),
);
await events(false).expect(200);
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++)
for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) {
await delay(5);
}
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
release([
{