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

7.3 KiB
Raw Blame History

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