krona-staff/tests/e2e/NOTES-FOR-EXECUTORS.md
grok 986843d8d4 docs(e2e): REPORT и NOTES — круг 3
Почему: TASK-GROK-3 — отчёт с командами и фактическим выводом и новые находки для исполнителей.
REPORT: раздел «Круг 3» (ожидания владельца на SHA из задания — все шесть совпали; проверка
форматирования; все открытые ветки после fetch 16:16 NOVT — конфликтов нет; локальная сборка
«после слияния» — храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0;
PILOT-REHOME), новые числа ci-local (46 red / 10 green), «Не проверено».
NOTES: контракт C6 для scripts/rehome-tenant.mjs (с ограничениями схемы: RLS журнала, составные
FK, app.user_id в политиках INSERT) и раздел 7: двойное закрытие пула в sync-bootstrap.test.ts,
неописанный предохранитель live-postgres-remediation.test.ts, N5 в основе, FK outbox_id без tenant_id.
2026-09-26 16:25:47 +07:00

253 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Заметки для исполнителей (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 дополнительно упрутся в K3/N2 (подтверждение смены падает 500).
ID в названиях тестов — двойные «владелец/мой» (`[K3/N2]`, `[D6/OWN-01]`, `[K17/OWN-10]`,
`[K19/OWN-07]`), по таблице TASK-GROK-2 §2 и AUDIT.md.
### C4. Проверочный пересчёт подписанной ведомости (K8/D1)
- `GET /payroll/runs/:id/verify` (право `staff:payroll:read`) →
`200 { "run_id", "stored_total_minor", "recalculated_total_minor", "matches": boolean }`.
- Пересчёт идёт по снимку версий правил, параметров и ставок, действовавших при подписи
(и ревизиям смен), а не по текущим значениям. После смены ставки `matches` остаётся `true`.
### C5. Параметры пресета в назначении оплаты (K9/D3)
- `PUT /employees/:id/pay-assignment` принимает `params: { <key>: integer }`, ключи — из
`param_declarations` выбранного правила (например, `shift_rate_minor`, `percent_bp`,
`upsell_rate_minor`, `avg_check_target_minor`, `avg_check_bonus_minor` у `waiter_upsell`);
неизвестный ключ → 400. `GET .../pay-assignment` возвращает их в `params`.
- Старые поля `rate_minor`/`percent_bp`/`guarantee_minor` можно оставить как сокращения.
### C6. Перенос арендатора пилота (PILOT-REHOME, TASK-CODEX-2 п.2.2)
Что проверяет `tests/e2e/60-pilot-rehome.e2e.ts` (`[PILOT-REHOME]`):
- Запуск: `DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from 01J00000000000000000000000 --to 01M2R6AC5652NPT3MG34DKN4ZP [--dry-run] [--json]`.
Стенд передаёт `DATABASE_ADMIN_URL` суперпользователя в БД стенда (`E2E_DB_ADMIN_URL` или
`E2E_PG_ADMIN_URL` с именем БД).
- `--json`: последней строкой stdout — `{"tables":{"<таблица>":<строк у --from>},"ledger_amount_minor_sum":<число или строка>}`.
Таблицы с нулём можно не печатать.
- `--dry-run`: exit 0, те же числа, в БД ничего не меняется.
- Перенос: exit 0; под `--from` 0 строк во **всех** таблицах с `tenant_id` (список из
`information_schema.columns`, не вручную); у `--to` те же счётчики и та же
`SUM(amount_minor)` в `staff_payroll_ledger`; другие арендаторы не тронуты.
- В `staff_audit_log` у нового арендатора появляется запись с `action`, содержащим `rehome`
(например, `tenant.rehomed`, старый tenant — в `new_values`).
- После переноса `krona_staff_app` видит тех же сотрудников (те же `id`) под новым tenant и никого
под старым; `UPDATE staff_payroll_ledger` ролью приложения по-прежнему отклоняется.
- Повторный запуск (под `--from` пусто / у `--to` уже есть данные) — exit ≠ 0, ничего не меняет.
Что нужно учесть (видно по схеме, `pg_policy`/`pg_constraint` на PG17):
- У `staff_payroll_ledger` нет политики UPDATE/DELETE, у приложения нет права UPDATE, FORCE RLS
действует и на владельца: `krona_migrator` строки журнала не перенесёт. Нужна роль с обходом RLS
(суперпользователь/BYPASSRLS) — поэтому `DATABASE_ADMIN_URL`.
- Внешние ключи составные `(tenant_id, …)` и не DEFERRABLE: переносить родителя раньше детей
нельзя — нужен порядок, отложенные проверки или `session_replication_role = replica` в одной транзакции.
- Политики INSERT `staff_audit_log`, `staff_payroll_ledger`, `staff_attendance_events`,
`staff_timesheet_entries` требуют `actor_id/created_by/updated_by = current_setting('app.user_id')` —
для записи аудита без обхода RLS выставить `app.user_id`.
## 2. Дефекты продукта, найденные сценариями (все красные сейчас)
Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`.
| ID | Файл | Что исправить |
| ---------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| K4/N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии |
| K3/N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией |
| K5/N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) |
| K12/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`, верхние границы сумм |
| N12-DATE | `POST /payroll/calculate` | `month=2026-13` → 500 `22008`; проверять месяц 01–12 → 400 (диапазон `2026-02-30` уже даёт 400) |
| K6/D5 | `portal.ts` PATCH /work-sessions | смена подписанной ведомости: `409`, либо сторно в ledger; сейчас (на fix-live-database) 200 и начисление остаётся |
| K7/D2 | `operations.ts` импорт `payroll_entries` | дельта по сумме всех записей дня, вставка по ключу: две точки за день → 222,00 вместо 333,00 ₽; считать дельту по ключу |
| K8/D1 | расчёт/подпись | снимок правил и ставок в подписанной ведомости; контракт проверки C4 (ниже) |
| K9/D3 | PUT pay-assignment | сохранять `params` по `param_declarations` правила (C5 ниже); сейчас `shift_rate_minor` официанта теряется |
| S9 | миграция 0011 FK устройств | `ON DELETE SET NULL` на составном `(tenant_id, org_unit_id)` обнуляет `tenant_id` → 23502; `SET NULL (org_unit_id)` или RESTRICT |
| A4 | обработчик ошибок | в теле 5xx нет SQLSTATE (`code`) и текста PostgreSQL; подробности только в лог |
| K1/F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) |
| K10/F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию |
| K11/F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) |
| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` |
| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять |
| K13/SYNC-A, K13/SYNC-B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) |
| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке |
| S5/SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка |
| S5/SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках |
| S4/SYNC-E | `executeCommand` | проверять `status='active'` сотрудника |
| S4/SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства |
| S3/SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета |
| S8/SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале |
| K1/SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) |
| S2/SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) |
| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` |
## 3. deploy/ (K1/PILOT-1, K2/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 (23 файла на замер 2026-09-26, в основном `src/`, `docs/`, `tests/unit/`).
- `format` станет зелёной после влития `codex/fix-format-baseline` (проверено на ветке).
- Задача `windows` выключена, пока нет раннера с меткой `windows`: включается переменной
репозитория `KRONA_WINDOWS_RUNNER=true`; для полного e2e нужен секрет
`WINDOWS_E2E_PG_ADMIN_URL` (локальная PostgreSQL 17 на раннере).
- `tests/e2e/evidence/` не хранится в git; в CI это артефакт `e2e-evidence`.
- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено.
## 5. N13 (гонка первого расчёта) и утечка текста ошибок БД
- `scripts/test/n13-race.mjs`, вывод `tests/e2e/evidence/n13-race.txt`: 10/25/50
параллельных первых `POST /payroll/calculate` × 5 попыток. Как есть — все 500 (N2),
гонку не видно. В одноразовой БД с обходом N2 (`DEFAULT now()` только там) — 0 из 30
некорректных попыток (без смен и с подтверждённой сменой): один `run_id`, один расчёт в БД.
- Почему не воспроизводится (вывод из кода, не проверено отдельным экспериментом):
`calculatePayroll` сначала вызывает `ensureDefaultPayRules`, а там
`INSERT ... ON CONFLICT DO UPDATE` по одним и тем же строкам `staff_pay_rules` тенанта.
Первая транзакция держит блокировки этих строк до коммита, остальные ждут — расчёты
одного тенанта выполняются по очереди случайно. Если upsert правил вынесут из расчёта
или сделают `DO NOTHING`, окно `SELECT ... FOR UPDATE` (строки нет) → `INSERT ... ON
CONFLICT DO NOTHING` со своим `runId` → строки/UPDATE по несуществующему `runId` вернётся.
Рекомендация: явный `pg_advisory_xact_lock` по (tenant, org_unit, period_start) или
`INSERT ... ON CONFLICT DO UPDATE ... RETURNING id`. Скрипт стоит перезапустить после
такого изменения.
- Ответы 500 содержат сырой текст ошибки PostgreSQL (например
`null value in column "occurred_at" of relation "staff_outbox" ...`) — клиенту видна
схема БД. Для 5xx отдавать нейтральное сообщение, подробности — только в лог.
## 6. Приёмка веток исполнителей (2026-09-26)
Подробно — `tests/e2e/REPORT.md` и `tests/e2e/report-evidence/acceptance-*.txt`.
- `codex/fix-live-database` (ea68e12, перепроверено на b289dd6): K3/N2 и K4/N1 зелёные на живой PostgreSQL 17.
Дальше по цепочке открылись K5/N4 (196800 вместо 98400 — двойное начисление) и
K6/D5 (отклонение оплаченной смены: 200, начисление осталось). A4 частично: `POST /employees`
больше не 500, но `month=2026-13` по-прежнему отдаёт `code=22008` с текстом.
- `codex/fix-format-baseline` (fd1ec3d): `prettier --check .` чистый. **Но коммит «style»
содержит функциональное исправление Antigravity**: `src/domain/sync/service.ts` и
`tests/unit/sync.test.ts` после нормализации prettier совпадают с
`antigravity/fix-reject-device-shifts` (0 строк diff) и отличаются от 331e490 на 255/139
строк. Изменение поведения стоит вынести в отдельный коммит с объяснением или влить
ветку Antigravity отдельно, иначе история смешивает темы (AGENTS.md).
- `antigravity/fix-reject-device-shifts` (795bba0, перепроверено на 1df2019 — уже на f5ac179):
K13/SYNC-A и K13/SYNC-B зелёные, остальное без изменений.
- Ни одна ветка не влита в основную (f5ac179), поэтому ID из `known-defects.json` не удалены.
- Суммы в тестах кратны рублю: расчёт округляет строку до `rounding_unit_minor`
(по умолчанию 100 коп.). Если округление должно быть другим — это решение продукта, не теста.
## 7. Круг 3 (TASK-GROK-3, 2026-09-26)
Команды и полный вывод — `tests/e2e/REPORT.md` («Круг 3») и `tests/e2e/report-evidence/round3-*.md`.
Приёмка одной командой: `node scripts/test/accept-branch.mjs <ветка>…` (нужен `E2E_PG_ADMIN_URL`
или готовые роли в `E2E_MIGRATOR_URL` + `E2E_APP_URL`).
- **Antigravity, `sync-bootstrap.test.ts` (8baad55)**: `Error: Called end on pool more than once` при
`Tests 2 passed`. Причина: `app.close()` в тесте вызывает хук `onClose` → `database.close()`
(`src/app.ts:148`), затем `afterAll` закрывает тот же `Database` ещё раз. На текущей голове
`24cc18d` файл зелёный (`1 passed (1) | 2 passed (2)`).
- **Antigravity, `sync-rejection.test.ts` (1df2019)**: `column "created_by" of relation "staff_employees"
does not exist` в `beforeAll` (`Tests 2 skipped`). На `6c5bf36`/`b283736` исправлено.
- **Конфликты**: на прежних головах `1df2019 × 8baad55` и `8baad55 × 513cdff` конфликтовали в
`src/domain/sync/service.ts`; на текущих головах (fetch 16:16 NOVT) конфликтов нет ни в одной из 55 пар.
- **Codex, `codex/fix-format-baseline`**: на `513cdff` в `service.ts`/`sync.test.ts` было 232/130 строк сверх
prettier, все есть в `antigravity/fix-reject-device-shifts`; на `a05777c` — только форматирование (19 файлов).
- **Codex, `live-postgres-remediation.test.ts`**: выполняется только при `DATABASE_TEST_ISOLATED=true`,
`DATABASE_TEST_GUARD_TOKEN`, таблице `krona_staff_test_guard` с этим маркером и имени БД
`krona_staff_audit_*`. Соглашение нигде, кроме самого теста, не описано, и ни CI, ни
`scripts/test/*` его не выполняют — в `npm run check` файл всегда `skipped`. `accept-branch` ставит
предохранитель в одноразовой БД: `1 passed (1)`. Предложение: описать соглашение в README/AGENTS
и после слияния включить его в CI (могу сделать в своей зоне, `.forgejo/workflows/ci.yml`).
- **N5 до сих пор в основе**: `tenant-isolation.test.ts` красный на `b0243d5` и всех ветках от
`f5ac179` (`permission denied for table staff_employees`); исправление — в `grok/e2e-harness`.
Локальная сборка основа + fix-live-database + fix-sync-c0-bootstrap + grok/e2e-harness:
интеграционные 7/7, храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0.
- **Схема (низкий приоритет)**: `staff_notification_deliveries.outbox_id → staff_outbox(id)` — единственный
внешний ключ без `tenant_id`; все остальные составные `(tenant_id, …)`. База не мешает доставке
ссылаться на событие outbox другого арендатора (RLS фильтрует чтение, но не ссылку). Предложение:
`UNIQUE (tenant_id, id)` на `staff_outbox` и FK `(tenant_id, outbox_id)`.
- **PILOT-REHOME**: скрипта нет → `[PILOT-REHOME]` красный; контракт — C6.