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'; 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((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 ('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(); }, ); });