test(e2e): сценарии K6–K9, S9, A4 и N12-DATE из аудита владельца

Зачем: TASK-GROK-2 §3 — денежные дефекты K6–K9, S9, A4 и дата расчёта
были в AUDIT.md, но не покрыты стендом; без красных сценариев исправления
Codex нечем принимать.

Что (tests/e2e/50-money-and-errors.e2e.ts, всё красное и внесено в
known-defects.json):
- K6/D5: отклонение смены из подписанной ведомости → 409 или сторно.
  Сейчас падает на предусловии: подтверждение смены 500 (K3).
- K7/D2: две записи начисления за один день (две точки) и повторный
  импорт с другой суммой → итог = сумма по книге. Факт: 22200 вместо 33300.
- K8/D1: после подписи меняется ставка, затем проверочный пересчёт
  (предполагаемый контракт C4 GET /payroll/runs/:id/verify). Блокирован K3.
- K9/D3: официант по пресету waiter_upsell: params.shift_rate_minor не
  сохраняется (сам D3, до K3); далее начисление = оплата смены (после K3).
- S9: удаление точки с устройством мигратором → 23502 вместо удаления или
  23503 (публичного API удаления нет — так же, как в live-sync.mjs владельца).
- A4: тела 500 не должны содержать SQLSTATE и текст PostgreSQL.
- N12-DATE: month=2026-13 → 400 (факт 500, 22008); 2026-02-30 уже 400.
Прогон run-e2e: 45 red / 9 green, храповик OK.
This commit is contained in:
grok 2026-09-26 15:24:02 +07:00
parent 0453168d70
commit ac8ab3cc02
2 changed files with 295 additions and 1 deletions

View file

@ -0,0 +1,287 @@
// Недостающие сценарии из аудита владельца (TASK-GROK-2 §3): денежная логика K6–K9,
// удаление точки с устройством (S9), утечка ошибок PostgreSQL (A4), дата расчёта (N12-DATE).
// Каждый тест проверяет ПРАВИЛЬНОЕ поведение: красный = открытый дефект. Где путь
// упирается в K3 (outbox без occurred_at), assert предусловия прямо называет K3.
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
import pg from 'pg';
import { describe, expect, it } from 'vitest';
import { api, ctx, dbQuery, describeResponse, type ApiResponse } from './harness/client.js';
import { randomUlid, synthetic } from './harness/context.js';
import {
SYNTHETIC_RATE_MINOR,
assignHourly,
createOrgUnits,
daysAgo,
importEmployees,
isoDate,
workedShift,
} from './harness/fixtures.js';
const monthOf = (date: Date) => date.toISOString().slice(0, 7);
async function ledgerTotal(employeeId: string, kinds = ['accrual', 'adjustment']) {
const rows = await dbQuery<{ total: string }>(
`SELECT COALESCE(SUM(amount_minor), 0)::text AS total FROM staff_payroll_ledger
WHERE employee_id = $1 AND kind = ANY($2::text[])`,
[employeeId, kinds],
);
return BigInt(rows[0]!.total);
}
async function confirmShift(sessionId: string) {
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'confirmed' },
});
expect(
confirm.status,
`подтверждение смены (предусловие; падает, пока открыт K3): ${describeResponse(confirm)}`,
).toBe(200);
}
/** Расчёт и утверждение (подпись) ведомости точки за месяц дня `day`. */
async function calculateAndApprove(unitId: string, day: Date): Promise<string> {
const calculate = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: monthOf(day), org_unit_id: unitId },
});
expect(calculate.status, `расчёт (предусловие, K3): ${describeResponse(calculate)}`).toBe(200);
const runId = (calculate.body.run_id ?? calculate.body.id) as string;
const approve = await api('POST', `/payroll/runs/${runId}/approve`, {
token: ctx().managerToken,
});
expect(approve.status, `утверждение (предусловие, K3): ${describeResponse(approve)}`).toBe(200);
return runId;
}
async function payRuleId(code: string): Promise<string> {
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
expect(response.status, describeResponse(response)).toBe(200);
const rule = (response.body.pay_rules as Array<{ id: string; code: string }>).find(
(item) => item.code === code,
);
expect(rule, `правило ${code} должно быть в пресете`).toBeTruthy();
return rule!.id;
}
/** Признаки ошибки PostgreSQL в теле ответа клиенту (SQLSTATE, имена отношений/столбцов). */
function postgresLeak(response: ApiResponse): string[] {
const leaks: string[] = [];
const body = (response.body ?? {}) as Record<string, unknown>;
if (typeof body.code === 'string' && /^[0-9A-Z]{5}$/.test(body.code)) {
leaks.push(`code=${body.code}`);
}
if (
/relation "|column "|violates|constraint|syntax for type|deduced for parameter/i.test(
response.text,
)
) {
leaks.push(`text=${response.text.slice(0, 120)}`);
}
return leaks;
}
describe('Деньги, S9, A4 и даты на реальной PostgreSQL', () => {
it('[K6/D5] отклонение смены из подписанной ведомости → 409 или сторно (начисление не остаётся)', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(4);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
await calculateAndApprove(unit!.id, day);
expect(await ledgerTotal(employee!.id)).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
const reject = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'rejected', correction_note: synthetic('отказ после подписи') },
});
if (reject.status === 409) return; // смена подписанной ведомости неизменяема — верно
expect(reject.status, describeResponse(reject)).toBe(200);
expect(
await ledgerTotal(employee!.id),
'после отклонения оплаченной смены должно быть сторно: итог начислений 0',
).toBe(0n);
});
it('[K7/D2] повторный импорт книги: итог в реестре равен сумме по книге (две точки в один день)', async () => {
const [employee] = await importEmployees(1);
const date = isoDate(daysAgo(5));
const keyA = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-a`;
const keyB = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-b`;
const entry = (externalKey: string, amount: number) => ({
external_key: externalKey,
employee_external_ref: employee!.external_ref,
business_date: date,
kind: 'accrual',
amount_minor: amount,
});
const importBook = (amountA: number, amountB: number) =>
api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: {
source_name: synthetic('import-book'),
payroll_entries: [entry(keyA, amountA), entry(keyB, amountB)],
},
});
const first = await importBook(11_100, 22_200);
expect(first.status, describeResponse(first)).toBeLessThan(300);
const afterFirst = await ledgerTotal(employee!.id);
const second = await importBook(15_500, 22_200);
expect(second.status, describeResponse(second)).toBeLessThan(300);
const afterSecond = await ledgerTotal(employee!.id);
expect({ afterFirst, afterSecond }).toEqual({
afterFirst: 33_300n, // 111,00 ₽ + 222,00 ₽ по книге
afterSecond: 37_700n, // 155,00 ₽ + 222,00 ₽ после исправления суммы точки A
});
});
it('[K8/D1] подписанная ведомость воспроизводится после смены ставки (сумма та же)', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(6);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
const runId = await calculateAndApprove(unit!.id, day);
const signed = await api('GET', `/payroll/runs/${runId}`, { token: ctx().managerToken });
expect(signed.status, describeResponse(signed)).toBe(200);
// Меняем ставку «задним числом» (то же effective_from) после подписи.
const change = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await payRuleId('hourly'),
org_unit_id: unit!.id,
rate_minor: SYNTHETIC_RATE_MINOR * 3,
effective_from: '2026-01-01',
},
});
expect(change.status, describeResponse(change)).toBe(200);
// Предполагаемый контракт C4 (NOTES-FOR-EXECUTORS): пересчёт подписанной ведомости
// для проверки по снимку правил и ставок на момент подписи.
const verify = await api('GET', `/payroll/runs/${runId}/verify`, {
token: ctx().managerToken,
});
expect(verify.status, `C4 verify: ${describeResponse(verify)}`).toBe(200);
expect({
matches: verify.body.matches,
recalculated: String(verify.body.recalculated_total_minor),
}).toEqual({ matches: true, recalculated: String(8 * SYNTHETIC_RATE_MINOR) });
});
it('[K9/D3] официант по пресету получает оплату смены из параметров пресета', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
const shiftRate = 111_111; // синтетические 1 111,11 ₽ за смену
// Предполагаемый контракт: параметры пресета передаются объектом `params`
// (ключи — param_declarations правила).
const assign = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await payRuleId('waiter_upsell'),
org_unit_id: unit!.id,
effective_from: '2026-01-01',
params: {
shift_rate_minor: shiftRate,
percent_bp: 0,
upsell_rate_minor: 0,
avg_check_target_minor: 99_999_999,
avg_check_bonus_minor: 0,
},
},
});
expect(assign.status, describeResponse(assign)).toBe(200);
const saved = await api('GET', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
});
expect(
saved.body.params?.shift_rate_minor,
`параметр пресета должен сохраниться: ${describeResponse(saved)}`,
).toBe(shiftRate);
const day = daysAgo(7);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
await confirmShift(sessionId);
await calculateAndApprove(unit!.id, day);
expect(await ledgerTotal(employee!.id)).toBe(BigInt(shiftRate));
});
it('[S9] удаление точки с привязанным устройством: удалена или понятный отказ, не 23502', async () => {
const [unit] = await createOrgUnits(1);
const device = await api('POST', '/sync/devices', {
token: ctx().managerToken,
body: {
device_code: `E2E-${randomUlid().slice(-10)}`,
name: synthetic('Терминал S9'),
org_unit_id: unit!.id,
},
});
expect(device.status, describeResponse(device)).toBe(201);
// Публичного API удаления точки нет, поэтому удаляем так же, как аудит владельца:
// SQL-запросом владельца схемы (мигратор), в tenant-контексте (FORCE RLS).
const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl });
await migrator.connect();
let outcome = 'deleted';
try {
await migrator.query('BEGIN');
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [ctx().tenantId]);
await migrator.query('DELETE FROM staff_org_units WHERE id = $1', [unit!.id]);
await migrator.query('ROLLBACK');
} catch (error) {
outcome = `${(error as { code?: string }).code} ${(error as Error).message}`;
await migrator.query('ROLLBACK').catch(() => undefined);
} finally {
await migrator.end();
}
// Верно: удалено, либо отказ внешнего ключа 23503 (понятный запрет).
expect(outcome, 'удаление точки с устройством').toMatch(/^(deleted|23503 )/);
});
it('[A4] ответы 500 не содержат кода и текста ошибки PostgreSQL', async () => {
const [unit] = await createOrgUnits(1);
const probes: Array<[string, ApiResponse]> = [
[
'POST /employees',
await api('POST', '/employees', {
token: ctx().managerToken,
body: {
personnel_number: `E2E-${randomUlid().slice(-8)}`,
display_name: synthetic('Сотрудник'),
position: 'E2E-должность',
},
}),
],
[
'POST /payroll/calculate month=2026-13',
await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: '2026-13', org_unit_id: unit!.id },
}),
],
];
const leaks = probes
.filter(([, response]) => response.status >= 500)
.map(([name, response]) => ({ name, leaks: postgresLeak(response) }))
.filter((item) => item.leaks.length > 0);
expect(leaks, 'тела 500 с деталями PostgreSQL').toEqual([]);
});
it('[N12-DATE] расчёт с несуществующей датой (month=2026-13, 2026-02-30) → 400', async () => {
const [unit] = await createOrgUnits(1);
const month = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: '2026-13', org_unit_id: unit!.id },
});
const range = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { from: '2026-02-01', to: '2026-02-30', org_unit_id: unit!.id },
});
expect(
{ month: month.status, range: range.status },
`${describeResponse(month)} | ${describeResponse(range)}`,
).toEqual({ month: 400, range: 400 });
});
});

View file

@ -38,6 +38,13 @@
"OWN-08": "решение владельца: возврат с комментарием", "OWN-08": "решение владельца: возврат с комментарием",
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
"K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", "K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
"K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала" "K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала",
"K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)",
"K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽",
"K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3",
"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"
} }
} }