Сводный отчёт 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>
65 lines
7.3 KiB
Markdown
65 lines
7.3 KiB
Markdown
# Системное тестирование 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` некогерентны).
|