From 7fde3ece6ec7143723140357602861cd2e20b3a5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 13 Sep 2026 12:33:17 +0300 Subject: [PATCH] test: cover isolated SQLite invariants and durability --- .ai/conventions/backend-testing.md | 7 ++++ README.md | 49 ++++++++++++++++++++++++++ package.json | 2 ++ test/app.e2e-spec.ts | 56 +++++++++++++++++++++++++++++- test/bank-cards-durability.cjs | 11 +++++- test/bank-cards.e2e-spec.ts | 2 +- test/sqlite.cjs | 42 ++++++++++++++++++++++ 7 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 test/sqlite.cjs diff --git a/.ai/conventions/backend-testing.md b/.ai/conventions/backend-testing.md index 6a083af..5346d06 100644 --- a/.ai/conventions/backend-testing.md +++ b/.ai/conventions/backend-testing.md @@ -44,3 +44,10 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`. Если задаче нужна 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 -- `. +Матрица рисков и подготовка совместимой предыдущей сборки — +[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных). +Эти проверки не снимают ограничение общего `test:e2e` автоматически. diff --git a/README.md b/README.md index 0312026..33b1243 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,52 @@ npm run test:openapi-contract Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит свой verify; при изменении wire contract дополнительно выполняется contract check. Обязательного OpenSpec/SDD lifecycle нет. + +## Реальная 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 | +| --- | --- | +| Чужой пользователь | `bank-cards.e2e-spec.ts`: owner каждой карты, категории, покупки и стороны перевода | +| Повтор и конкурентный update | Те же tests: fingerprint, tombstone, версии, один refund в пределах покупки, archive/write | +| Конкурентный refresh | `app.e2e-spec.ts`: оба запроса читают прежний hash до CAS; один 200, один 401, cookie победителя работает после replay | +| Rollback и деньги | Карточные tests: откат source lock, атомарный перевод, точные суммы выше 2^53 и границы signed64 | +| Migration, restart, backup/restore | `test:durability`: прежнее приложение, новая schema, история, откат приложения и восстановление backup | + +Prisma в этих suites не заменяется mocks. В refresh тесте барьер задерживает +настоящую подпись JWT, чтобы оба запроса дошли до реальной конкурентной записи. +Unit tests с mocks отдельно проверяют orchestration и не доказывают DB concurrency. + +При изменении schema/migrations или сохранения данных дополнительно выполните +`npm run test:durability -- /absolute/path/to/baseline`. Он не входит в lint/verify. +Baseline — отдельная сборка до проверяемых миграций с неизменённым префиксом +migration history. Для текущей карточной схемы подходит revision +`8ec172f48eefa2237dc0c16919fbb2e726b39a65` (до добавления карт): + +```bash +# Из backend; экспорт не содержит .env или локальную базу. +baseline_dir=$(mktemp -d /tmp/wealthledger-baseline-XXXXXX) +git archive 8ec172f48eefa2237dc0c16919fbb2e726b39a65 | tar -x -C "$baseline_dir" +( + cd "$baseline_dir" + nvm use --silent + npm ci + npm run build +) +nvm use --silent +npm run build +npm run test:durability -- "$baseline_dir" +``` + +Не подменяйте baseline текущей сборкой: runner отвергает тот же путь, +отсутствующую сборку и неподходящую migration history. Для следующей schema +выберите совместимую предыдущую revision и проверьте её API-сценарии runner. +Durability оставляет временные DB, логи и `result.json` по пути из вывода для +диагностики; он не откатывает schema пользовательской базы. diff --git a/package.json b/package.json index b42918a..a88ad3a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "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: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", "verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build" }, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0b0d3f2..85c6139 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; @@ -21,7 +22,7 @@ describe('Authentication (e2e)', () => { app = moduleFixture.createNestApplication(); configureApp(app); - await app.init(); + await app.listen(0, '127.0.0.1'); await app.get(PrismaService).session.deleteMany(); await app.get(PrismaService).tbankConnection.deleteMany(); await app.get(PrismaService).user.deleteMany(); @@ -59,6 +60,59 @@ describe('Authentication (e2e)', () => { await agent.post('/api/v1/auth/logout').expect(204); }); + it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'race@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + // Both requests read the old hash before either reaches the real SQLite CAS. + const jwt = app.get(JwtService); + const sign = jwt.signAsync.bind(jwt); + let arrived = 0; + let release = () => {}; + const ready = new Promise((resolve) => { + release = resolve; + }); + const barrier = jest + .spyOn(jwt, 'signAsync') + .mockImplementation(async (...args) => { + const signed = await sign(...args); + if ( + typeof args[0] === 'object' && + 'type' in args[0] && + args[0].type === 'refresh' + ) { + if (++arrived === 2) release(); + await ready; + } + return signed; + }); + const responses = await Promise.all( + Array.from({ length: 2 }, () => + request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie), + ), + ); + barrier.mockRestore(); + expect(arrived).toBe(2); + expect(responses.map((response) => response.status).sort()).toEqual([ + 200, 401, + ]); + const winner = responses.find((response) => response.status === 200); + if (!winner) throw new Error('Expected one successful rotation'); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(401); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', winner.headers['set-cookie'][0]) + .expect(200); + expect(await app.get(PrismaService).session.count()).toBe(1); + }); + it('rejects duplicate registration and invalid credentials', async () => { await request(app.getHttpServer()) .post('/api/v1/auth/register') diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 9374ad4..bc4ab43 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const { spawn, execFileSync } = require('node:child_process'); -const { mkdtempSync, openSync, closeSync, writeFileSync } = require('node:fs'); +const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs'); const { tmpdir } = require('node:os'); const { join, resolve } = require('node:path'); const { once } = require('node:events'); @@ -9,6 +9,15 @@ const { randomUUID } = require('node:crypto'); const repo = resolve(__dirname, '..'); assert.ok(process.argv[2], 'Pass a separately built baseline repository directory'); const baseline = resolve(process.argv[2]); +assert.notEqual(baseline, repo, 'Baseline must be a separate historical build'); +for (const root of [baseline, repo]) { + assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); +} +const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); +const previous = migrations(baseline), current = migrations(repo); +assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations'); +assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix'); +for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8')); const Database = require(join(repo, 'node_modules/better-sqlite3')); const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-')); let file = join(dir, 'history.db'); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 9ff99d4..48899c8 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -79,7 +79,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }).compile(); app = module.createNestApplication(); configureApp(app); - await app.init(); + await app.listen(0, '127.0.0.1'); db = app.get(PrismaService); }; beforeAll(async () => { diff --git a/test/sqlite.cjs b/test/sqlite.cjs new file mode 100644 index 0000000..d7aee08 --- /dev/null +++ b/test/sqlite.cjs @@ -0,0 +1,42 @@ +const { spawnSync } = require('node:child_process'); +const { mkdtempSync, rmSync } = require('node:fs'); +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', + }; + 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 }); + } + if (process.exitCode) break; +}