test(e2e): сценарии решений владельца (OWN-01..11) и заметки исполнителям

Зачем: решения владельца (роли точки/бухгалтера/директора, пакет зарплаты
с подписью директора, неизменяемость после подписи, зарплата по точке
фактической смены, согласие на биометрию) должны стать исполняемыми
критериями приёмки до того, как 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.
This commit is contained in:
grok 2026-09-26 14:30:46 +07:00
parent c73c714611
commit 82a2a7ca37
3 changed files with 403 additions and 1 deletions

View file

@ -0,0 +1,270 @@
// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 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',
});
});
});

View file

@ -0,0 +1,121 @@
# Заметки для исполнителей (Codex / Antigravity)
Автор: Grok (стенд тестов и независимая приёмка). Я не меняю `src/`, `apps/`,
`migrations/`, `deploy/`; всё, что требует изменения продукта, описано здесь.
Как запускать:
```sh
# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают)
export E2E_PG_ADMIN_URL=postgres://<admin>:<pass>@127.0.0.1:55433/postgres
node scripts/test/run-e2e.mjs # храповик known-defects (как в CI)
node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные
E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском
./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e
```
**Правило храповика.** Исправили дефект → его сценарий позеленел →
`run-e2e.mjs` падает с «дефект исправлен, обновите known-defects.json» —
удалите ID из `tests/e2e/known-defects.json` в том же коммите. Новый красный
сценарий, которого нет в списке, — регрессия.
## 1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4
API пока нет, поэтому сценарии `tests/e2e/30-owner-decisions.e2e.ts` написаны
против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и
этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя.
Все пути относительно `/api/v1/staff`.
### C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC)
- `POST /access-grants` (только главный менеджер, `staff:employee:write`)
тело `{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": ["<ULID>"], "display_name": "..." }`
→ `201 { "id", "role", "org_unit_ids", "access_token" }`. Токен показывается один раз,
хранится как хеш (как ключи сотрудников), используется как `Authorization: Bearer`.
- `point_manager` видит/считает только свои `org_unit_ids`; чужая точка → `403`
(и для нового `POST /payroll/batches`, и для старого `POST /payroll/calculate`).
- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md).
### C2. Пакет зарплаты точки и согласование
| Метод и путь | Кто | Результат |
| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /payroll/batches` `{org_unit_id, from, to}` | accountant; point_manager своей точки | `201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]}` |
| `POST /payroll/batches/:id/submit` | только accountant | `200 {status:"submitted"}`; point_manager → `403`; повтор после подписи → `409` |
| `GET /notifications` | любой пользователь | `200 {notifications:[{type:"payroll.batch.submitted", subject_id:<batchId>}]}`; каждому директору ровно одно уведомление на отправку |
| `POST /payroll/batches/:id/sign` | только director | `200 {status:"signed"}`; accountant → `403`; если смена пакета изменилась после отправки → `409`, пакет получает `status:"returned"` |
| `POST /payroll/batches/:id/return` `{comment}` | director | `200 {status:"returned", return_comment}` |
| `GET /payroll/batches/:id` | accountant, director | `200 {status, return_comment, lines:[...]}` |
Инварианты, которые проверяют тесты:
- до подписи в `staff_payroll_ledger` для сотрудников пакета нет ни `accrual`, ни `payout` (OWN-05);
- подпись атомарно пишет `accrual` и `payout`; после подписи `PATCH /work-sessions/:id`
для смен пакета → `409`, пакет не отправляется повторно (OWN-06);
- строка пакета принадлежит точке, где отработана смена (`staff_work_sessions.org_unit_id`),
а не основной точке сотрудника (OWN-09).
### C3. Согласие на биометрию
- `PUT /employees/:id/biometric-consent` `{ "status": "granted" | "revoked", "document_ref": "..." }`
(главный менеджер) → `200`.
- `GET /terminal/employees/:id/enrollment` (терминальный токен) →
`200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }`.
Без записи о согласии — `false/consent_missing` (OWN-10); после отзыва — `false/consent_revoked` (OWN-11).
- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md);
сервер хранит только факт согласия. Тот же флаг стоит отдать в `/sync/pull` (employee payload).
Сейчас все OWN красные с `404 Route ... not found` (C1–C3 не реализованы);
OWN-03..09 дополнительно упрутся в N2 (подтверждение смены падает 500).
## 2. Дефекты продукта, найденные сценариями (все красные сейчас)
Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`.
| ID | Файл | Что исправить |
| -------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии |
| N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией |
| N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) |
| N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, `redirect:'manual'`; сейчас сервер подключается к `127.0.0.1` |
| N9 | `push-dispatcher.ts` | `ON CONFLICT ... DO UPDATE SET employee_id` → 409 для чужого endpoint |
| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 |
| N11 | импорт | валидировать `kind` и схему `items` → 400 вместо 500 (23514) |
| N12 | PUT pay-assignment | `additionalProperties:false`, верхние границы сумм |
| F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) |
| F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию |
| F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) |
| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` |
| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять |
| SYNC-A/B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) |
| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке |
| SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка |
| SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках |
| SYNC-E | `executeCommand` | проверять `status='active'` сотрудника |
| SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства |
| SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета |
| SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале |
| SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) |
| SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) |
| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` |
## 3. deploy/ (PILOT-1, PILOT-2) — Codex
- PILOT-1: все подстановки в `deploy/pilot/compose.yaml` → `${VAR:?VAR is required}`
(сейчас 14 без `:?`: пустой `STANDALONE_MANAGER_TOKEN` молча превращает pilot в F2).
- PILOT-2: `NODE_ENV: development` в `app`. Конфликт: `config.ts` запрещает
`STAFF_RUNTIME_MODE=standalone` при `NODE_ENV=production`. Нужно решение: либо
разрешить standalone в production при обязательных токенах и HTTPS, либо
отдельный режим для pilot. Тест требует только «не development».
- N6: `scripts/create-app-role.mjs` не работает мигратором без SUPERUSER
(«Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e
обходятся `scripts/test/provision-pg.mjs` (роли создаёт bootstrap-админ, как
`docker/postgres/init-app-role.sh`). В pilot мигратор = `POSTGRES_USER` =
суперпользователь; рекомендую схему как в provision-pg.mjs.
## 4. CI
- Задаче `docker-build` нужен раннер с меткой `docker-host` (Docker daemon).
Без него задача ждёт в очереди; остальные задачи не блокируются.
- `format` красная, пока не исправлен F14 (25 файлов, в основном `src/`, `docs/`, `tests/unit/`).
- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено.

View file

@ -27,6 +27,17 @@
"SYNC-J": "sync принимает отметки вне окна времени",
"SYNC-K": "ack принимает курсор больше выданного сервером",
"PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
"PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development"
"PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
"OWN-01": "решение владельца: роли точки (этап 4 Codex)",
"OWN-02": "решение владельца: пакет отправляет только бухгалтер",
"OWN-03": "решение владельца: уведомления директорам",
"OWN-04": "решение владельца: подписывает только директор",
"OWN-05": "решение владельца: ledger пуст до подписи",
"OWN-06": "решение владельца: подпись → ledger, неизменяемость",
"OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
"OWN-08": "решение владельца: возврат с комментарием",
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
"OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
"OWN-11": "решение владельца: отзыв согласия меняет статус для терминала"
}
}