diff --git a/.ai/conventions/backend-testing.md b/.ai/conventions/backend-testing.md index 4e538ab..9dc0863 100644 --- a/.ai/conventions/backend-testing.md +++ b/.ai/conventions/backend-testing.md @@ -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 -- `. -Матрица рисков и подготовка совместимой предыдущей сборки — +`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 -- `. +Матрица рисков и подготовка совместимой предыдущей сборки — в том же README. +Mock Prisma не заменяет доказательство реальной DB concurrency. diff --git a/AGENTS.md b/AGENTS.md index 2addf59..3a1c6b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 33b1243..d0e8b45 100644 --- a/README.md +++ b/README.md @@ -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 | | --- | --- | diff --git a/package.json b/package.json index a88ad3a..e7845f8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma.config.ts b/prisma.config.ts index 8cb24a2..d310452 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -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: { diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index bc4ab43..8d4c635 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -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 }); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 13eb900..57cae6c 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -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: [ diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index 9ebae4f..1c76d9d 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -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='; diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs new file mode 100644 index 0000000..fb7f43c --- /dev/null +++ b/test/sqlite-regression.cjs @@ -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; +}); diff --git a/test/sqlite.cjs b/test/sqlite.cjs index d7aee08..74357e6 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -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; +});