test: cover isolated SQLite invariants and durability
This commit is contained in:
@@ -44,3 +44,10 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную
|
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную
|
||||||
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось;
|
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось;
|
||||||
сохрани точный failure и оставшуюся проверку. Unit/Swagger success не заменяет e2e.
|
сохрани точный 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>`.
|
||||||
|
Матрица рисков и подготовка совместимой предыдущей сборки —
|
||||||
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
|
Эти проверки не снимают ограничение общего `test:e2e` автоматически.
|
||||||
|
|||||||
@@ -68,3 +68,52 @@ npm run test:openapi-contract
|
|||||||
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
|
||||||
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
свой verify; при изменении wire contract дополнительно выполняется contract check.
|
||||||
Обязательного OpenSpec/SDD lifecycle нет.
|
Обязательного 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 пользовательской базы.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": "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",
|
"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"
|
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
+55
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
@@ -21,7 +22,7 @@ describe('Authentication (e2e)', () => {
|
|||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
await app.init();
|
await app.listen(0, '127.0.0.1');
|
||||||
await app.get(PrismaService).session.deleteMany();
|
await app.get(PrismaService).session.deleteMany();
|
||||||
await app.get(PrismaService).tbankConnection.deleteMany();
|
await app.get(PrismaService).tbankConnection.deleteMany();
|
||||||
await app.get(PrismaService).user.deleteMany();
|
await app.get(PrismaService).user.deleteMany();
|
||||||
@@ -59,6 +60,59 @@ describe('Authentication (e2e)', () => {
|
|||||||
await agent.post('/api/v1/auth/logout').expect(204);
|
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<void>((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 () => {
|
it('rejects duplicate registration and invalid credentials', async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/v1/auth/register')
|
.post('/api/v1/auth/register')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { spawn, execFileSync } = require('node:child_process');
|
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 { tmpdir } = require('node:os');
|
||||||
const { join, resolve } = require('node:path');
|
const { join, resolve } = require('node:path');
|
||||||
const { once } = require('node:events');
|
const { once } = require('node:events');
|
||||||
@@ -9,6 +9,15 @@ const { randomUUID } = require('node:crypto');
|
|||||||
const repo = resolve(__dirname, '..');
|
const repo = resolve(__dirname, '..');
|
||||||
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
|
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
|
||||||
const baseline = resolve(process.argv[2]);
|
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 Database = require(join(repo, 'node_modules/better-sqlite3'));
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
|
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
|
||||||
let file = join(dir, 'history.db');
|
let file = join(dir, 'history.db');
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
app = module.createNestApplication();
|
app = module.createNestApplication();
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
await app.init();
|
await app.listen(0, '127.0.0.1');
|
||||||
db = app.get(PrismaService);
|
db = app.get(PrismaService);
|
||||||
};
|
};
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user