krona-staff/tests/e2e/harness/fixtures.ts
grok 646e8d4f22 test(e2e): стенд с реальным сервером и PostgreSQL 17
Зачем: 156 зелёных тестов на fakeDatabase не поймали, что ключевые
сценарии (создание сотрудника, подтверждение смены, расчёт зарплаты)
всегда падают на реальной PostgreSQL. Нужен приёмочный стенд, который
проверяет продукт как чёрный ящик: настоящий процесс сервера, настоящая
БД, роли как в pilot, но с минимальными привилегиями.

- tests/e2e/harness/global-setup.ts: создаёт БД и роли через
  scripts/test/provision-pg.mjs, применяет миграции ролью krona_migrator
  (NOSUPERUSER), поднимает `tsx src/server.ts` под krona_staff_app с
  токенами/VAPID из crypto.randomBytes; три процесса: с токенами, без
  токенов (как корневой compose.yaml) и отдельный для проверки rate limit;
  логи серверов пишутся в tests/e2e/evidence/.
- *.e2e.ts и отдельный vitest.e2e.config.ts: e2e не попадают в `npm test`,
  поэтому `npm run check` не требует БД и не краснеет от известных дефектов.
- [HARNESS]/[CTRL] — позитивные контроли (версия PG >= 17, атрибуты ролей,
  app-роль не видит schema_migrations, RBAC, уволенный теряет доступ):
  если они красные, красные дефектные сценарии ничего не доказывают.
- scripts/test/run-e2e.mjs: «храповик» известных дефектов по
  tests/e2e/known-defects.json (новый красный или исправленный-но-не-
  вычеркнутый ID = exit 1), таблица e2e-table.md и проверка, что в
  evidence нет ничего похожего на секреты стенда.

Логи названы *.txt, т.к. *.log в .gitignore.
2026-09-26 15:18:31 +07:00

155 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Подготовка данных через публичный API. Везде только синтетические данные.
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { expect } from 'vitest';
import { api, ctx, dbQuery, describeResponse } from './client.js';
import { randomUlid, synthetic } from './context.js';
export interface OrgUnit {
id: string;
code: string;
}
export interface Employee {
id: string;
external_ref: string;
personnel_number: string;
}
/** Создаёт торговые точки через импорт (единственный публичный способ в текущем API). */
export async function createOrgUnits(count: number): Promise<OrgUnit[]> {
const units = Array.from({ length: count }, () => {
const code = `e2e-${randomUlid().slice(-10).toLowerCase()}`;
return { code, name: synthetic('Точка') };
});
const response = await api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: { source_name: synthetic('import-units'), units },
});
expect(response.status, describeResponse(response)).toBeLessThan(300);
const rows = await dbQuery<{ id: string; code: string }>(
'SELECT id, code FROM staff_org_units WHERE code = ANY($1::text[])',
[units.map((unit) => unit.code)],
);
return units.map((unit) => ({
code: unit.code,
id: rows.find((row) => row.code === unit.code)!.id,
}));
}
/**
* Создаёт сотрудников через импорт книги владельца: POST /employees на реальной БД
* сейчас падает (N1), а сценарии, не относящиеся к N1, не должны зависеть от этого дефекта.
*/
export async function importEmployees(count: number): Promise<Employee[]> {
const employees = Array.from({ length: count }, () => {
const suffix = randomUlid().slice(-8);
return {
external_ref: `e2e:${suffix}`,
personnel_number: `E2E-${suffix}`,
display_name: synthetic('Сотрудник'),
};
});
const response = await api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: { source_name: synthetic('import-employees'), employees },
});
expect(response.status, describeResponse(response)).toBeLessThan(300);
const rows = await dbQuery<{ id: string; external_ref: string }>(
'SELECT id, external_ref FROM staff_employees WHERE external_ref = ANY($1::text[])',
[employees.map((employee) => employee.external_ref)],
);
return employees.map((employee) => ({
...employee,
id: rows.find((row) => row.external_ref === employee.external_ref)!.id,
}));
}
export async function issueEmployeeToken(employeeId: string): Promise<string> {
const response = await api('POST', `/employees/${employeeId}/access-token`, {
token: ctx().managerToken,
});
expect(response.status, describeResponse(response)).toBe(200);
return response.body.access_token as string;
}
export async function clock(
employeeId: string,
eventType: 'check_in' | 'check_out',
occurredAt: Date,
orgUnitId?: string,
) {
return api('POST', '/attendance/events', {
token: ctx().terminalToken,
body: {
employee_id: employeeId,
event_type: eventType,
occurred_at: occurredAt.toISOString(),
source_event_id: `e2e-${randomUlid()}`,
...(orgUnitId ? { org_unit_id: orgUnitId } : {}),
},
});
}
/** Дата (UTC) N дней назад в полдень: всегда в окне терминала (<=180 дней) и не в будущем. */
export function daysAgo(days: number, hourUtc = 9): Date {
const date = new Date();
date.setUTCDate(date.getUTCDate() - days);
date.setUTCHours(hourUtc, 0, 0, 0);
return date;
}
export function isoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Отработанная смена: check_in/check_out терминалом + сверка; возвращает id смены. */
export async function workedShift(
employeeId: string,
orgUnitId: string,
day: Date,
hours = 8,
): Promise<string> {
const start = new Date(day);
const end = new Date(start.getTime() + hours * 3_600_000);
const inResponse = await clock(employeeId, 'check_in', start, orgUnitId);
expect(inResponse.status, describeResponse(inResponse)).toBe(201);
const outResponse = await clock(employeeId, 'check_out', end, orgUnitId);
expect(outResponse.status, describeResponse(outResponse)).toBe(201);
const reconcile = await api('POST', '/work-sessions/reconcile', {
token: ctx().managerToken,
body: { date_from: isoDate(start), date_to: isoDate(start) },
});
expect(reconcile.status, describeResponse(reconcile)).toBe(200);
const rows = await dbQuery<{ id: string }>(
`SELECT id FROM staff_work_sessions
WHERE employee_id = $1 AND org_unit_id = $2 AND business_date = $3::date`,
[employeeId, orgUnitId, isoDate(start)],
);
expect(rows.length, 'сверка должна создать смену').toBe(1);
return rows[0]!.id;
}
export async function hourlyRuleId(): Promise<string> {
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
expect(response.status, describeResponse(response)).toBe(200);
const rules = response.body.pay_rules as Array<{ id: string; code: string }>;
const hourly = rules.find((rule) => rule.code === 'hourly') ?? rules[0];
expect(hourly, 'должно существовать хотя бы одно правило оплаты').toBeTruthy();
return hourly!.id;
}
/** Синтетическая ставка 123,45 ₽/ч (заведомо не реальная сумма). */
export const SYNTHETIC_RATE_MINOR = 12_345;
export async function assignHourly(employeeId: string, orgUnitId: string | null) {
const response = await api('PUT', `/employees/${employeeId}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await hourlyRuleId(),
org_unit_id: orgUnitId,
rate_minor: SYNTHETIC_RATE_MINOR,
effective_from: '2026-01-01',
},
});
expect(response.status, describeResponse(response)).toBe(200);
}