fix: make SQLite migrations and e2e reproducible
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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=';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user