266 lines
9.8 KiB
TypeScript
266 lines
9.8 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 nextMigration = '20261007181218_add_portfolio_value_history';
|
|
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);
|
|
db.exec('PRAGMA foreign_keys=ON');
|
|
const beforeNext = snapshot();
|
|
db.exec(
|
|
readFileSync(join(migrations, nextMigration, 'migration.sql'), 'utf8'),
|
|
);
|
|
expect(snapshot()).toEqual(beforeNext);
|
|
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();
|
|
},
|
|
);
|
|
});
|