test(e2e): PILOT-REHOME — приёмка будущего scripts/rehome-tenant.mjs
Почему: TASK-GROK-3 п.5 / TASK-CODEX-2 п.2.2 — данные пилота живут под заглушкой 01J00000000000000000000000, перенос на 01M2R6AC5652NPT3MG34DKN4ZP нельзя проверить руками. [PILOT-REHOME-SEED] (должен быть зелёным): таблицы с tenant_id берутся из information_schema, в каждую сеется 2+ строки заглушки и постороннего арендатора; новая таблица без сида → красный. [PILOT-REHOME]: --dry-run печатает те же числа и ничего не меняет; перенос админом БД; под заглушкой 0 строк, счётчики и SUM(amount_minor) ledger совпадают, посторонний не тронут, запись в staff_audit_log нового арендатора, krona_staff_app видит тех же сотрудников под новым tenant и никого под старым, UPDATE staff_payroll_ledger отклоняется, повторный запуск отказывает. Пока скрипта нет — красный с понятным сообщением, ID в known-defects.json. Сценарий проверен временным эталоном (не коммитился): зелёный; мутации без аудита / без отказа на повтор / с пропущенной таблицей / с записью в dry-run — красные (report-evidence/round3-pilot-rehome.txt). client.ts: dbQueryAs(tenant, …) — запрос ролью приложения под заданным арендатором.
This commit is contained in:
parent
87f85751d0
commit
d7b235248a
4 changed files with 593 additions and 4 deletions
543
tests/e2e/60-pilot-rehome.e2e.ts
Normal file
543
tests/e2e/60-pilot-rehome.e2e.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
// PILOT-REHOME (TASK-GROK-3 п.5): приёмочный сценарий для будущего scripts/rehome-tenant.mjs
|
||||
// (TASK-CODEX-2 п.2.2) — перенос данных пилота с заглушки арендатора на настоящий tenant.
|
||||
// Предполагаемый контракт (NOTES-FOR-EXECUTORS.md, C6):
|
||||
// DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from <ulid> --to <ulid> [--dry-run] [--json]
|
||||
// --json печатает (последней строкой) {"tables":{"<таблица>":<строк>},"ledger_amount_minor_sum":<число>}.
|
||||
// Таблицы берутся из information_schema.columns (column_name = 'tenant_id'), а не списком вручную:
|
||||
// новая таблица без сида делает красным [PILOT-REHOME-SEED], чтобы перенос не забыл её.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ctx, dbQueryAs } from './harness/client.js';
|
||||
import { randomUlid } from './harness/context.js';
|
||||
import { baseEnv } from '../../scripts/test/node-commands.mjs';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
||||
const scriptPath = path.join(repoRoot, 'scripts/rehome-tenant.mjs');
|
||||
const STUB = '01J00000000000000000000000';
|
||||
const TARGET = '01M2R6AC5652NPT3MG34DKN4ZP';
|
||||
const FOREIGN = randomUlid();
|
||||
|
||||
type RehomeReport = { tables?: Record<string, number>; ledger_amount_minor_sum?: number | string };
|
||||
type Snapshot = { counts: Record<string, number>; ledgerSum: string };
|
||||
|
||||
async function withMigrator<T>(
|
||||
tenant: string,
|
||||
fn: (client: pg.Client) => Promise<T>,
|
||||
userId?: string,
|
||||
connectionString = ctx().migrationDatabaseUrl,
|
||||
): Promise<T> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
|
||||
// Политики INSERT журнала аудита, начислений, отметок и табеля требуют app.user_id = автор.
|
||||
if (userId) await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function tenantTables(): Promise<string[]> {
|
||||
return withMigrator(STUB, async (client) => {
|
||||
const result = await client.query<{ table_name: string }>(
|
||||
`SELECT DISTINCT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t USING (table_schema, table_name)
|
||||
WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id' AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY 1`,
|
||||
);
|
||||
return result.rows.map((row) => row.table_name);
|
||||
});
|
||||
}
|
||||
|
||||
async function snapshot(tenant: string, tables: string[]): Promise<Snapshot> {
|
||||
return withMigrator(tenant, async (client) => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const table of tables) {
|
||||
const result = await client.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM ${table} WHERE tenant_id = $1`,
|
||||
[tenant],
|
||||
);
|
||||
counts[table] = result.rows[0]!.n;
|
||||
}
|
||||
const sum = await client.query<{ s: string }>(
|
||||
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM staff_payroll_ledger WHERE tenant_id = $1',
|
||||
[tenant],
|
||||
);
|
||||
return { counts, ledgerSum: sum.rows[0]!.s };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет строки арендатора из всех таблиц (дочерние раньше родительских — повторными проходами).
|
||||
* У staff_payroll_ledger нет политики DELETE, поэтому под FORCE RLS мигратор его не очистит:
|
||||
* при наличии админа БД чистит админ, иначе остаток ловит проверка ниже.
|
||||
*/
|
||||
async function purge(tenant: string, tables: string[]) {
|
||||
const connection = ctx().adminDatabaseUrl ?? ctx().migrationDatabaseUrl;
|
||||
await withMigrator(
|
||||
tenant,
|
||||
async (client) => {
|
||||
let pending = [...tables];
|
||||
for (let pass = 0; pass < tables.length && pending.length > 0; pass += 1) {
|
||||
const failed: string[] = [];
|
||||
for (const table of pending) {
|
||||
await client.query('SAVEPOINT purge');
|
||||
try {
|
||||
await client.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
|
||||
await client.query('RELEASE SAVEPOINT purge');
|
||||
} catch {
|
||||
await client.query('ROLLBACK TO SAVEPOINT purge');
|
||||
failed.push(table);
|
||||
}
|
||||
}
|
||||
pending = failed;
|
||||
}
|
||||
if (pending.length) throw new Error(`не удалось очистить ${tenant}: ${pending.join(', ')}`);
|
||||
},
|
||||
undefined,
|
||||
connection,
|
||||
);
|
||||
const left = Object.entries((await snapshot(tenant, tables)).counts).filter(([, n]) => n > 0);
|
||||
if (left.length) {
|
||||
throw new Error(
|
||||
`после очистки у ${tenant} остались строки (нужен админ БД: E2E_DB_ADMIN_URL): ${left.map(([t, n]) => `${t}=${n}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hex64 = () => randomBytes(32).toString('hex');
|
||||
const code = (prefix: string) => `${prefix}_${randomBytes(4).toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Синтетические данные арендатора: по 2+ строки в каждой известной таблице с tenant_id.
|
||||
* Возвращает id сотрудников и список засеянных таблиц. Пишет мигратор (владелец таблиц),
|
||||
* FORCE RLS действует и на него, поэтому app.tenant_id выставлен в транзакции.
|
||||
*/
|
||||
async function seed(tenant: string) {
|
||||
const ids = (n: number) => Array.from({ length: n }, () => randomUlid());
|
||||
const actor = randomUlid();
|
||||
const corr = () => randomUlid();
|
||||
const seeded = new Set<string>();
|
||||
const employees = ids(3);
|
||||
const amounts = [150_000n, 72_300n, -12_300n].map(
|
||||
(base) => base + BigInt(randomBytes(2).readUInt16BE(0)),
|
||||
);
|
||||
await withMigrator(
|
||||
tenant,
|
||||
async (client) => {
|
||||
const q = async (table: string, sql: string, params: unknown[]) => {
|
||||
await client.query(sql, params);
|
||||
seeded.add(table);
|
||||
};
|
||||
const [ou1, ou2] = ids(2);
|
||||
for (const [i, ou] of [ou1, ou2].entries()) {
|
||||
await q(
|
||||
'staff_org_units',
|
||||
'INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
|
||||
[ou, tenant, `rehome-${i}-${randomBytes(3).toString('hex')}`, `Точка ${i + 1}`],
|
||||
);
|
||||
}
|
||||
const professions = ids(2);
|
||||
for (const [i, id] of professions.entries()) {
|
||||
await q(
|
||||
'staff_professions',
|
||||
'INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
|
||||
[id, tenant, code('prof'), `Профессия ${i}-${randomBytes(3).toString('hex')}`],
|
||||
);
|
||||
}
|
||||
for (const [i, id] of employees.entries()) {
|
||||
await q(
|
||||
'staff_employees',
|
||||
'INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, primary_org_unit_id, profession_id) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[
|
||||
id,
|
||||
tenant,
|
||||
`R-${randomBytes(4).toString('hex')}`,
|
||||
`Сотрудник ${i + 1}`,
|
||||
i % 2 ? ou2 : ou1,
|
||||
professions[i % 2],
|
||||
],
|
||||
);
|
||||
}
|
||||
const metricKeys = [code('metric'), code('metric')];
|
||||
for (const key of metricKeys) {
|
||||
await q(
|
||||
'staff_metric_definitions',
|
||||
'INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1,$2,$3,$4)',
|
||||
[randomUlid(), tenant, key, `Метрика ${key}`],
|
||||
);
|
||||
}
|
||||
for (const [i, key] of metricKeys.entries()) {
|
||||
await q(
|
||||
'staff_daily_metrics',
|
||||
'INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[randomUlid(), tenant, ou1, `2026-09-0${i + 1}`, key, 1000 + i, actor],
|
||||
);
|
||||
}
|
||||
for (const [i, ou] of [ou1, ou2].entries()) {
|
||||
await q(
|
||||
'staff_org_unit_terms',
|
||||
'INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, ou, `2026-0${i + 1}-01`, actor],
|
||||
);
|
||||
}
|
||||
const rules = ids(2);
|
||||
for (const [i, id] of rules.entries()) {
|
||||
await q(
|
||||
'staff_pay_rules',
|
||||
'INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by) VALUES ($1,$2,$3,1,$4,$5,$6,$7)',
|
||||
[
|
||||
id,
|
||||
tenant,
|
||||
code('rule'),
|
||||
`Правило ${i}`,
|
||||
JSON.stringify({ kind: 'hourly', rate_minor: 12_300 }),
|
||||
'2026-01-01',
|
||||
actor,
|
||||
],
|
||||
);
|
||||
}
|
||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
||||
await q(
|
||||
'staff_pay_assignments',
|
||||
'INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
||||
);
|
||||
}
|
||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
||||
await q(
|
||||
'staff_attendance_events',
|
||||
'INSERT INTO staff_attendance_events (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
emp,
|
||||
i ? 'check_out' : 'check_in',
|
||||
'terminal',
|
||||
`src-${randomBytes(6).toString('hex')}`,
|
||||
'2026-09-01T09:00:00Z',
|
||||
actor,
|
||||
corr(),
|
||||
],
|
||||
);
|
||||
await q(
|
||||
'staff_work_sessions',
|
||||
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, emp, ou1, '2026-09-01'],
|
||||
);
|
||||
await q(
|
||||
'staff_timesheet_entries',
|
||||
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, emp, '2026-09-01', actor],
|
||||
);
|
||||
await q(
|
||||
'staff_work_items',
|
||||
'INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[randomUlid(), tenant, emp, ou1, '2026-09-01', 'rehome_item', actor],
|
||||
);
|
||||
}
|
||||
const runs = ids(2);
|
||||
for (const [i, run] of runs.entries()) {
|
||||
await q(
|
||||
'staff_payroll_runs',
|
||||
'INSERT INTO staff_payroll_runs (id, tenant_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5)',
|
||||
[run, tenant, `2026-0${i + 7}-01`, `2026-0${i + 7}-28`, actor],
|
||||
);
|
||||
await q(
|
||||
'staff_payroll_lines',
|
||||
'INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
|
||||
[randomUlid(), tenant, run, employees[i], ou1, `2026-0${i + 7}-02`, rules[i], 'hourly'],
|
||||
);
|
||||
await q(
|
||||
'staff_payouts',
|
||||
'INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[randomUlid(), tenant, employees[i], `2026-0${i + 8}-05`, 50_000 + i, actor],
|
||||
);
|
||||
}
|
||||
for (const [i, amount] of amounts.entries()) {
|
||||
await q(
|
||||
'staff_payroll_ledger',
|
||||
'INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, ref_type, correlation_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
employees[i % 2],
|
||||
'2026-09-01',
|
||||
amount < 0n ? 'adjustment' : 'accrual',
|
||||
amount.toString(),
|
||||
'manual',
|
||||
corr(),
|
||||
actor,
|
||||
],
|
||||
);
|
||||
}
|
||||
const outbox = ids(2);
|
||||
for (const id of outbox) {
|
||||
await q(
|
||||
'staff_outbox',
|
||||
'INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9,now())',
|
||||
[id, tenant, 'staff.rehome_seed', 'employee', employees[0], 'user', actor, '{}', corr()],
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await q(
|
||||
'staff_inbox',
|
||||
'INSERT INTO staff_inbox (event_id, tenant_id, event_type, event_version, correlation_id) VALUES ($1,$2,$3,1,$4)',
|
||||
[randomUlid(), tenant, 'core.rehome_seed', corr()],
|
||||
);
|
||||
await q(
|
||||
'staff_idempotency_keys',
|
||||
"INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at) VALUES ($1,$2,$3, now() + interval '1 day')",
|
||||
[tenant, `idem-${randomBytes(8).toString('hex')}`, hex64()],
|
||||
);
|
||||
const endpoint = `https://push.example.test/${randomBytes(8).toString('hex')}`;
|
||||
await q(
|
||||
'staff_push_subscriptions',
|
||||
'INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
employees[i],
|
||||
endpoint,
|
||||
randomBytes(16).toString('base64url'),
|
||||
randomBytes(8).toString('base64url'),
|
||||
],
|
||||
);
|
||||
await q(
|
||||
'staff_notification_deliveries',
|
||||
'INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, employees[i], outbox[i], endpoint],
|
||||
);
|
||||
await q(
|
||||
'staff_standalone_employee_access',
|
||||
'INSERT INTO staff_standalone_employee_access (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
employees[i],
|
||||
randomUlid(),
|
||||
createHash('sha256').update(randomBytes(32)).digest('hex'),
|
||||
'e2e…',
|
||||
actor,
|
||||
],
|
||||
);
|
||||
await q(
|
||||
'staff_sync_change_log',
|
||||
'INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUlid(), tenant, 'employee', employees[i], 'upsert'],
|
||||
);
|
||||
await q(
|
||||
'staff_audit_log',
|
||||
'INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, correlation_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[randomUlid(), tenant, actor, 'employee.seeded', 'employee', employees[i], corr()],
|
||||
);
|
||||
}
|
||||
const devices = ids(2);
|
||||
for (const device of devices) {
|
||||
await q(
|
||||
'staff_sync_devices',
|
||||
'INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[
|
||||
device,
|
||||
tenant,
|
||||
`dev-${randomBytes(4).toString('hex')}`,
|
||||
'Терминал',
|
||||
hex64(),
|
||||
'kstaff…',
|
||||
actor,
|
||||
],
|
||||
);
|
||||
await q(
|
||||
'staff_sync_command_inbox',
|
||||
'INSERT INTO staff_sync_command_inbox (id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,now())',
|
||||
[
|
||||
randomUlid(),
|
||||
tenant,
|
||||
device,
|
||||
'record_attendance',
|
||||
`idem-${randomBytes(6).toString('hex')}`,
|
||||
corr(),
|
||||
'applied',
|
||||
],
|
||||
);
|
||||
await q(
|
||||
'staff_sync_device_checkpoints',
|
||||
'INSERT INTO staff_sync_device_checkpoints (device_id, tenant_id) VALUES ($1,$2)',
|
||||
[device, tenant],
|
||||
);
|
||||
}
|
||||
},
|
||||
actor,
|
||||
);
|
||||
return { employees, seeded };
|
||||
}
|
||||
|
||||
function runRehome(args: string[], adminUrl: string) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--from', STUB, '--to', TARGET, ...args],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...baseEnv(), DATABASE_ADMIN_URL: adminUrl },
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
const lines = (result.stdout ?? '').trim().split('\n');
|
||||
let report: RehomeReport | null = null;
|
||||
for (const line of [...lines].reverse()) {
|
||||
if (!line.trim().startsWith('{')) continue;
|
||||
try {
|
||||
report = JSON.parse(line) as RehomeReport;
|
||||
break;
|
||||
} catch {
|
||||
// не JSON — ищем дальше
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
report,
|
||||
};
|
||||
}
|
||||
|
||||
const state: {
|
||||
tables: string[];
|
||||
employees: string[];
|
||||
before?: { stub: Snapshot; foreign: Snapshot };
|
||||
} = { tables: [], employees: [] };
|
||||
|
||||
describe('PILOT-REHOME: перенос заглушки арендатора на настоящий tenant', () => {
|
||||
it('[PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema)', async () => {
|
||||
state.tables = await tenantTables();
|
||||
expect(state.tables.length).toBeGreaterThan(0);
|
||||
await purge(STUB, state.tables);
|
||||
await purge(TARGET, state.tables);
|
||||
const stub = await seed(STUB);
|
||||
const foreign = await seed(FOREIGN);
|
||||
state.employees = stub.employees;
|
||||
const unseeded = state.tables.filter((table) => !stub.seeded.has(table));
|
||||
expect(
|
||||
unseeded,
|
||||
`таблицы с tenant_id без сида в tests/e2e/60-pilot-rehome.e2e.ts — перенос арендатора их не проверит: ${unseeded.join(', ')}`,
|
||||
).toEqual([]);
|
||||
const stubSnap = await snapshot(STUB, state.tables);
|
||||
const foreignSnap = await snapshot(FOREIGN, state.tables);
|
||||
const empty = state.tables.filter((t) => stubSnap.counts[t]! < 1 || foreignSnap.counts[t]! < 1);
|
||||
expect(empty, `сид не виден через RLS: ${empty.join(', ')}`).toEqual([]);
|
||||
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(foreign.seeded.size).toBe(stub.seeded.size);
|
||||
state.before = { stub: stubSnap, foreign: foreignSnap };
|
||||
});
|
||||
|
||||
it('[PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает', async () => {
|
||||
const before = state.before;
|
||||
expect(before, 'сид не выполнен (см. [PILOT-REHOME-SEED])').toBeDefined();
|
||||
if (!existsSync(scriptPath)) {
|
||||
throw new Error(
|
||||
'scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки ' +
|
||||
`${STUB} → ${TARGET} не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. ` +
|
||||
`Засеяно таблиц: ${state.tables.length}, ledger SUM(amount_minor)=${before!.stub.ledgerSum}`,
|
||||
);
|
||||
}
|
||||
const adminUrl = ctx().adminDatabaseUrl;
|
||||
if (!adminUrl) {
|
||||
throw new Error('нет админа БД: задайте E2E_DB_ADMIN_URL или E2E_PG_ADMIN_URL');
|
||||
}
|
||||
const expectedTables = Object.fromEntries(
|
||||
Object.entries(before!.stub.counts).filter(([, n]) => n > 0),
|
||||
);
|
||||
|
||||
// 1. --dry-run: печатает те же числа и ничего не меняет.
|
||||
const dry = runRehome(['--dry-run', '--json'], adminUrl);
|
||||
expect(dry.status, `dry-run exit: ${dry.stderr}`).toBe(0);
|
||||
expect(dry.report, `dry-run без JSON-отчёта: ${dry.stdout}`).not.toBeNull();
|
||||
const dryTables = Object.fromEntries(
|
||||
Object.entries(dry.report!.tables ?? {}).filter(([, n]) => Number(n) > 0),
|
||||
);
|
||||
expect(dryTables).toEqual(expectedTables);
|
||||
expect(String(dry.report!.ledger_amount_minor_sum)).toBe(before!.stub.ledgerSum);
|
||||
expect(await snapshot(STUB, state.tables), '--dry-run изменил данные заглушки').toEqual(
|
||||
before!.stub,
|
||||
);
|
||||
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// 2. Перенос ролью администратора БД.
|
||||
const real = runRehome(['--json'], adminUrl);
|
||||
expect(real.status, `перенос exit: ${real.stderr}`).toBe(0);
|
||||
const afterStub = await snapshot(STUB, state.tables);
|
||||
const afterTarget = await snapshot(TARGET, state.tables);
|
||||
const leftovers = state.tables.filter((t) => afterStub.counts[t] !== 0);
|
||||
expect(leftovers, 'под заглушкой остались строки').toEqual([]);
|
||||
for (const table of state.tables) {
|
||||
const expected = before!.stub.counts[table]!;
|
||||
if (table === 'staff_audit_log')
|
||||
expect(
|
||||
afterTarget.counts[table]!,
|
||||
'staff_audit_log: у нового арендатора нет новой записи о переносе',
|
||||
).toBeGreaterThan(expected);
|
||||
else expect(afterTarget.counts[table], table).toBe(expected);
|
||||
}
|
||||
expect(afterTarget.ledgerSum).toBe(before!.stub.ledgerSum);
|
||||
expect(await snapshot(FOREIGN, state.tables), 'посторонний арендатор изменён').toEqual(
|
||||
before!.foreign,
|
||||
);
|
||||
const distinct = await withMigrator(TARGET, async (client) => {
|
||||
const found: string[] = [];
|
||||
for (const table of state.tables) {
|
||||
const r = await client.query<{ tenant_id: string }>(
|
||||
`SELECT DISTINCT tenant_id FROM ${table} WHERE tenant_id IN ($1, $2)`,
|
||||
[STUB, TARGET],
|
||||
);
|
||||
found.push(...r.rows.map((row) => `${table}:${row.tenant_id}`));
|
||||
}
|
||||
return found;
|
||||
});
|
||||
expect(distinct.filter((d) => d.endsWith(STUB))).toEqual([]);
|
||||
|
||||
// 3. Запись аудита у нового арендатора.
|
||||
const audit = await dbQueryAs<{ action: string }>(
|
||||
TARGET,
|
||||
"SELECT action FROM staff_audit_log WHERE tenant_id = $1 AND action ILIKE '%rehome%'",
|
||||
[TARGET],
|
||||
);
|
||||
expect(
|
||||
audit.length,
|
||||
'нет записи staff_audit_log о переносе у нового арендатора',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// 4. Роль приложения видит тех же сотрудников под новым tenant и ничего под старым.
|
||||
const underTarget = await dbQueryAs<{ id: string }>(
|
||||
TARGET,
|
||||
'SELECT id FROM staff_employees ORDER BY id',
|
||||
);
|
||||
expect(underTarget.map((row) => row.id)).toEqual([...state.employees].sort());
|
||||
expect(await dbQueryAs(STUB, 'SELECT id FROM staff_employees')).toEqual([]);
|
||||
|
||||
// 5. Журнал начислений по-прежнему append-only для приложения.
|
||||
await expect(
|
||||
dbQueryAs(TARGET, 'UPDATE staff_payroll_ledger SET amount_minor = amount_minor + 1'),
|
||||
).rejects.toThrow(/permission denied|append-only|read-only/i);
|
||||
|
||||
// 6. Повторный запуск отказывает и ничего не меняет.
|
||||
const again = runRehome(['--json'], adminUrl);
|
||||
expect(again.status, 'повторный перенос должен отказать').not.toBe(0);
|
||||
expect(await snapshot(TARGET, state.tables)).toEqual(afterTarget);
|
||||
});
|
||||
});
|
||||
|
|
@ -71,12 +71,20 @@ export async function dbQuery<T extends pg.QueryResultRow = Record<string, unkno
|
|||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const context = ctx();
|
||||
const client = new pg.Client({ connectionString: context.appDatabaseUrl });
|
||||
return dbQueryAs<T>(ctx().tenantId, sql, params);
|
||||
}
|
||||
|
||||
/** Запрос ролью приложения (krona_staff_app) под указанным арендатором (app.tenant_id). */
|
||||
export async function dbQueryAs<T extends pg.QueryResultRow = Record<string, unknown>>(
|
||||
tenantId: string,
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const client = new pg.Client({ connectionString: ctx().appDatabaseUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [context.tenantId]);
|
||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
||||
const result = await client.query<T>(sql, params);
|
||||
await client.query('COMMIT');
|
||||
return result.rows;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются",
|
||||
"S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)",
|
||||
"A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL",
|
||||
"N12-DATE": "month=2026-13 → 500 (22008) вместо 400"
|
||||
"N12-DATE": "month=2026-13 → 500 (22008) вместо 400",
|
||||
"PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
tests/e2e/report-evidence/round3-pilot-rehome.txt
Normal file
37
tests/e2e/report-evidence/round3-pilot-rehome.txt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# PILOT-REHOME: доказательство, что сценарий ловит ошибки переноса (TASK-GROK-3 п.5)
|
||||
# Эталон — временный scripts/rehome-tenant.mjs (минимальный UPDATE tenant_id под session_replication_role=replica + запись аудита);
|
||||
# он НЕ коммитился и удалён после прогона. Команда: node scripts/test/run-e2e.mjs (PG17, admin-режим).
|
||||
|
||||
== эталон (reference), не коммитится
|
||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 176ms
|
||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 340ms
|
||||
× tests/e2e/30-owner-decisions.e2e.ts > Решения владельца: роли, пакеты зарплаты, согласие на биометрию > [D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403 111ms
|
||||
== эталон (no-audit), не коммитится
|
||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 164ms
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 191ms
|
||||
→ staff_audit_log: у нового арендатора нет новой записи о переносе: expected 2 to be greater than 2
|
||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
||||
== эталон (dry-writes), не коммитится
|
||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 182ms
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 88ms
|
||||
→ --dry-run изменил данные заглушки: expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) }
|
||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
||||
== без скрипта
|
||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 169ms
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 4ms
|
||||
→ scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 → 01M2R6AC5652NPT3MG34DKN4ZP не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. Засеяно таблиц: 27, ledger SUM(amount_minor)=313227
|
||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
||||
|
||||
# Мутации эталона (первый прогон, до уточнения сообщений ассертов)
|
||||
== мутация эталона: no-audit
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 196ms
|
||||
→ expected 2 to be greater than 2
|
||||
== мутация эталона: no-refuse
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 319ms
|
||||
→ повторный перенос должен отказать: expected +0 not to be +0 // Object.is equality
|
||||
== мутация эталона: skip-table
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 195ms
|
||||
→ под заглушкой остались строки: expected [ 'staff_work_items' ] to deeply equal []
|
||||
== мутация эталона: dry-writes
|
||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 91ms
|
||||
→ expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) }
|
||||
Loading…
Reference in a new issue