From ac8ab3cc02bb70479c876f8b1456b352874d802a Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 15:24:02 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20K6=E2=80=93K9,=20S9,=20A4=20=D0=B8=20N12-D?= =?UTF-8?q?ATE=20=D0=B8=D0=B7=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: 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. --- tests/e2e/50-money-and-errors.e2e.ts | 287 +++++++++++++++++++++++++++ tests/e2e/known-defects.json | 9 +- 2 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/50-money-and-errors.e2e.ts diff --git a/tests/e2e/50-money-and-errors.e2e.ts b/tests/e2e/50-money-and-errors.e2e.ts new file mode 100644 index 0000000..4093d81 --- /dev/null +++ b/tests/e2e/50-money-and-errors.e2e.ts @@ -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 { + 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 { + 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; + 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 }); + }); +}); diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index 4ec0e14..1d0276c 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -38,6 +38,13 @@ "OWN-08": "решение владельца: возврат с комментарием", "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", "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" } }