Files
backend/test/tbank-migration.e2e-spec.ts
T
ksv741 d6e6c4ace5
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
feat!: persist T-Bank data and synchronize reads
Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
2026-09-26 21:29:15 +03:00

254 lines
9.2 KiB
TypeScript

import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../generated/prisma/client';
const migration = '20260924170000_persist_tbank_data';
const migrations = join(__dirname, '../prisma/migrations');
const preserved = [
'User',
'Session',
'BankCard',
'ExpenseCategory',
'BankCardOperation',
] as const;
describe('T-Bank additive migration on real SQLite', () => {
it.each([false, true])(
'preserves existing data (populated=%s), keys and decimal strings after reopen',
async (populated) => {
const directory = process.env.WL_E2E_DIRECTORY;
if (!directory) throw new Error('Isolated SQLite runner required');
const file = join(directory, `tbank-migration-${populated}.db`);
let db = new DatabaseSync(file);
db.exec('PRAGMA foreign_keys=ON');
for (const name of readdirSync(migrations)
.filter((name) => name < migration && /^\d/.test(name))
.sort())
db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8'));
if (populated) {
db.exec(`
INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES
('owner','owner@example.invalid','synthetic',0,0),
('other','other@example.invalid','synthetic',0,0);
INSERT INTO Session (id,userId,refreshTokenHash,expiresAt,createdAt,updatedAt)
VALUES ('session','owner','synthetic-hash',9000000000000,0,0);
INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES
('owner','synthetic-ciphertext-preserve-exactly',0),
('other','different-synthetic-ciphertext',0);
INSERT INTO BankCard (id,userId,name,openingBalanceKopecks,openingDate,createFingerprint,createdAt,updatedAt)
VALUES ('card','owner','Synthetic',9007199254740993,'2026-01-01','card-intent',0,0);
INSERT INTO ExpenseCategory (id,userId,name,nameKey) VALUES ('category','owner','Synthetic','synthetic');
INSERT INTO BankCardOperation (id,userId,kind,cardId,categoryId,amountKopecks,date,createFingerprint,createdAt,updatedAt)
VALUES ('operation','owner','expense','card','category',12345,'2026-08-01','operation-intent',0,0);
`);
}
const snapshot = () =>
preserved.map((table) => {
const statement = db.prepare(`SELECT * FROM "${table}" ORDER BY id`);
statement.setReadBigInts(true);
return statement.all();
});
const before = snapshot();
const connectionsBefore = db
.prepare(
'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId',
)
.all();
db.exec(
readFileSync(join(migrations, migration, 'migration.sql'), 'utf8'),
);
db.close();
db = new DatabaseSync(file);
db.exec('PRAGMA foreign_keys=ON');
expect(snapshot()).toEqual(before);
expect(
db
.prepare(
'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId',
)
.all(),
).toEqual(connectionsBefore);
expect(db.prepare('PRAGMA integrity_check').get()).toEqual({
integrity_check: 'ok',
});
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
const revisions = db
.prepare('SELECT revision FROM TbankConnection')
.all()
.map((row) => row.revision);
expect(new Set(revisions).size).toBe(revisions.length);
expect(
revisions.every(
(value) => typeof value === 'string' && value.length > 0,
),
).toBe(true);
db.close();
const prisma = new PrismaClient({
adapter: new PrismaBetterSqlite3({ url: `file:${file}` }),
});
try {
if (!populated)
await prisma.user.create({
data: {
id: 'owner',
email: 'owner@example.invalid',
passwordHash: 'synthetic',
tbankConnection: {
create: {
encryptedToken: 'synthetic',
connectedAt: new Date(0),
},
},
},
});
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const scope = {
userId: 'owner',
revision: connection.revision,
accountId: 'account',
};
await prisma.tbankAccount.create({
data: {
...scope,
type: 'BROKERAGE',
name: 'Synthetic',
upstreamOrder: 0,
},
});
const exact = { currency: 'RUB', value: '-9007199254740993.123456789' };
const data = {
...scope,
brokerOperationId: 'broker-id',
occurredAt: new Date('2026-08-01'),
category: 'income',
payload: { payment: exact },
};
await prisma.tbankOperation.create({ data });
await prisma.tbankAccount.create({
data: {
...scope,
accountId: 'second',
type: 'BROKERAGE',
name: 'Second',
upstreamOrder: 1,
},
});
await prisma.tbankOperation.create({
data: { ...data, accountId: 'second' },
});
await expect(
prisma.tbankOperation.create({ data }),
).rejects.toMatchObject({ code: 'P2002' });
await expect(
prisma.tbankOperation.create({
data: {
...data,
brokerOperationId: 'foreign',
revision: 'wrong-connection',
},
}),
).rejects.toMatchObject({ code: 'P2003' });
// A revision update cannot silently move private rows to another connection.
await expect(
prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { revision: 'replacement' },
}),
).rejects.toMatchObject({ code: 'P2003' });
const saved = await prisma.tbankOperation.findFirstOrThrow({
where: scope,
});
expect(saved.payload).toEqual({ payment: exact });
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'synthetic-uid',
instrumentType: 'bond',
metadata: { name: 'Synthetic' },
fetchedAt: new Date(),
},
});
await prisma.tbankCandle.create({
data: {
provider: 'tbank',
instrumentUid: 'synthetic-uid',
interval: 'day',
time: new Date('2026-08-01'),
close: exact.value,
payload: { close: exact.value },
isComplete: true,
fetchedAt: new Date(),
},
});
await prisma.tbankSyncWork.create({
data: {
id: 'work',
userId: scope.userId,
revision: scope.revision,
scopeKey: 'operations',
intent: {},
state: 'running',
attemptId: 'new-attempt',
processRunId: 'run',
priority: 0,
progress: {},
},
});
const staged = {
workId: 'work',
attemptId: 'new-attempt',
sourceKey: 'operations',
itemKey: 'broker-id',
payload: { payment: exact },
};
await prisma.tbankSyncStage.create({ data: staged });
await expect(
prisma.tbankSyncStage.create({
data: { ...staged, attemptId: 'old-attempt' },
}),
).rejects.toMatchObject({ code: 'P2003' });
await prisma.$disconnect();
await prisma.$connect();
expect(
(await prisma.tbankOperation.findFirstOrThrow({ where: scope }))
.payload,
).toEqual({ payment: exact });
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
expect(await prisma.tbankOperation.count()).toBe(0);
expect(await prisma.tbankAccount.count()).toBe(0);
expect(await prisma.tbankSyncWork.count()).toBe(0);
expect(await prisma.tbankSyncStage.count()).toBe(0);
expect((await prisma.tbankCandle.findFirstOrThrow()).close).toBe(
exact.value,
);
expect(await prisma.bankCard.count()).toBe(populated ? 1 : 0);
expect(await prisma.bankCardOperation.count()).toBe(populated ? 1 : 0);
} finally {
await prisma.$disconnect();
}
db = new DatabaseSync(file);
const plan = db
.prepare(
'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50',
)
.all('owner', 'revision', 'account');
expect(
plan.some((row) => String(row.detail).includes('USING INDEX')),
).toBe(true);
expect(
plan.some((row) => String(row.detail).includes('TEMP B-TREE')),
).toBe(false);
expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]);
expect(db.prepare('PRAGMA integrity_check').get()).toEqual({
integrity_check: 'ok',
});
db.close();
},
);
});