krona-staff/scripts/test/n13-race.mjs
grok 0aaa2f4d2d test(e2e): запуск vitest/tsx/prettier без .cmd-обёрток (Windows)
Зачем: проект разрабатывается под Windows, а стенд там не стартовал:
spawn('npx') и spawn('node_modules/.bin/tsx') без shell падают с ENOENT,
потому что в Windows это .cmd-файлы (приёмка владельца, TASK-GROK-2 п.1).

Что: scripts/test/node-commands.mjs находит JS-точки входа через
require.resolve (tsx/cli, vitest.mjs, prettier.cjs) и всегда запускает их
текущим node (process.execPath). Сервер запускается одним процессом
`node --import tsx src/server.ts`: CLI tsx порождает дочерний node, и под
Windows kill() родителя оставил бы сервер-сироту на порту. baseEnv()
передаёт дочерним процессам PATH и под win32 системные переменные
(SystemRoot, TEMP и др.), без которых node на Windows не работает с сетью,
но не наследует секреты родителя.

Проверка на Linux: tests/e2e/harness/node-commands.test.ts (входит в
npm test) — команды начинаются с process.execPath, без .cmd/.bin/npx,
файлы существуют; исходники стенда не вызывают npx и node_modules/.bin.
Реальный прогон на Windows 11 — не проверено (нет Windows-раннера).
2026-09-26 15:19:47 +07:00

300 lines
11 KiB
JavaScript

#!/usr/bin/env node
// N13: гонка первого расчёта зарплаты. N параллельных POST /payroll/calculate для одной и той же
// пары (точка, месяц), когда расчёта ещё нет. Каждая попытка — новая точка (значит «первый» расчёт).
//
// Режимы:
// --mode as-is код и схема как есть (N2 не обходится);
// --mode n2-bypass ДИАГНОСТИКА: в одноразовой БД мигратор ставит DEFAULT now() для
// staff_outbox.occurred_at, чтобы обойти N2 и увидеть саму гонку.
// Продукт/миграции не меняются, БД удаляется при следующем provision.
// Опции: --sizes 10,25,50 --attempts 5 --with-sessions (подтверждённые смены, нужен n2-bypass)
// Требует E2E_PG_ADMIN_URL (bootstrap-админ только для создания ролей/БД).
// Правильное поведение: все ответы 200, один и тот же run_id, ровно один расчёт в БД,
// каждый возвращённый run_id существует в БД.
import { spawn, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import pg from 'pg';
import { baseEnv, nodeWithTsx } from './node-commands.mjs';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const args = process.argv.slice(2);
const opt = (name, fallback) => {
const index = args.indexOf(`--${name}`);
return index >= 0 ? args[index + 1] : fallback;
};
const mode = opt('mode', 'as-is');
const sizes = opt('sizes', '10,25,50').split(',').map(Number);
const attempts = Number(opt('attempts', '5'));
const withSessions = args.includes('--with-sessions');
const database = opt('db', `krona_staff_n13_${mode.replace(/-/g, '_')}`);
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const ulid = () =>
Array.from(randomBytes(26), (byte) => CROCKFORD[byte % 32])
.join('')
.replace(/^./, '0');
function parseEnvFile(file) {
const result = {};
for (const line of readFileSync(file, 'utf8').split('\n')) {
const index = line.indexOf('=');
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
}
return result;
}
function runNode(scriptArgs, env) {
const result = spawnSync(process.execPath, scriptArgs, { cwd: repoRoot, env, encoding: 'utf8' });
if (result.status !== 0) {
process.stdout.write(`${result.stdout}${result.stderr}`);
throw new Error(`${scriptArgs.join(' ')} failed`);
}
return result.stdout;
}
async function freePort() {
return new Promise((resolve) => {
const server = createServer();
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
const adminUrl = process.env.E2E_PG_ADMIN_URL;
if (!adminUrl) throw new Error('E2E_PG_ADMIN_URL не задан');
const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-n13-'));
runNode(
['scripts/test/provision-pg.mjs', '--out', path.join(secretsDir, 'db.env'), '--db', database],
{
...process.env,
},
);
const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env'));
rmSync(secretsDir, { recursive: true, force: true });
runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], {
...baseEnv(),
DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL,
});
const migrator = new pg.Client({ connectionString: dbEnv.DATABASE_MIGRATION_URL });
await migrator.connect();
if (mode === 'n2-bypass') {
await migrator.query('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
console.log(
'[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)',
);
}
const tenantId = ulid();
const managerToken = randomBytes(32).toString('hex');
const terminalToken = randomBytes(32).toString('hex');
const vapid = {};
for (const line of runNode(['scripts/generate-vapid-keys.mjs'], process.env).split('\n')) {
const index = line.indexOf('=');
if (index > 0) vapid[line.slice(0, index)] = line.slice(index + 1);
}
const port = await freePort();
const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`));
const server = nodeWithTsx(['src/server.ts']);
const child = spawn(server.command, server.args, {
cwd: repoRoot,
env: {
...baseEnv(),
NODE_ENV: 'test',
HOST: '127.0.0.1',
PORT: String(port),
LOG_LEVEL: 'warn',
STAFF_RUNTIME_MODE: 'standalone',
DATABASE_URL: dbEnv.DATABASE_URL,
STANDALONE_TENANT_ID: tenantId,
STANDALONE_ACTOR_ID: ulid(),
STANDALONE_ACCESS: 'employee',
STANDALONE_MANAGER_TOKEN: managerToken,
STANDALONE_TERMINAL_TOKEN: terminalToken,
STANDALONE_TERMINAL_ACTOR_ID: ulid(),
...vapid,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.pipe(log);
child.stderr.pipe(log);
const base = `http://127.0.0.1:${port}/api/v1/staff`;
async function api(method, route, token, body) {
const response = await fetch(`${base}${route}`, {
method,
headers: {
authorization: `Bearer ${token}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
// не JSON
}
return { status: response.status, json, text };
}
try {
for (let i = 0; i < 240; i += 1) {
try {
if ((await fetch(`${base}/ready`)).status === 200) break;
} catch {
// ещё не слушает
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
await migrator.query("SELECT set_config('app.tenant_id', $1, false)", [tenantId]);
const month = (() => {
const date = new Date();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() - 1);
return date.toISOString().slice(0, 7);
})();
let hourlyRule = null;
if (withSessions) {
const rules = await api('GET', '/pay-rules', managerToken);
hourlyRule = (
rules.json.pay_rules.find((rule) => rule.code === 'hourly') ?? rules.json.pay_rules[0]
).id;
}
async function prepareUnit() {
const code = `n13-${ulid().slice(-10).toLowerCase()}`;
const suffix = ulid().slice(-8);
const imported = await api('POST', '/imports/owner-workbook', managerToken, {
source_name: `n13-import-${suffix}`,
units: [{ code, name: `Синтетическая точка ${suffix}` }],
...(withSessions
? {
employees: [
{
external_ref: `n13:${suffix}`,
personnel_number: `N13-${suffix}`,
display_name: `Синтетический ${suffix}`,
},
],
}
: {}),
});
if (imported.status >= 300) throw new Error(`import ${imported.status} ${imported.text}`);
const unitId = (await migrator.query('SELECT id FROM staff_org_units WHERE code = $1', [code]))
.rows[0].id;
if (withSessions) {
const employeeId = (
await migrator.query('SELECT id FROM staff_employees WHERE external_ref = $1', [
`n13:${suffix}`,
])
).rows[0].id;
const assign = await api('PUT', `/employees/${employeeId}/pay-assignment`, managerToken, {
pay_rule_id: hourlyRule,
org_unit_id: unitId,
rate_minor: 12345,
effective_from: '2026-01-01',
});
if (assign.status !== 200) throw new Error(`assign ${assign.status} ${assign.text}`);
const start = new Date(`${month}-10T09:00:00Z`);
for (const [eventType, at] of [
['check_in', start],
['check_out', new Date(start.getTime() + 8 * 3_600_000)],
]) {
const event = await api('POST', '/attendance/events', terminalToken, {
employee_id: employeeId,
event_type: eventType,
occurred_at: at.toISOString(),
source_event_id: `n13-${ulid()}`,
org_unit_id: unitId,
});
if (event.status !== 201) throw new Error(`clock ${event.status} ${event.text}`);
}
const day = start.toISOString().slice(0, 10);
const reconcile = await api('POST', '/work-sessions/reconcile', managerToken, {
date_from: day,
date_to: day,
});
if (reconcile.status !== 200) throw new Error(`reconcile ${reconcile.status}`);
const sessionId = (
await migrator.query('SELECT id FROM staff_work_sessions WHERE employee_id = $1', [
employeeId,
])
).rows[0].id;
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, managerToken, {
status: 'confirmed',
});
if (confirm.status !== 200)
throw new Error(`confirm ${confirm.status} ${confirm.text.slice(0, 200)}`);
}
return unitId;
}
let totalBad = 0;
const summary = [];
for (const size of sizes) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const unitId = await prepareUnit();
const responses = await Promise.all(
Array.from({ length: size }, () =>
api('POST', '/payroll/calculate', managerToken, { month, org_unit_id: unitId }),
),
);
const statuses = {};
const errors = {};
const runIds = new Set();
for (const response of responses) {
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
if (response.status === 200) {
const id = response.json?.run_id ?? response.json?.id ?? response.json?.run?.id;
if (id) runIds.add(id);
} else {
const key = (
response.json?.error?.message ??
response.json?.message ??
response.text
).slice(0, 90);
errors[key] = (errors[key] ?? 0) + 1;
}
}
const dbRuns = (
await migrator.query('SELECT id FROM staff_payroll_runs WHERE org_unit_id = $1', [unitId])
).rows.map((row) => row.id);
const dbLines = Number(
(
await migrator.query(
'SELECT count(*) AS n FROM staff_payroll_lines WHERE org_unit_id = $1',
[unitId],
)
).rows[0].n,
);
const phantom = [...runIds].filter((id) => !dbRuns.includes(id));
const ok =
(statuses[200] ?? 0) === size &&
runIds.size === 1 &&
dbRuns.length === 1 &&
phantom.length === 0;
if (!ok) totalBad += 1;
const line =
`[n13] mode=${mode} sessions=${withSessions} N=${size} attempt=${attempt}: ` +
`statuses=${JSON.stringify(statuses)} distinct_run_ids=${runIds.size} db_runs=${dbRuns.length} db_lines=${dbLines} ` +
`phantom_run_ids=${phantom.length} errors=${JSON.stringify(errors)} -> ${ok ? 'OK' : 'BAD'}`;
console.log(line);
summary.push(ok);
}
}
console.log(
`[n13] ИТОГ mode=${mode} sessions=${withSessions}: попыток ${summary.length}, некорректных ${totalBad}`,
);
process.exitCode = totalBad > 0 ? 1 : 0;
} finally {
await migrator.end();
child.kill('SIGTERM');
}