Зачем: аудит владельца (docs/audit/2026-09-26/AUDIT.md) стал основным реестром дефектов; без сопоставления исполнители и приёмка говорят о разных ID для одной ошибки (TASK-GROK-2 §2). Что: теги переименованы по таблице владельца ([K3/N2], [K13/SYNC-A], [S5/SYNC-C], [D6/OWN-01], [K17/OWN-10] и т. д.), ключи known-defects.json — так же; N9–N12, F7, F10 остаются одиночными, как в таблице. Сверх таблицы, по тексту AUDIT.md: K1/SYNC-I (анонимный доступ без токенов — это K1 на /sync), K1/PILOT-1 (подстановки без :? в пилотном compose прямо названы в K1), K2/PILOT-2 (NODE_ENV=development в пилоте — суть K2). SYNC-C0, SYNC-K, OWN-03..06/08/09, PILOT-3 ID владельца не имеют. run-e2e принимает «/» в теге. Прогон: 38 red / 9 green, храповик OK — те же сценарии, новые имена.
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 4 Codex).
|
||
// Публичного API для ролей точки/бухгалтера/директора, пакетов зарплаты и согласия
|
||
// на биометрию пока нет, поэтому тесты написаны против наиболее вероятного контракта,
|
||
// описанного в tests/e2e/NOTES-FOR-EXECUTORS.md (C1–C3). Если Codex выберет другой
|
||
// контракт — меняется только этот файл и NOTES, смысл проверок остаётся.
|
||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { api, ctx, dbQuery, describeResponse } from './harness/client.js';
|
||
import { synthetic } from './harness/context.js';
|
||
import {
|
||
assignHourly,
|
||
createOrgUnits,
|
||
daysAgo,
|
||
importEmployees,
|
||
isoDate,
|
||
workedShift,
|
||
type OrgUnit,
|
||
} from './harness/fixtures.js';
|
||
|
||
type Role = 'point_manager' | 'accountant' | 'director';
|
||
|
||
/** C1: главный менеджер выдаёт ключ роли (standalone-аналог Core RBAC). */
|
||
async function grant(role: Role, orgUnitIds: string[] = []): Promise<string> {
|
||
const response = await api('POST', '/access-grants', {
|
||
token: ctx().managerToken,
|
||
body: { role, org_unit_ids: orgUnitIds, display_name: synthetic(role) },
|
||
});
|
||
expect(response.status, `C1 POST /access-grants (${role}): ${describeResponse(response)}`).toBe(
|
||
201,
|
||
);
|
||
return response.body.access_token as string;
|
||
}
|
||
|
||
async function confirm(sessionId: string) {
|
||
const response = await api('PATCH', `/work-sessions/${sessionId}`, {
|
||
token: ctx().managerToken,
|
||
body: { status: 'confirmed' },
|
||
});
|
||
expect(response.status, `подтверждение смены (см. N2): ${describeResponse(response)}`).toBe(200);
|
||
}
|
||
|
||
interface World {
|
||
unitA: OrgUnit;
|
||
unitB: OrgUnit;
|
||
employeeId: string;
|
||
sessionId: string;
|
||
day: Date;
|
||
}
|
||
|
||
/** Точка A и B, сотрудник точки A со сменой в точке A (по умолчанию подтверждённой; см. N2). */
|
||
async function world(confirmShift = true): Promise<World> {
|
||
const [unitA, unitB] = await createOrgUnits(2);
|
||
const [employee] = await importEmployees(1);
|
||
await assignHourly(employee!.id, unitA!.id);
|
||
const day = daysAgo(4);
|
||
const sessionId = await workedShift(employee!.id, unitA!.id, day, 8);
|
||
if (confirmShift) await confirm(sessionId);
|
||
return { unitA: unitA!, unitB: unitB!, employeeId: employee!.id, sessionId, day };
|
||
}
|
||
|
||
function period(day: Date) {
|
||
const from = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 1));
|
||
const to = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0));
|
||
return { from: isoDate(from), to: isoDate(to) };
|
||
}
|
||
|
||
/** C2: пакет зарплаты точки за месяц. */
|
||
async function createBatch(token: string, orgUnitId: string, day: Date) {
|
||
return api('POST', '/payroll/batches', {
|
||
token,
|
||
body: { org_unit_id: orgUnitId, ...period(day) },
|
||
});
|
||
}
|
||
|
||
async function submittedBatch(w: World, accountant: string): Promise<string> {
|
||
const created = await createBatch(accountant, w.unitA.id, w.day);
|
||
expect(created.status, `C2 создание пакета: ${describeResponse(created)}`).toBe(201);
|
||
const batchId = created.body.id as string;
|
||
const submit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant });
|
||
expect(submit.status, `C2 отправка пакета: ${describeResponse(submit)}`).toBe(200);
|
||
return batchId;
|
||
}
|
||
|
||
async function ledgerFor(employeeId: string) {
|
||
return dbQuery<{ kind: string; amount_minor: string }>(
|
||
`SELECT kind, amount_minor::text AS amount_minor FROM staff_payroll_ledger WHERE employee_id = $1`,
|
||
[employeeId],
|
||
);
|
||
}
|
||
|
||
describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => {
|
||
it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => {
|
||
const w = await world(false);
|
||
const managerA = await grant('point_manager', [w.unitA.id]);
|
||
const batch = await createBatch(managerA, w.unitB.id, w.day);
|
||
const legacy = await api('POST', '/payroll/calculate', {
|
||
token: managerA,
|
||
body: { month: isoDate(w.day).slice(0, 7), org_unit_id: w.unitB.id },
|
||
});
|
||
expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 });
|
||
});
|
||
|
||
it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => {
|
||
const w = await world(false);
|
||
const managerA = await grant('point_manager', [w.unitA.id]);
|
||
const created = await createBatch(managerA, w.unitA.id, w.day);
|
||
expect(
|
||
created.status,
|
||
`C2 создание пакета менеджером своей точки: ${describeResponse(created)}`,
|
||
).toBe(201);
|
||
const submit = await api('POST', `/payroll/batches/${created.body.id}/submit`, {
|
||
token: managerA,
|
||
});
|
||
expect(submit.status, describeResponse(submit)).toBe(403);
|
||
});
|
||
|
||
it('[OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление', async () => {
|
||
const accountant = await grant('accountant');
|
||
const directors = [await grant('director'), await grant('director')];
|
||
const w = await world();
|
||
const batchId = await submittedBatch(w, accountant);
|
||
for (const director of directors) {
|
||
const inbox = await api('GET', '/notifications', { token: director });
|
||
expect(inbox.status, `C2 уведомления директора: ${describeResponse(inbox)}`).toBe(200);
|
||
const aboutBatch = (
|
||
inbox.body.notifications as Array<{ type: string; subject_id: string }>
|
||
).filter((n) => n.type === 'payroll.batch.submitted' && n.subject_id === batchId);
|
||
expect(aboutBatch).toHaveLength(1);
|
||
}
|
||
});
|
||
|
||
it('[OWN-04] бухгалтер пытается подписать пакет → 403', async () => {
|
||
const accountant = await grant('accountant');
|
||
const w = await world();
|
||
const batchId = await submittedBatch(w, accountant);
|
||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: accountant });
|
||
expect(sign.status, describeResponse(sign)).toBe(403);
|
||
});
|
||
|
||
it('[OWN-05] до подписи директора в ledger нет начислений и выплат', async () => {
|
||
const accountant = await grant('accountant');
|
||
const w = await world();
|
||
await submittedBatch(w, accountant);
|
||
expect(await ledgerFor(w.employeeId)).toEqual([]);
|
||
});
|
||
|
||
it('[OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы', async () => {
|
||
const accountant = await grant('accountant');
|
||
const director = await grant('director');
|
||
const w = await world();
|
||
const batchId = await submittedBatch(w, accountant);
|
||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director });
|
||
expect(sign.status, describeResponse(sign)).toBe(200);
|
||
const kinds = new Set((await ledgerFor(w.employeeId)).map((row) => row.kind));
|
||
const editShift = await api('PATCH', `/work-sessions/${w.sessionId}`, {
|
||
token: ctx().managerToken,
|
||
body: {
|
||
status: 'confirmed',
|
||
worked_minutes: 1,
|
||
correction_note: 'E2E попытка правки после подписи',
|
||
},
|
||
});
|
||
const resubmit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant });
|
||
expect({
|
||
accrual: kinds.has('accrual'),
|
||
payout: kinds.has('payout'),
|
||
editShift: editShift.status,
|
||
resubmit: resubmit.status,
|
||
}).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 });
|
||
});
|
||
|
||
it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => {
|
||
const accountant = await grant('accountant');
|
||
const director = await grant('director');
|
||
const w = await world();
|
||
const batchId = await submittedBatch(w, accountant);
|
||
const edit = await api('PATCH', `/work-sessions/${w.sessionId}`, {
|
||
token: ctx().managerToken,
|
||
body: {
|
||
status: 'confirmed',
|
||
worked_minutes: 420,
|
||
correction_note: 'E2E правка после отправки',
|
||
},
|
||
});
|
||
expect(edit.status, describeResponse(edit)).toBe(200);
|
||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director });
|
||
const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant });
|
||
expect({ sign: sign.status, status: batch.body?.status }).toEqual({
|
||
sign: 409,
|
||
status: 'returned',
|
||
});
|
||
});
|
||
|
||
it('[OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён', async () => {
|
||
const accountant = await grant('accountant');
|
||
const director = await grant('director');
|
||
const w = await world();
|
||
const batchId = await submittedBatch(w, accountant);
|
||
const comment = synthetic('Комментарий директора');
|
||
const back = await api('POST', `/payroll/batches/${batchId}/return`, {
|
||
token: director,
|
||
body: { comment },
|
||
});
|
||
expect(back.status, describeResponse(back)).toBe(200);
|
||
const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant });
|
||
expect({ status: batch.body?.status, comment: batch.body?.return_comment }).toEqual({
|
||
status: 'returned',
|
||
comment,
|
||
});
|
||
});
|
||
|
||
it('[OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B', async () => {
|
||
const accountant = await grant('accountant');
|
||
const [unitA, unitB] = await createOrgUnits(2);
|
||
const [employee] = await importEmployees(1);
|
||
await assignHourly(employee!.id, unitA!.id);
|
||
const day = daysAgo(5);
|
||
const sessionId = await workedShift(employee!.id, unitB!.id, day, 6);
|
||
await confirm(sessionId);
|
||
const batchB = await createBatch(accountant, unitB!.id, day);
|
||
const batchA = await createBatch(accountant, unitA!.id, day);
|
||
expect(batchB.status, describeResponse(batchB)).toBe(201);
|
||
const sessionsOf = (batch: typeof batchB) =>
|
||
((batch.body?.lines ?? []) as Array<{ work_session_id: string }>).map(
|
||
(l) => l.work_session_id,
|
||
);
|
||
expect({
|
||
inB: sessionsOf(batchB).includes(sessionId),
|
||
inA: sessionsOf(batchA).includes(sessionId),
|
||
}).toEqual({
|
||
inB: true,
|
||
inA: false,
|
||
});
|
||
});
|
||
|
||
it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => {
|
||
const [employee] = await importEmployees(1);
|
||
const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, {
|
||
token: ctx().terminalToken,
|
||
});
|
||
expect(response.status, `C3: ${describeResponse(response)}`).toBe(200);
|
||
expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' });
|
||
});
|
||
|
||
it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => {
|
||
const [employee] = await importEmployees(1);
|
||
const setConsent = (status: 'granted' | 'revoked') =>
|
||
api('PUT', `/employees/${employee!.id}/biometric-consent`, {
|
||
token: ctx().managerToken,
|
||
body: { status, document_ref: synthetic('согласие') },
|
||
});
|
||
const enrollment = () =>
|
||
api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken });
|
||
const granted = await setConsent('granted');
|
||
expect(granted.status, `C3 PUT biometric-consent: ${describeResponse(granted)}`).toBe(200);
|
||
const before = await enrollment();
|
||
const revoked = await setConsent('revoked');
|
||
expect(revoked.status, describeResponse(revoked)).toBe(200);
|
||
const after = await enrollment();
|
||
expect({
|
||
before: before.body?.registration_allowed,
|
||
after: after.body?.registration_allowed,
|
||
reason: after.body?.reason,
|
||
}).toEqual({
|
||
before: true,
|
||
after: false,
|
||
reason: 'consent_revoked',
|
||
});
|
||
});
|
||
});
|