Зачем: решения владельца (роли точки/бухгалтера/директора, пакет зарплаты с подписью директора, неизменяемость после подписи, зарплата по точке фактической смены, согласие на биометрию) должны стать исполняемыми критериями приёмки до того, как Codex начнёт этап 4, иначе реализацию будет нечем проверить. API ещё нет, поэтому тесты написаны против наиболее вероятного контракта C1 (access-grants), C2 (payroll/batches, submit/sign/return, notifications), C3 (biometric-consent, terminal enrollment); контракт и инварианты описаны в tests/e2e/NOTES-FOR-EXECUTORS.md. Сейчас все OWN красные (404 Route not found) и внесены в known-defects.json. В NOTES также: найденные сценариями дефекты с указанием файлов, конфликт NODE_ENV/standalone для pilot, N6, требования к раннеру docker-host.
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('[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('[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('[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('[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('[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',
|
||
});
|
||
});
|
||
});
|