food-market/tests/e2e/reports/systemic-2026-05-26.md
nns f2f64646b1 docs(e2e): финальный системный отчёт 2026-05-26 — все 9 сценариев зелёные
Сводный отчёт systemic-2026-05-26.md + зелёные прогоны всех сценариев
(82 шага, 0 падений). За сессию исправлено: refresh-rotation (TokenId +
zero reuse-leeway), сериализуемое проведение приёмки против lost update,
MoySklad BaseUrl в конфиг. Покрыты впервые: конкурентность приёмок,
дашбордная выручка, импорт MoySklad (идемпотентность/маппинг). Зафиксированы
gap'ы по нереализованным отчётам (профит/ABC/экспорт, ТЗ 2.12).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:30:46 +05:00

65 lines
7.3 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.

# Системное тестирование Food Market — 2026-05-26
> Инициировано Opus 4.7 по плану из `docs/TZ-тестирование.md` (продолжение сессии 2026-05-23, см. `systemic-2026-05-23.md`).
> Среда: docker `food-market-postgres` (postgres:16-alpine, 127.0.0.1:5434) + dotnet 8 API локально на :5081 + E2E через axios/psql.
> Запуск: `E2E_ADMIN_URL=http://127.0.0.1:5081 ./tests/e2e/run.sh <scenario> --api-only`.
## 0. TL;DR
| Сценарий | Результат |
|---|---|
| **full-cycle** (signup → bootstrap → supply → sale) | **12/12 ✓** |
| **multi-tenant-isolation** (Alpha/Beta + SuperAdmin override) | **12/12 ✓** |
| **documents-edge** (защита денег и инварианта на posting) | **10/10 ✓** |
| **auth-edge** (refresh-rotation, подделка JWT, архив-орг, signup) | **10/10 ✓** |
| **catalog-edge** (валидация, дубли, удаление с зависимостями) | **12/12 ✓** |
| **stock-invariant-deep** (Stock == Σ Movement, post/unpost/repost) | **10/10 ✓** |
| **stock-concurrency** (конкурентное проведение приёмок) | **4/4 ✓** |
| **reports-stats** (дашбордная выручка + tenant-изоляция) | **5/5 ✓** |
| **moysklad-import** (импорт, идемпотентность, маппинг) | **7/7 ✓** |
**Итого 9 сценариев, 82 шага — все зелёные. Багов нет.**
**Найдено и исправлено в этой сессии: 3 бага** (1 critical, 1 high, + связка из 2 правок по безопасности refresh-токенов).
## 1. Найденные баги и исправления
### BUG #1 — Старый refresh-token остаётся валидным после ротации (commit 32729e7)
`auth-edge` step03. Две причины, обе закрыты:
1. `AuthorizationController.Exchange` (refresh-ветка) строил новый principal с нуля и прокидывал только `AuthorizationId`, но не `TokenId`. Handler OpenIddict `RedeemTokenEntry` читает `TokenId` из подписываемого principal — без него старый refresh не помечался `Redeemed`.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный **reuse-leeway** — погашенный refresh ещё принимается в этом окне. Для розничной админки это дыра: утёкший refresh живёт 30с после ротации.
**Severity:** high (одна утечка refresh → продлеваемый доступ).
**Fix:** прокидываем `TokenId` старого refresh в новый principal + `SetRefreshTokenReuseLeeway(TimeSpan.Zero)` в `Program.cs`. Проверено в БД: старый токен переходит в `redeemed` и немедленно отвергается (4xx).
### BUG #2 — Конкурентное проведение приёмки ломает инвариант остатков (commit 15f27fd)
`stock-concurrency` step03. `Supply.Post` шёл на дефолтной изоляции (Read Committed), а `StockService.ApplyMovementAsync` делает read-modify-write по `Stock.Quantity` без RowVersion. Под гонкой:
- двойное проведение ОДНОЙ приёмки (оба запроса читают `Status=Draft` до коммита соседа) применяло остаток дважды — 2 `StockMovement`, но `Stock` рос на одну партию → `Stock=32`, `Σ Movement=39`;
- две разные приёмки одного товара могли потерять обновление остатка и посчитать скользящее среднее `Cost` от устаревшего `currentQty`.
**Severity:** critical (нарушение главного учётного инварианта `Stock == Σ StockMovement`).
**Fix:** проведение переведено на `IsolationLevel.Serializable` (как `RetailSale.Post`), конфликт сериализации (SQLSTATE 40001/40P01) перехватывается → 409 (клиент повторяет, а не получает 500). После фикса: `Stock=32`, `Σ=32`, statuses 204+409.
### Доработка для тестируемости — базовый URL MoySklad из конфига (commit e78e921)
`MoySkladClient.BaseUrl` был константой `api.moysklad.ru`, импорт нельзя было прогнать без боевого токена. Вынесли `BaseAddress` в `MoySklad:BaseUrl` (дефолт — прежний боевой URL); e2e наводит клиент на mock-сервер `lib/moysklad-mock.ts`. Прод-поведение не меняется.
## 2. Что покрыто впервые в этой сессии
- **Конкурентность приёмок** (`stock-concurrency`) — раньше под Serializable был только `RetailSale.Post`; теперь и `Supply.Post`.
- **Дашбордная выручка** (`reports-stats`) — только Posted-чеки, непрерывная серия по дням, параметр `days`, строгая tenant-изоляция `/stats`.
- **Импорт MoySklad** (`moysklad-import`) — сохранение/маскирование токена, test-connection, фоновый job, идемпотентность повторного импорта (`overwrite=false → Skipped`), обновление по ключу (`overwrite=true → Updated`), маппинг полей в БД (BIN/тип/адрес контрагента; артикул/НДС/упаковка/цена/штрихкод/группа/страна товара) — поля сверены с `MoySkladDtos`/remap 1.2.
## 3. Logic gaps (не баги — нереализованный функционал по ТЗ 2.12)
- Отчёт **«прибыль»** (выручка себестоимость) не реализован: `RetailSaleLine` не хранит снимок себестоимости, `/stats` отдаёт только валовую выручку.
- **ABC-анализ**, **«остатки на дату»** (`SUM(Movement) до даты`), **экспорт CSV/XLSX** — отдельного `ReportsController` нет.
- `Supply.Unpost` использует те же read-modify-write по `Stock` без транзакции — под одновременным unpost теоретически уязвим к lost update (вне фокуса этой сессии; проведение `Post` закрыто).
## 4. Замечание по окружению
- На dev-vm установлен только SDK **8.0.126**; в `global.json` репозитория остаётся `8.0.417`. Локальный даунгрейд `global.json` использован только для сборки и **в коммиты не включён**.
- `admin.food-market.kz` — отдельный деплой с другой БД; e2e обязательно гонять против локального API, подключённого к контейнеру `food-market-postgres` (иначе DB-проверки через `docker exec` некогерентны).