fix: make SQLite migrations and e2e reproducible

This commit is contained in:
2026-09-13 14:52:09 +03:00
parent 8637ad2f34
commit ce24a84b7f
10 changed files with 237 additions and 83 deletions
+11 -14
View File
@@ -36,18 +36,15 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` существует, но известный Prisma migration blocker описан в
[ограничении сквозной проверки](../../../docs/development/verification.md#известное-ограничение-сквозной-проверки) и WL-P2-05
[registry](../../../docs/development/audit-remediation.md). Скрипт использует
фиксированную `/tmp/wealth-ledger-e2e.db`; не запускай его конкурентно как
независимые DB tests. Для isolation выбери отдельную test DB и runner configuration.
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось;
сохрани точный failure и оставшуюся проверку. Unit/Swagger success не заменяет e2e.
Для auth/cards DB-инвариантов используйте `npm run test:sqlite`: отдельная
временная база на suite, migrations с `RUST_LOG=info`, configured HTTP и реальная
Prisma. Для schema/data changes дополнительно `npm run test:durability -- <baseline>`.
Матрица рисков и подготовка совместимой предыдущей сборки —
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`,
вызывающего ошибку fresh migration на Prisma 7.9.1. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
Эти проверки не снимают ограничение общего `test:e2e` автоматически.
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
не считается успешным тестом; повторите проверку в подходящем окружении.
Для schema/data changes дополнительно `npm run test:durability -- <baseline>`.
Матрица рисков и подготовка совместимой предыдущей сборки — в том же README.
Mock Prisma не заменяет доказательство реальной DB concurrency.
+1 -1
View File
@@ -14,7 +14,7 @@ zero-warning ESLint, application/test TypeScript, Jest и build.
HTTP contract changes дополнительно проходят общий
[contract workflow](../docs/development/runtime-openapi-verification.md).
Общий e2e имеет известный environment blocker; применимость и маршрут проверки
Общий e2e запускается на отдельных временных DB; применимость и маршрут проверки
описаны в [backend testing](.ai/conventions/backend-testing.md).
Generated Prisma client не редактируется вручную. Credentials, авторизация
доступа к счетам и внешние интеграции остаются server-side.
+25 -9
View File
@@ -12,6 +12,7 @@ NestJS API WealthLedger. Backend владеет auth, SQLite-данными и s
cp .env.example .env
nvm use --silent
npm ci
npx prisma migrate deploy
npm run start:dev
```
@@ -31,9 +32,10 @@ npm run verify
Он запускает zero-warning lint,
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
записывающая команда, `npm run test:cov` — диагностика без общего числового
threshold. `npm run test:e2e` пока не является passing gate: Prisma schema
engine останавливает migration до запуска Jest. Точный статус и trigger
пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md).
threshold. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
и runtime OpenAPI с соседним frontend checkout. Для полной backend-проверки
выполните обе команды. Результаты — в
[remediation registry](../docs/development/audit-remediation.md).
Общий `tsconfig.json` включает `strict: true` и `noFallthroughCasesInSwitch`
для приложения, тестов и сборок. ESLint запрещает non-null assertions
@@ -72,12 +74,26 @@ npm run test:openapi-contract
## Реальная SQLite и сохранность данных
После `nvm use --silent` запускайте `npm run test:sqlite` при изменении auth,
карточных операций или DB boundary. Runner запускает существующие HTTP suites
последовательно, каждую на новой `/tmp/bank-cards-*/test.db`, применяет migrations
с `RUST_LOG=info` и удаляет только созданную им директорию. Внешний `DATABASE_URL`
заменяется временным; `.env` и пользовательские данные не изменяются. Ошибка
migration или Jest возвращает ненулевой exit code. Нужен доступ к loopback-порту.
Это отдельный scope, а не подтверждение всего `test:e2e` (задача 03).
карточных операций или DB boundary, `npm run test:e2e` — для всех HTTP suites.
Обе команды используют один runner: отдельный каталог `wealthledger-e2e-*`
в системном temp на каждую suite, один `DATABASE_URL` для migrations и Jest.
Внешний `DATABASE_URL` заменяется временным; `.env` и пользовательские данные
не изменяются. Прямой Jest без runner отклоняется до обращения к базе.
При ошибке migration/Jest или SIGINT/SIGTERM runner останавливает дочерние
процессы, удаляет свой каталог и возвращает ненулевой exit code.
Нужен доступ к локальным HTTP-портам; sandbox может его запрещать.
`prisma.config.ts` удаляет унаследованный `RUST_LOG` только в процессе Prisma CLI:
на Prisma 7.9.1 уровни `warn`/`error` вызывают пустой `Schema engine error`
при fresh migration, без переменной миграция проходит. Постоянный
`RUST_LOG=info` и обновление зависимостей не нужны. Logging приложения
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
Логи остаются в каталоге из вывода. Для одной suite:
`node test/sqlite.cjs app.e2e-spec.ts`.
| Риск | Проверка реальной DB |
| --- | --- |
+2 -2
View File
@@ -24,10 +24,10 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json",
"test:e2e": "node test/sqlite.cjs --all",
"test:sqlite": "node test/sqlite.cjs",
"test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json --runInBand openapi-contract.e2e-spec.ts",
"test:openapi-contract": "node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build"
},
"dependencies": {
+4
View File
@@ -1,6 +1,10 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
// Prisma 7.9.1 fresh SQLite migration fails with inherited RUST_LOG=warn/error.
// Use the engine default; application logging is configured separately.
delete process.env.RUST_LOG;
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
+3 -1
View File
@@ -21,7 +21,9 @@ for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/mi
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') };
const env = { ...process.env, DATABASE_URL: `file:${file}`, 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') };
// Historical baseline config predates the Prisma RUST_LOG isolation.
delete env.RUST_LOG;
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
const rub = value => ({ currency: 'RUB', value });
-18
View File
@@ -2,7 +2,6 @@ import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import request from 'supertest';
@@ -83,23 +82,6 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
db = app.get(PrismaService);
};
beforeAll(async () => {
const url = process.env.DATABASE_URL;
if (
!url ||
!/^file:\/(?:private\/)?tmp\/bank-cards-[^/]+\/.+\.db$/.test(url)
)
throw new Error(
'Pass a unique /tmp/bank-cards-*/test.db DATABASE_URL. No shared/user database is allowed.',
);
execFileSync(
process.execPath,
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
{
cwd: process.cwd(),
env: { ...process.env, RUST_LOG: 'info' },
stdio: 'pipe',
},
);
await start();
await db.user.createMany({
data: [
+15 -2
View File
@@ -1,4 +1,17 @@
process.env.DATABASE_URL ??= 'file:/tmp/wealth-ledger-e2e.db';
const { join, dirname, basename } = require('node:path');
const { tmpdir } = require('node:os');
const dir = process.env.WL_E2E_DIRECTORY;
if (
!dir ||
dirname(dir) !== tmpdir() ||
!basename(dir).startsWith('wealthledger-e2e-') ||
process.env.DATABASE_URL !== `file:${join(dir, 'test.db')}`
) {
throw new Error(
'Use npm run test:e2e or test:sqlite: an isolated runner database is required.',
);
}
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
process.env.TOKEN_ENCRYPTION_KEY =
'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
+90
View File
@@ -0,0 +1,90 @@
// Full fresh-DB proof, including concurrent runners and failure/signal cleanup.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const {
mkdtempSync,
writeFileSync,
readFileSync,
existsSync,
} = require('node:fs');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const cwd = resolve(__dirname, '..');
const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-'));
const sentinel = join(evidence, 'user.db');
writeFileSync(sentinel, 'fictional user database: must never be opened');
const before = readFileSync(sentinel);
const databases = new Set();
async function check(name, overrides = {}, signal) {
const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], {
cwd,
env: {
...process.env,
DATABASE_URL: `file:${sentinel}`,
RUST_LOG: 'warn',
LOG_LEVEL: 'error',
...overrides,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
let sent = false;
child.stdout.on('data', (chunk) => {
log += chunk;
if (signal && !sent && log.includes('SQLite ')) {
sent = true;
child.kill(signal);
}
});
child.stderr.on('data', (chunk) => {
log += chunk;
});
const code = await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', resolve);
});
writeFileSync(join(evidence, `${name}.log`), log);
const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map(
(match) => match[1],
);
assert.ok(dirs.length > 0, 'runner must allocate a database');
for (const dir of dirs) {
assert.ok(!databases.has(dir), 'runners must never share databases');
databases.add(dir);
assert.ok(!existsSync(dir), `runner must clean up ${dir}`);
}
assert.deepEqual(
readFileSync(sentinel),
before,
'user database must be untouched',
);
const expected =
signal === 'SIGINT'
? 130
: signal === 'SIGTERM'
? 143
: overrides.PRISMA_SCHEMA_ENGINE_BINARY
? 1
: 0;
assert.equal(code, expected, `See ${name}.log`);
console.log(`PASS ${name}: ${dirs.length} isolated databases`);
}
(async () => {
console.log(`Evidence: ${evidence}`);
for (let i = 1; i <= 3; i++) await check(`sequential-${i}`);
const parallel = await Promise.allSettled([
check('parallel-1'),
check('parallel-2', { RUST_LOG: 'error' }),
]);
for (const result of parallel)
if (result.status === 'rejected') throw result.reason;
await check('migration-failure', {
PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'),
});
await check('sigint', {}, 'SIGINT');
await check('sigterm', {}, 'SIGTERM');
console.log(`PASS regression: ${evidence}`);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+86 -36
View File
@@ -1,42 +1,92 @@
const { spawnSync } = require('node:child_process');
const { mkdtempSync, rmSync } = require('node:fs');
const { spawn } = require('node:child_process');
const { mkdtempSync, readdirSync, rmSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const cwd = resolve(__dirname, '..');
for (const suite of ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']) {
const dir = mkdtempSync('/tmp/bank-cards-');
const env = {
...process.env,
DATABASE_URL: `file:${join(dir, 'test.db')}`,
RUST_LOG: 'info',
NODE_ENV: 'development',
NODE_OPTIONS: '--experimental-vm-modules',
};
const available = readdirSync(__dirname)
.filter((name) => name.endsWith('.e2e-spec.ts'))
.sort();
const requested = process.argv.slice(2);
const suites =
requested.length === 0
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
: requested.length === 1 && requested[0] === '--all'
? available
: requested;
if (suites.some((suite) => !available.includes(suite)))
throw new Error('Unknown e2e suite');
let child;
let interrupted;
let killTimer;
function kill(signal) {
if (!child) return;
try {
for (const args of [
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
[
'node_modules/jest/bin/jest.js',
'--config',
'test/jest-e2e.json',
'--runInBand',
'--runTestsByPath',
`test/${suite}`,
],
]) {
const result = spawnSync(process.execPath, args, {
cwd,
env,
stdio: 'inherit',
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exitCode = result.status ?? 1;
break;
}
}
} finally {
rmSync(dir, { recursive: true, force: true });
process.kill(-child.pid, signal);
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
if (process.exitCode) break;
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
interrupted = signal;
kill(signal);
killTimer ??= setTimeout(() => kill('SIGKILL'), 5000);
});
}
function run(args, env) {
return new Promise((resolve, reject) => {
child = spawn(process.execPath, args, {
cwd,
env,
stdio: 'inherit',
detached: true,
});
child.once('error', reject);
child.once('close', (code, signal) => {
clearTimeout(killTimer);
child = undefined;
if (code !== 0 || signal)
reject(new Error(`Child failed: ${signal ?? code}`));
else resolve();
});
});
}
(async () => {
for (const suite of suites) {
if (interrupted) throw new Error(interrupted);
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-e2e-'));
const env = {
...process.env,
DATABASE_URL: `file:${join(dir, 'test.db')}`,
WL_E2E_DIRECTORY: dir,
NODE_ENV: 'development',
NODE_OPTIONS: '--experimental-vm-modules',
};
console.log(`SQLite ${suite}: ${dir}`);
try {
await run(
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
env,
);
if (interrupted) throw new Error(interrupted);
await run(
[
'node_modules/jest/bin/jest.js',
'--config',
'test/jest-e2e.json',
'--runInBand',
'--runTestsByPath',
`test/${suite}`,
],
env,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
})().catch((error) => {
console.error(error.message);
process.exitCode =
interrupted === 'SIGINT' ? 130 : interrupted === 'SIGTERM' ? 143 : 1;
});