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:
parent
0453168d70
commit
ac8ab3cc02
2 changed files with 295 additions and 1 deletions
287
tests/e2e/50-money-and-errors.e2e.ts
Normal file
287
tests/e2e/50-money-and-errors.e2e.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue