Files
backend/test/bank-cards-durability.cjs
T
ksv741 e6f58ef0ee feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
2026-09-12 18:34:18 +03:00

75 lines
7.8 KiB
JavaScript

const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { mkdtempSync, openSync, closeSync, writeFileSync } = require('node:fs');
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]);
const Database = require(join(repo, 'node_modules/better-sqlite3'));
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
let file = join(dir, 'history.db');
const env = { ...process.env, DATABASE_URL: `file:${file}`, RUST_LOG: 'info', NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
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');
}
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();}}
(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);
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(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
console.log(`PASS durability ${dir}`);
}finally{await stop();}
})().catch(error=>{console.error(error);process.exitCode=1;});