Files
backend/test/tbank-migration.e2e-spec.ts
T

259 lines
9.5 KiB
TypeScript

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