# Системное тестирование 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 --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` некогерентны).