Почему: 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, …) — запрос ролью приложения под заданным арендатором.
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
// HTTP-клиент e2e. Тело ответа типизировано как `any` намеренно: тесты проверяют
|
||
// внешний контракт JSON, а не типы src/. Правила no-unsafe-* отключены только здесь
|
||
// и в файлах сценариев.
|
||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
||
import pg from 'pg';
|
||
import { inject } from 'vitest';
|
||
import type { E2EContext } from './context.js';
|
||
|
||
export interface ApiResponse {
|
||
status: number;
|
||
body: any;
|
||
text: string;
|
||
headers: Headers;
|
||
}
|
||
|
||
export interface RequestOptions {
|
||
token?: string | undefined;
|
||
body?: unknown;
|
||
headers?: Record<string, string>;
|
||
base?: 'secured' | 'open' | 'ratelimit';
|
||
}
|
||
|
||
export function ctx(): E2EContext {
|
||
return inject('e2e');
|
||
}
|
||
|
||
export async function api(
|
||
method: string,
|
||
path: string,
|
||
options: RequestOptions = {},
|
||
): Promise<ApiResponse> {
|
||
const context = ctx();
|
||
const base =
|
||
options.base === 'open'
|
||
? context.openBaseUrl
|
||
: options.base === 'ratelimit'
|
||
? context.rateLimitBaseUrl
|
||
: context.baseUrl;
|
||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
||
let payload: string | undefined;
|
||
if (options.body !== undefined) {
|
||
headers['content-type'] = 'application/json';
|
||
payload = JSON.stringify(options.body);
|
||
}
|
||
const url = path.startsWith('/api/') || path.startsWith('/staff') ? path : `/api/v1/staff${path}`;
|
||
const response = await fetch(`${base}${url}`, {
|
||
method,
|
||
headers,
|
||
...(payload === undefined ? {} : { body: payload }),
|
||
redirect: 'manual',
|
||
});
|
||
const text = await response.text();
|
||
let body: any = null;
|
||
try {
|
||
body = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
body = null;
|
||
}
|
||
return { status: response.status, body, text, headers: response.headers };
|
||
}
|
||
|
||
/** Краткое описание ответа для сообщений assert (без токенов). */
|
||
export function describeResponse(response: ApiResponse): string {
|
||
const snippet = response.text.length > 300 ? `${response.text.slice(0, 300)}…` : response.text;
|
||
return `HTTP ${response.status} ${snippet}`;
|
||
}
|
||
|
||
/** Запрос к БД ролью приложения внутри tenant-транзакции (RLS действует). */
|
||
export async function dbQuery<T extends pg.QueryResultRow = Record<string, unknown>>(
|
||
sql: string,
|
||
params: unknown[] = [],
|
||
): Promise<T[]> {
|
||
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)", [tenantId]);
|
||
const result = await client.query<T>(sql, params);
|
||
await client.query('COMMIT');
|
||
return result.rows;
|
||
} finally {
|
||
await client.end();
|
||
}
|
||
}
|