#!/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'; 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'], { PATH: process.env.PATH ?? '', 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 child = spawn(path.join(repoRoot, 'node_modules/.bin/tsx'), ['src/server.ts'], { cwd: repoRoot, env: { PATH: process.env.PATH ?? '', 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'); }