test(accept): предохранитель изолированной БД и вердикт SKIP для интеграционных тестов

Почему: live-postgres-remediation.test.ts (codex/fix-live-database) запускается только при
DATABASE_TEST_ISOLATED=true, DATABASE_TEST_GUARD_TOKEN, таблице krona_staff_test_guard и имени
БД krona_staff_audit_*; иначе весь файл пропускается, и первый прогон показал его как FAIL
«1 skipped (1)», что неверно в обе стороны. В admin-режиме интеграционная БД одноразовая
(krona_staff_audit_acc_it), поэтому предохранитель создаётся со случайным маркером — тест
реально выполнился: 1 passed. В режиме E2E_MIGRATOR_URL/E2E_APP_URL изоляция не гарантирована,
предохранитель не ставится, файл получает отдельный вердикт SKIP (ничего не проверил).
This commit is contained in:
grok 2026-09-26 16:16:23 +07:00
parent d7b235248a
commit 6f408bc0aa

View file

@ -19,7 +19,11 @@
// Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/. // Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/.
// //
// БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными // БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными
// паролями, дальше только пересоздаёт БД `krona_acc_it`/`krona_acc_e2e` (provision-pg --roles keep). // паролями, дальше только пересоздаёт БД `krona_staff_audit_acc_it`/`krona_acc_e2e` (provision-pg --roles keep).
// Интеграционная БД в этом режиме заведомо изолирована, поэтому для тестов с «предохранителем»
// (codex/fix-live-database: DATABASE_TEST_ISOLATED, DATABASE_TEST_GUARD_TOKEN, таблица
// krona_staff_test_guard, имя БД krona_staff_audit_*) он выставляется со случайным маркером.
// Файл, где все тесты пропущены, — отдельный вердикт SKIP (ничего не проверил).
// Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются, // Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются,
// все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций. // все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций.
// //
@ -28,7 +32,7 @@
// --skip-integration, --skip-e2e, --out <файл.md>. // --skip-integration, --skip-e2e, --out <файл.md>.
// Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA. // Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA.
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { import {
cpSync, cpSync,
existsSync, existsSync,
@ -42,6 +46,7 @@ import {
} from 'node:fs'; } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import pg from 'pg';
import * as prettier from 'prettier'; import * as prettier from 'prettier';
const repoRoot = path.resolve(import.meta.dirname, '../..'); const repoRoot = path.resolve(import.meta.dirname, '../..');
@ -296,6 +301,23 @@ async function migrate(wt, db, logFile) {
return r; return r;
} }
/** Предохранитель изолированной тестовой БД (соглашение теста codex/fix-live-database). */
async function createGuard(db) {
const marker = randomBytes(16).toString('hex');
const client = new pg.Client({ connectionString: db.migrator });
await client.connect();
try {
await client.query(
'CREATE TABLE IF NOT EXISTS krona_staff_test_guard (marker text PRIMARY KEY)',
);
await client.query('INSERT INTO krona_staff_test_guard (marker) VALUES ($1)', [marker]);
await client.query('GRANT SELECT ON krona_staff_test_guard TO krona_staff_app');
} finally {
await client.end();
}
return marker;
}
async function stepIntegration(target, wt) { async function stepIntegration(target, wt) {
const dir = path.join(wt.dir, 'tests/integration'); const dir = path.join(wt.dir, 'tests/integration');
const files = existsSync(dir) const files = existsSync(dir)
@ -307,8 +329,9 @@ async function stepIntegration(target, wt) {
for (const file of files) { for (const file of files) {
progress(`${target.name}: integration ${file}`); progress(`${target.name}: integration ${file}`);
const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`; const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`;
const db = await freshDatabase('krona_acc_it'); const db = await freshDatabase('krona_staff_audit_acc_it');
const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`)); const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`));
const guard = m.code === 0 && dbMode === 'admin' ? await createGuard(db) : null;
if (m.code !== 0) { if (m.code !== 0) {
results.push({ results.push({
file, file,
@ -328,6 +351,7 @@ async function stepIntegration(target, wt) {
DATABASE_TEST_URL: db.app, DATABASE_TEST_URL: db.app,
DATABASE_TEST_MIGRATION_URL: db.migrator, DATABASE_TEST_MIGRATION_URL: db.migrator,
DATABASE_MIGRATION_URL: db.migrator, DATABASE_MIGRATION_URL: db.migrator,
...(guard ? { DATABASE_TEST_ISOLATED: 'true', DATABASE_TEST_GUARD_TOKEN: guard } : {}),
}), }),
logFile: path.join(logDir, `${slug}.txt`), logFile: path.join(logDir, `${slug}.txt`),
}, },
@ -335,12 +359,14 @@ async function stepIntegration(target, wt) {
const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)'; const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)';
const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '';
const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine); const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine);
const skipped = r.code === 0 && /skipped/.test(filesLine) && !/passed|failed/.test(filesLine);
results.push({ results.push({
file, file,
ok, ok,
skipped,
files: filesLine, files: filesLine,
tests: testsLine, tests: testsLine,
error: ok ? '' : firstError(r.out), error: ok || skipped ? '' : firstError(r.out),
}); });
} }
return results; return results;
@ -574,9 +600,11 @@ if (needRuns) {
? `OK (${res.check.tests})` ? `OK (${res.check.tests})`
: `FAIL exit=${res.check.code}: ${res.check.error}` : `FAIL exit=${res.check.code}: ${res.check.error}`
: 'не запускался'; : 'не запускался';
const reds = res.integration?.filter((r) => !r.ok).map((r) => r.file) ?? []; const reds = res.integration?.filter((r) => !r.ok && !r.skipped).map((r) => r.file) ?? [];
const skips = res.integration?.filter((r) => r.skipped).map((r) => r.file) ?? [];
const greens = (res.integration?.length ?? 0) - reds.length - skips.length;
const integration = res.integration const integration = res.integration
? `${res.integration.length - reds.length}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}` ? `${greens}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}${skips.length ? `; SKIP (файл пропущен целиком): ${skips.join(', ')}` : ''}`
: 'не запускались'; : 'не запускались';
let e2e = 'не запускался'; let e2e = 'не запускался';
let green = ''; let green = '';
@ -609,7 +637,7 @@ if (needRuns) {
for (const res of results.values()) { for (const res of results.values()) {
for (const r of res.integration ?? []) { for (const r of res.integration ?? []) {
md.push( md.push(
`| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`, `| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : r.skipped ? 'SKIP' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`,
); );
} }
} }