docs: ТЗ на доработку и тестирование (полный аудит 2026-05-22)

Два документа после полного обхода кодовой базы:
- docs/TZ-доработка.md — что нужно сделать, P0/P1/P2 приоритеты,
  дорожная карта по спринтам, технический долг
- docs/TZ-тестирование.md — сценарии тестирования по модулям,
  multi-tenant изоляция, регрессионный чек-лист, стратегия
  покрытия unit/integration/E2E

Сводка готовности: ядро (auth/catalog/Supply/RetailSale/multi-tenancy)
85-95%, но критичные пробелы: ОФД, складские документы Enter/Loss/
Transfer/Inventory, Demand, отчёты, POS-приложение, observability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-22 15:30:04 +05:00
parent 05c70f0368
commit 35d70c5d80
2 changed files with 851 additions and 0 deletions

View file

@ -0,0 +1,221 @@
# ТЗ на доработку Food Market
> Дата составления: 2026-05-22
> Автор анализа: Claude Opus 4.7
> Базируется на полном обходе кодовой базы `~/food-market` (backend + web + public + tests + deploy).
---
## 1. Текущее состояние системы
### 1.1. Сводная таблица готовности по модулям
| Модуль / слой | Статус | Готовность | Ключевой комментарий |
|---|---|---:|---|
| **food-market.domain** | ✅ готово | 95% | 26 сущностей, мультитенантность через `ITenantEntity`/`IOptionalTenantEntity`, чисто (нет TODO/HACK). |
| **food-market.infrastructure** | ✅ готово | 90% | EF Core 8, query filters, MailKit SMTP, StockService, MoySkladClient, 34 миграции. |
| **food-market.api (контроллеры)** | ✅ готово | 85% | 27 контроллеров, ~120 endpoint'ов, OpenIddict (password + refresh), CRUD полный. |
| **food-market.application** | 🟡 частично | 60% | Только DTO + интерфейсы, нет MediatR handlers — вся логика в контроллерах. |
| **food-market.web (админка)** | ✅ готово | 95% | 35 страниц, темная тема, адаптив, RU-локаль, onBlur-валидация. |
| **food-market.public (сайт)** | ✅ готово | 90% | Astro 4: landing, тарифы, блог, KB, legal; SignupForm → API. |
| **food-market.shared (POS контракты)** | ❌ нет | 0% | Только .csproj, ни одного CS-файла. |
| **food-market.pos.core + food-market.pos** | ❌ скелет | 5% | Пустой WPF-проект, только зависимости в .csproj. |
| **POS Sync API** | ❌ нет | 0% | Нет `/api/pos/sync`, нет `/api/pos/sales bulk`, нет WebSocket. |
| **Documents: Supply / RetailSale** | ✅ готово | 100% | Полный цикл (Draft → Post → Unpost), Stock + Movement, Cost (скользящее среднее). |
| **Documents: Inventory / Loss / Enter / Transfer** | ❌ нет | 0% | Нет контроллеров и страниц. Domain-сущности тоже не определены. |
| **Documents: Demand (оптовая отгрузка)** | ❌ нет | 0% | Только enum `MovementType.WholesaleSale`, контроллера/сущности нет. |
| **Reports** | ❌ нет | 5% | Есть `/api/sales/retail/stats` для дашборда, отдельных отчётов нет. |
| **MoySklad интеграция** | 🟡 частично | 50% | Импорт товаров и контрагентов ✅; нет Demand/Payment sync, нет webhook'ов. |
| **OpenIddict auth** | ✅ готово | 100% | Password + refresh_token; org_id, role, sub claims; persistent dev-ключи. |
| **Multi-tenancy** | ✅ готово | 95% | Query filters + `HttpContextTenantContext`; SuperAdmin override read-only/edit. |
| **Permission-based authz (RolePermissions)** | 🟡 частично | 30% | 30+ флагов в БД, но контроллеры проверяют только Roles (Admin/Cashier и т.д.). |
| **SuperAdmin Console** | ✅ готово | 95% | Organizations CRUD, audit log, archive/restore, platform settings (SMTP); биллинг-KPI заглушка. |
| **Hangfire** | 🟡 частично | 40% | `ReferencePriceRefreshJob` ✅; нет dashboard, нет scheduled cleanup, нет retry. |
| **Email (SMTP)** | ✅ готово | 100% | MailKit, DataProtection-шифрование пароля, forgot-password flow. |
| **Платёжные интеграции (Kaspi/Halyk/Jusan)** | ❌ нет | 0% | Упомянуты только в маркетинге; есть `PaymentMethod` enum, реальных шлюзов нет. |
| **ОФД (фискализация чеков РК)** | ❌ нет | 0% | Поля `FiscalSerial`/`FiscalRegNumber` есть в RetailPoint, отправки чеков нет. |
| **Маркетплейсы (Ozon, Wildberries, Kaspi Magazin)** | ❌ нет | 0% | Только маркетинговые баннеры. |
| **CI/CD (Forgejo Actions)** | ✅ готово | 90% | docker-api/web/public + smoke-тест /health после деплоя; self-hosted runner. |
| **Docker / docker-compose (stage)** | ✅ готово | 95% | postgres:16 + api + web + public + persistent volumes + local registry. |
| **E2E тесты** | 🟡 частично | 40% | Один сценарий `full-cycle` (12 шагов), отчёт в md; нет регрессии и параллелизма. |
| **Backend unit/integration тесты** | ❌ нет | 0% | Совсем. В CI стоит `\|\| echo "No tests yet"`. |
| **Logging / Serilog** | ✅ готово | 90% | Console + File с ротацией 14 дней; нет structured fields для бизнес-событий. |
| **Health checks (детальные)** | 🟡 частично | 20% | Только `/health` → {status:ok}; нет проверки БД, SMTP, диска. |
| **Метрики / observability** | ❌ нет | 0% | Нет Prometheus/AppInsights/OpenTelemetry. |
| **Rate limiting** | 🟡 частично | 15% | Только в `forgot-password` (3/час/IP, in-memory). |
| **Backup БД** | 🟡 частично | 60% | `deploy/backup.sh` есть, но не привязан к cron/timer, restore-скрипта нет. |
### 1.2. Что точно работает (готово к продакшен-использованию)
- **Регистрация → онбординг → ежедневная работа магазина** (товары, цены, приёмки, розничные продажи, остатки).
- **Управление пользователями и ролями**, soft-delete, передача владельца, восстановление пароля по email.
- **SuperAdmin-консоль платформы** (создание/архивирование организаций, SMTP, аудит).
- **Импорт каталога из МойСклад** (товары + контрагенты, асинхронный job с прогрессом).
- **Полный stage-стенд** на docker-compose с локальным registry и автодеплоем через Forgejo Actions.
### 1.3. Где точно не получится запуститься без доработки
- **Невозможно работать с физическим магазином без ККМ-фискализации** (РК требует чеки в ОФД).
- **Невозможно вести полноценный складской учёт** — нет инвентаризации, оприходования, списания, перемещения.
- **Нет аналитики/отчётов** — только сводка на дашборде, ABC-анализа, отчёта по поставщикам/прибыли нет.
- **Нет POS-приложения** — главная ценность проекта (offline-касса на Windows) — пустой проект.
- **Нет защиты от перебора паролей** в основных endpoint'ах (login/signup), только в forgot-password.
---
## 2. ТЗ на доработку по приоритетам
### Приоритет P0 — блокеры запуска в продакшен
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P0-1 | **Production-сертификаты OpenIddict** | Заменить `App_Data/openiddict-dev-key.xml` на реальные RSA/X.509 сертификаты, читать из KeyVault или secrets. | Сейчас токены подписываются dev-ключом без шифрования access-token. В проде это утечка claims. |
| P0-2 | **HTTPS на nginx** | Настроить TLS-termination на reverse-proxy (Let's Encrypt через certbot), форсировать HTTPS-only, добавить HSTS. | OAuth/refresh_token нельзя гонять по HTTP. |
| P0-3 | **Rate limiting на login/signup** | Добавить `Microsoft.AspNetCore.RateLimiting` (sliding window) на `/connect/token`, `/api/auth/signup`. 5 попыток/минута/IP, 20/час/IP. | Перебор паролей и DOS публичного signup. |
| P0-4 | **Health check БД** | Расширить `/health` на `/health/live` (alive) + `/health/ready` (DB ping, миграции применены). Использовать `Microsoft.Extensions.Diagnostics.HealthChecks`. | Сейчас docker-compose `healthcheck` возвращает 200 даже когда БД упала — стейдж не падает корректно. |
| P0-5 | **Permission-based authorization** | В `RolePermissions` (Domain) уже 30+ флагов. Реализовать `PermissionHandler` (IAuthorizationHandler) + атрибут `[RequiresPermission("ProductsEdit")]`, проверять в контроллерах вместо `[Authorize(Roles=...)]`. | Без этого все Admin'ы организации имеют полные права, кастомные роли (Менеджер/Кладовщик/Кассир) — фикция. |
| P0-6 | **Автоматический backup БД** | Создать systemd-timer (`food-market-backup.timer`) на ежедневный запуск `deploy/backup.sh`, добавить restore-инструкцию в `docs/`. Хранить 30 дней локально + копия в S3/MinIO. | Сейчас бэкап делается вручную, восстановления не отрепетировали. |
| P0-7 | **ОФД фискализация РК** | Интегрировать одного оператора (например, Webkassa или ОФД-Соло, КГД РК), отправлять `RetailSale.Post` чек, сохранять QR-код и фискальный номер в `RetailSale.FiscalQrCode`/`FiscalNumber`. | В РК продажа без чека ОФД — административное правонарушение. Без этого нельзя продавать. |
| P0-8 | **.env.example + документация secrets** | Описать все required env-переменные (`ConnectionStrings__DefaultConnection`, `Cors__AllowedOrigins`, `Smtp__*`, `OpenIddict__Issuer`, `OFD__Token`). Обновить `docs/stage-setup.md`. | Сейчас новый деплой не задокументирован. Передача знаний из головы — узкое место. |
| P0-9 | **Чек-листы перед релизом** | Документ `docs/release-checklist.md`: миграции применены, бэкап свежий, smoke-тесты прошли, E2E full-cycle зелёный, мониторинг здоров. | Снижает риск выкатки в проде сломанной версии. |
### Приоритет P1 — важные функциональные пробелы
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P1-1 | **Документ «Оприходование» (Enter)** | Domain-сущность `Enter` + `EnterLine` (как Supply, но без поставщика). Контроллер CRUD + Post/Unpost. UI-страницы `/inventory/enters`. Создаёт `StockMovement` с типом `Enter`. | Нужно вводить начальные остатки и излишки инвентаризации без поставщика. |
| P1-2 | **Документ «Списание» (Loss)** | Domain-сущность `Loss` + `LossLine` (причина: брак, истечение срока, бой, недостача). Контроллер + UI. `StockMovement` тип `WriteOff`. | Списание брака — обязательная функция магазина. |
| P1-3 | **Документ «Перемещение» (Transfer)** | Domain `Transfer` + `TransferLine` (FromStore → ToStore). Контроллер с атомарной транзакцией (списание + поступление). UI-форма. | В сети магазинов товар постоянно перемещается между складами. |
| P1-4 | **Документ «Инвентаризация» (Inventory)** | Domain `Inventory` + `InventoryLine` (book qty, actual qty, diff). Контроллер с импортом текущих остатков + Post создаёт корректирующее движение `InventoryAdjustment`. UI-форма с CSV-импортом фактического количества. | Регулярная сверка остатков — обязательно для розницы. |
| P1-5 | **Документ «Оптовая отгрузка» (Demand)** | Domain `Demand` + `DemandLine` (покупатель, способ оплаты — наличные/безнал, цена опт.). Контроллер. UI-страницы. `StockMovement` тип `WholesaleSale`. | Часть клиентов работает с юрлицами, отгрузка по накладной с НДС. |
| P1-6 | **Возврат от покупателя (CustomerReturn)** | Расширить `RetailSale` опцией «Возврат» (по чеку или без). Domain enum `MovementType.CustomerReturn` уже есть. UI: кнопка «Создать возврат» из посту-проведённой продажи. | Закон о защите прав потребителей в РК требует приёма возвратов. |
| P1-7 | **Возврат поставщику (SupplierReturn)** | По аналогии с CustomerReturn для Supply. UI: «Возврат поставщику» из проведённой приёмки. | Брак, неликвид, отказ от партии. |
| P1-8 | **Отчёт «Продажи»** | `/api/reports/sales` с группировкой по периодам (день/неделя/месяц), товарам, кассирам, кассам, способам оплаты. UI: страница `/reports/sales` с фильтром периода и экспортом в CSV/XLSX. | Без отчёта по продажам управлять бизнесом невозможно. |
| P1-9 | **Отчёт «Остатки на дату»** | `/api/reports/stock` с восстановлением остатков на любую дату через `StockMovement` журнал. UI с экспортом. | Налоговый учёт, инвентаризация. |
| P1-10 | **Отчёт «Прибыль»** | `/api/reports/profit` — выручка - себестоимость по периодам/группам/товарам. Используем `Cost` snapshot из `RetailSaleLine`. | Главный показатель магазина. |
| P1-11 | **Отчёт «ABC-анализ»** | Топ товаров по выручке/прибыли/маржинальности за период. Группа A/B/C по правилу Парето. | Управление ассортиментом. |
| P1-12 | **POS Sync API** | Endpoints: `GET /api/pos/sync?since={ts}` (товары, цены, остатки, контрагенты с изменениями после ts); `POST /api/pos/sales` (батч продаж с idempotency-key). Контракты в `food-market.shared`. | Без этого POS-приложение не может синхронизироваться с сервером. |
| P1-13 | **POS WPF MVP** | Минимальный UI: логин кассира (привязка к RetailPoint), список товаров/поиск по штрихкоду, корзина, оплата (нал/карта), печать чека (с ОФД), оффлайн-буфер на SQLite, фоновая синхронизация. | Главная фича проекта по позиционированию. |
| P1-14 | **MoySklad — Demand sync** | Импорт оптовых отгрузок (демандов) из МойСклад. Расширить `MoySkladImportService`. | Текущая интеграция только односторонняя для каталога; продажи не синхронизируются. |
| P1-15 | **MoySklad — webhook на изменения** | Получать webhook'и от МойСклад при изменении товаров, автоматически обновлять каталог (вместо ручного «Импортировать сейчас»). | Двусторонняя живая синхронизация. |
| P1-16 | **Hangfire dashboard** | Подключить `Hangfire.Dashboard` с авторизацией только для SuperAdmin. Добавить scheduled jobs: ежедневный cleanup `StockMovement` (старше 2 лет), audit-log (старше 90 дней), eтиничные jobs (e.g. рассылка email). | Сейчас jobs запускаются только вручную через AdminCleanupController; нет видимости. |
| P1-17 | **Метрики Prometheus** | Подключить `prometheus-net.AspNetCore` (`/metrics` endpoint). Базовый набор: http_requests_total, http_request_duration, db_query_duration, business: sales_count, supply_posted_count, errors_total. | Без observability нельзя гнать прод. |
| P1-18 | **Аудит мутаций tenant'а** | Расширить `SuperAdminAuditLog` на обычные org-мутации (`OrgAuditLog`): кто, когда, что изменил в Supply/Sale/Product/Counterparty. Хранить diff JSON. | Розница часто судится с сотрудниками по поводу пропавших товаров — нужны доказательства. |
| P1-19 | **OpenAPI спецификация** | Включить `Swashbuckle.AspNetCore`. Опубликовать `/swagger/v1/swagger.json` (только в Dev) и сгенерировать TypeScript-клиент для food-market.web. | Удалит ручной труд по типизации API в фронте и POS. |
| P1-20 | **Unit-тесты критичной логики** | Покрыть xUnit'ом: `StockService.ApplyMovement`, расчёт Cost при `SuppliesController.Post`, расчёт автонаценки по `ProductGroup.MarkupPercent`, валидация платежа `RetailSalesController.Post`, multi-tenant query filter. | Без этих тестов любое изменение логики Supply/Sale = потенциально баг с минусовыми остатками или потерями денег. |
| P1-21 | **Integration-тесты на тестовой БД** | `Testcontainers.PostgreSql` + `WebApplicationFactory`. Покрыть: signup-flow, supply post→unpost, retail sale post с overselling, tenant isolation (org A vs org B), permission проверки. | Регрессия на каждый коммит в CI. |
| P1-22 | **Email-нотификации** | Готовый MailKit-сервис расширить шаблонами: приглашение сотрудника (с временным паролем), еженедельный отчёт владельцу, low-stock alert. Хранить шаблоны в `Resources/EmailTemplates/*.html`. | Сейчас email отправляется только при forgot-password. |
### Приоритет P2 — желательные улучшения
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P2-1 | **Платёжный шлюз Kaspi Pay** | Интеграция Kaspi Pay QR (касса показывает QR, покупатель оплачивает с приложения, callback фиксирует оплату в RetailSale). | Самый популярный способ безнала в РК. |
| P2-2 | **Платёжные шлюзы банков** | Halyk Epay, Jusan Pay, Forte Pay (POS-терминал API или e-commerce). | Альтернативы Kaspi. |
| P2-3 | **Интеграция с маркетплейсами** | Ozon Seller API, Wildberries, Kaspi Magazin — синхронизация остатков и цен (исходящая), импорт заказов (входящая). | Расширение каналов продаж. |
| P2-4 | **2FA для админов** | TOTP (Google Authenticator) для роли Admin и SuperAdmin. Использовать `Identity.AddDefaultTokenProviders` + `AuthenticatorTokenProvider`. | Защита платёжного функционала. |
| P2-5 | **SSO (Google/Microsoft)** | Расширить OpenIddict внешними провайдерами для логина персонала. | UX для офисных сотрудников. |
| P2-6 | **Многоязычность (en/kz)** | Подключить `react-i18next` в web, выделить русские строки в `locales/ru.json`. Перевести интерфейс на казахский (государственное требование). | Государство РК требует госязык в публичных интерфейсах. |
| P2-7 | **WebSocket / SignalR для real-time** | Push-уведомления на дашборд (новая продажа), кассе (изменение цены), импортах (вместо polling). | UX + снижение нагрузки от polling. |
| P2-8 | **Аналитика на public-сайте** | Google Analytics или Yandex.Metrika, A/B тесты pricing'а, события signup-конверсии. | Маркетинг. |
| P2-9 | **Mobile-приложение (PWA или React Native)** | Просмотр остатков, продаж, KPI для владельца. | UX для владельцев. |
| P2-10 | **Распознавание чеков (OCR)** | Загрузка фото чека от поставщика → распознавание → автозаполнение Supply. | Уменьшение ручного ввода. |
| P2-11 | **Электронные счёт-фактуры (ЭСФ)** | Интеграция с ИС ЭСФ КГД РК (выпуск счетов-фактур для юрлиц). | Часть оптовых клиентов требует ЭСФ. |
| P2-12 | **Бонусные программы / скидочные карты** | Domain: `LoyaltyProgram`, `LoyaltyCard`. Списание/начисление в RetailSale. | Удержание клиентов. |
| P2-13 | **Промокоды / акции** | Domain: `Promotion`, правила (категория, период, % скидки). UI-настройка из админки. | Маркетинг для магазина. |
| P2-14 | **Telegram-бот для владельца** | Ежедневная сводка выручки, low-stock alerts. | UX для владельцев. |
| P2-15 | **Multi-storage для изображений** | Сейчас файлы лежат в `/app/uploads` (volume). Перевести на S3-совместимое хранилище (MinIO/Yandex.Cloud). | Масштабируемость, отказоустойчивость. |
---
## 3. Дорожная карта (рекомендованная последовательность)
### Спринт 1 — Стабилизация (2-3 недели)
Цель: безопасно выкатить текущий функционал в прод.
- P0-1 → P0-9 (все блокеры запуска)
- P1-20, P1-21 (юнит/интеграционные тесты на текущую логику)
- P1-18 (аудит мутаций tenant'а)
**Критерий готовности:** прод-стенд работает с HTTPS, rate-limit'ы установлены, бэкап автоматический, фискализация ОФД работает, права RolePermissions проверяются.
### Спринт 2 — Складской учёт (3-4 недели)
Цель: полноценное складское ядро ERP.
- P1-1 (Enter), P1-2 (Loss), P1-3 (Transfer), P1-4 (Inventory)
- P1-6 (CustomerReturn), P1-7 (SupplierReturn)
- P1-16 (Hangfire dashboard + scheduled cleanup)
**Критерий готовности:** магазин может вести полный складской учёт без обходных путей.
### Спринт 3 — Отчёты и аналитика (2 недели)
- P1-8 (Sales report), P1-9 (Stock on date), P1-10 (Profit), P1-11 (ABC)
- P1-19 (OpenAPI / Swagger)
**Критерий готовности:** владелец видит, как идёт бизнес, без выгрузки в Excel.
### Спринт 4 — POS (4-6 недель)
- P1-12 (POS Sync API), `food-market.shared` контракты
- P1-13 (POS WPF MVP)
- P1-17 (метрики Prometheus + Grafana dashboard)
**Критерий готовности:** касса работает оффлайн, синхронизируется с сервером, печатает фискальные чеки.
### Спринт 5 — Оптовые продажи + MoySklad full sync (2-3 недели)
- P1-5 (Demand)
- P1-14 (MoySklad Demand sync), P1-15 (webhook'и)
- P1-22 (email-шаблоны)
**Критерий готовности:** клиент, работающий с юрлицами через МойСклад, может полностью перейти на Food Market.
### Спринт 6+ — Интеграции и фичи (P2)
P2-1 Kaspi Pay → P2-3 маркетплейсы → P2-6 локализация → P2-11 ЭСФ → P2-12/13 лояльность/акции.
---
## 4. Технический долг (для рефакторинга)
Не блокирует функциональность, но затрудняет развитие.
| # | Что | Почему важно |
|---:|---|---|
| TD-1 | **CQRS через MediatR** — перенести бизнес-логику из контроллеров в Command/Query handlers. | Сейчас невозможно переиспользовать логику между API/POS/Hangfire. Контроллеры по 500 строк. |
| TD-2 | **FluentValidation** — заменить inline-валидацию в контроллерах на отдельные `Validator<T>`. | Сейчас валидация перемешана с бизнес-логикой, тестировать сложно. |
| TD-3 | **Mapster** — выделить mapping в отдельные `MapperConfig`. | Сейчас projection'ы инлайнятся в LINQ-запросы, переиспользования нет. |
| TD-4 | **Структурные log-fields в Serilog** — добавить `org_id`, `user_id`, `correlation_id` в log scope. | Сейчас в логах сложно найти конкретного пользователя/организацию. |
| TD-5 | **ImportJobRegistry в БД** — сейчас in-memory `ConcurrentDictionary`. При рестарте API теряется. Перевести на таблицу `ImportJobs`. | Жизненный цикл job'а >5 минут — рестарт обычное дело. |
| TD-6 | **Concurrency-токены на документах**`RowVersion` (xmin/timestamp) на Supply/RetailSale, чтобы исключить race condition при параллельной правке. | Сейчас два кассира могут испортить один чек. |
---
## 5. Сводка по оценке готовности
```
┌──────────────────────────────────┬──────────────┬─────────────┐
│ Категория │ Готовность │ Состояние │
├──────────────────────────────────┼──────────────┼─────────────┤
│ Авторизация и multi-tenancy │ 95% │ ✅ готово │
│ Каталог товаров │ 95% │ ✅ готово │
│ Документы (Supply, RetailSale) │ 100% │ ✅ готово │
│ Документы (Inventory/Loss/...) │ 0% │ ❌ нет │
│ Отчёты │ 5% │ ❌ нет │
│ POS │ 5% │ ❌ нет │
│ MoySklad │ 50% │ 🟡 частично │
│ Платежи и фискализация │ 0% │ ❌ нет │
│ Инфраструктура (CI/CD, Docker) │ 90% │ ✅ готово │
│ Безопасность (HTTPS, rate-limit) │ 30% │ 🟡 частично │
│ Observability (метрики, аудит) │ 20% │ 🟡 частично │
│ Тестирование │ 40% │ 🟡 частично │
└──────────────────────────────────┴──────────────┴─────────────┘
Общая готовность к продакшен-запуску: 60-65%
- Для MVP "магазин на одном POS-терминале": требуется ОФД + базовые складские документы.
- Для полноценного ERP: требуется выполнение P0+P1.
- Для конкуренции с МойСклад: требуется ещё и P2.
```

View file

@ -0,0 +1,630 @@
# ТЗ на тестирование Food Market
> Дата составления: 2026-05-22
> Автор: Claude Opus 4.7
> Документ парный к [TZ-доработка.md](./TZ-доработка.md). Описывает что и как проверять до и после релизов.
---
## 0. Принципы тестирования
### 0.1. Пирамида тестов
```
▲ E2E (Playwright, full-cycle) ~ 5%
▲▲▲ API integration (axios + Testcontainers) ~ 25%
▲▲▲▲▲ Unit-тесты бизнес-логики (xUnit) ~ 70%
```
Сейчас реальное соотношение **5% / 0% / 0%** — это нужно перевернуть.
### 0.2. Уровни тестирования
| Уровень | Когда запускается | Что проверяет |
|---|---|---|
| **Smoke** | Каждый push, ≤2 мин | Сервис стартует, /health отвечает 200, миграции применены, главные страницы открываются. |
| **Регрессия** | Каждый PR, ≤10 мин | Основные сценарии не сломаны: signup→login, создание продукта/приёмки/продажи, multi-tenant isolation. |
| **E2E full-cycle** | Каждый merge в main, ≤30 мин | Полный путь от создания организации до отчёта о продажах. |
| **Нагрузочные** | Перед мажорными релизами | 1000 одновременных пользователей, 10000 товаров, 50000 движений. |
| **Безопасность** | Перед релизом + регулярно | OWASP Top-10, рейт-лимит, multi-tenant утечки. |
### 0.3. Когда считать «работает корректно»
- **Бэкенд:** код возвращает ожидаемый HTTP-код, тело ответа валидно по схеме, побочные эффекты в БД соответствуют ожиданиям (StockMovement, Stock.Quantity).
- **Фронтенд:** интерфейс отображает корректные данные, формы валидируются (HTML5 + onBlur + submit), ошибки сервера показываются человекочитаемо.
- **Безопасность:** запрещённые операции возвращают 401/403/404 без утечки информации (не «такого пользователя нет», а «неверный логин/пароль»).
- **Мультитенант:** пользователь org A никогда не видит и не изменяет данные org B (ни через UI, ни через прямой API).
### 0.4. Что считать багом
- Любой 500 без log-entry с причиной.
- Отрицательный остаток после провёденной продажи (overselling без контроля).
- Несоответствие `Stock.Quantity` сумме `StockMovement.Quantity` по тому же (Product, Store).
- Возможность увидеть/изменить данные другой организации.
- Возможность залогиниться как заархивированный/удалённый сотрудник.
- Email-flow signup/reset не работает (письма не доходят).
---
## 1. Приоритезация по критичности модулей
| Приоритет | Модули | Что попадает |
|---|---|---|
| **P0 (smoke + регрессия в каждом PR)** | Auth, Supply, RetailSale, Stock, Multi-tenancy | Без этого не работает ничего. |
| **P1 (регрессия перед релизом)** | Catalog (Products, Groups, Counterparties), SuperAdmin Console, MoySklad import, Permissions | Без этого магазин не функционален. |
| **P2 (один раз перед мажорным релизом + после изменений)** | Reports, Email, Health checks, UI-валидация, локализация чисел/дат | Не блокирует, но влияет на UX. |
| **P3 (точечно по требованию)** | Public site (Astro), CI/CD-сценарии | Меняется реже. |
---
## 2. Сценарии тестирования по модулям
### 2.1. Аутентификация (P0)
#### 2.1.1. Регистрация новой организации (`POST /api/auth/signup`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: новая орга** | POST с уникальным email, паролем 8+, org name, валидным KZ-телефоном (+7 7XX...). | 201, в БД: новый Organization, User с ролью Admin, Employee, bootstrap-данные (Stores=1, Roles=4, Units, PriceTypes). |
| **Email уже занят** | Повторный POST с тем же email. | 400 «email уже занят» (без раскрытия деталей о существующей орге). |
| **Слабый пароль (<8 симв.)** | POST с password="abc". | 400, поле password в ошибках. |
| **Невалидный телефон** | POST с phone="+79161234567" (РФ). | 400 «введите корректный номер Казахстана». |
| **Без согласия с офертой** | UI: не отметить чекбокс. | Submit заблокирован, поле agree красное. |
| **Email с лишними пробелами** | " user@example.kz ". | Нормализация: trim, lowercase. 201. |
| **Bootstrap полнота** | После регистрации: GET `/api/catalog/stores` → 1 основной склад; GET `/api/organization/employee-roles` → 4 системных роли (Admin/Manager/Storekeeper/Cashier). | Соответствие. |
#### 2.1.2. Логин (`POST /connect/token`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: правильный пароль** | grant_type=password, valid creds. | 200, access_token (jwt), refresh_token, claims.org_id, claims.role. |
| **Неверный пароль** | Wrong password. | 400 OAuth error="invalid_grant", без раскрытия «такой email есть» vs «нет». |
| **Заблокированный пользователь** | User.IsActive=false. | 400, токен не выдан. |
| **Архивированная организация** | Organization.IsArchived=true. | 400, токен не выдан, claim org_id отсутствует или вход запрещён. |
| **SuperAdmin без org** | User с ролью SuperAdmin, OrganizationId=null. | 200, токен выдан, claim role="SuperAdmin". |
| **Refresh token flow** | grant_type=refresh_token с действующим refresh. | 200, новый access + refresh (sliding). |
| **Истёкший access токен** | Запрос с истёкшим токеном. | 401, фронт делает refresh автоматически. |
| **Истёкший refresh** | Подождать >30 дней. | 400 на refresh, форс-логаут на фронте. |
| **Rate limit (после P0-3)** | 6+ login-попыток за минуту с одного IP. | 429 Too Many Requests. |
#### 2.1.3. Forgot/Reset password
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: запрос восстановления** | POST `/api/auth/forgot-password` с email существующего. | 200 (всегда), на email приходит ссылка с токеном (1 час). |
| **Несуществующий email** | POST с unknown@example. | 200 (anti-enumeration), без email. |
| **Сброс по токену** | POST `/api/auth/reset-password` с email+token+newPassword. | 200, все refresh_token'ы revoke'нуты. |
| **Просроченный токен (>1 ч)** | POST с истёкшим токеном. | 400 «ссылка устарела». |
| **Rate limit forgot** | 4+ запроса за час с одного IP. | 429 «слишком много попыток». |
---
### 2.2. Multi-tenancy и изоляция данных (P0, КРИТИЧНО)
#### 2.2.1. Изоляция через UI
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Видимость списков** | Залогиниться в org A, создать товар «Хлеб». Залогиниться в org B. | `/catalog/products` org B не содержит «Хлеб». |
| **Переключение организаций** | Один email в двух организациях (если возможно — сейчас нет). | (Не поддерживается — каждый User принадлежит одной org). |
#### 2.2.2. Изоляция через прямой API
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **GET по GUID чужой орги** | Залогиниться в org A, узнать `productId` org B (например, через БД). GET `/api/catalog/products/{productIdOrgB}`. | 404 (не 200). Query filter скрывает. |
| **PUT по GUID чужой орги** | PUT `/api/catalog/products/{productIdOrgB}` с теми же данными. | 404 (не 200, не 200 с записью в чужую). |
| **DELETE по GUID чужой орги** | DELETE того же. | 404. |
| **POST с FK на чужую сущность** | POST Supply в org A с supplierId, принадлежащим org B. | 400 «contact not found» (FK проверка через query filter). |
| **Подделка org_id в JWT** | Сгенерировать токен с org_id чужой орги (без подписи). | 401 (подпись не валидна). |
#### 2.2.3. SuperAdmin override
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **SuperAdmin без override** | GET `/api/super-admin/organizations`. | Видит все организации. |
| **SuperAdmin с override (read)** | GET `/api/catalog/products` с заголовком `X-Org-Override: <orgId>`. | Видит товары org X. |
| **SuperAdmin с override (write)** | PUT `/api/catalog/products/{id}` с `X-Org-Override: <orgId>` без `X-Org-Override-Reason`. | 403 «Read-only mode...». |
| **SuperAdmin с override + reason** | PUT с обоими заголовками (`X-Org-Override-Reason: "Customer support ticket #123, исправляем дубль штрихкода"`). | 200, запись в SuperAdminAuditLog. |
| **Reason слишком короткий** | reason="ok". | 403. |
| **Обычный Admin с X-Org-Override** | Admin org A пытается подделать заголовок и выйти в org B. | 403 «only SuperAdmin can override». |
#### 2.2.4. Глобальные справочники
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Country / Currency видны всем** | Login org A: GET /api/catalog/countries → видит. Login org B: GET → тот же список. | Идентичные списки. |
| **System ProductGroup видна всем** | SuperAdmin создаёт ProductGroup OrganizationId=null. Login любая org: видит. | Видна, но не редактируется обычным Admin. |
| **System UnitOfMeasure (ОКЕИ)** | SuperAdmin: GET /api/super-admin/units-of-measure → видит все global. Admin org A: GET /api/catalog/units-of-measure → видит только enabled через junction. | Корректная фильтрация. |
---
### 2.3. Каталог (P1)
#### 2.3.1. Товары (Products)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание минимального** | POST `/api/catalog/products` с name + unit. | 201, автогенерация артикула (числовой), штрихкода нет. |
| **Создание с штрихкодом EAN13** | POST с barcode value="4607034630092", type=Ean13. | 201, валидация контрольной цифры. |
| **EAN13 с невалидной контрольной цифрой** | barcode="4607034630099". | 400 «невалидный EAN13». |
| **Дубль штрихкода в одной орге** | POST два товара с одинаковым штрихкодом. | 409 на втором. |
| **Дубль штрихкода в разных оргах** | Org A: barcode X. Org B: тот же barcode. | 201 для обеих (per-tenant unique). |
| **Цена по типам** | POST с prices: розничная=1000 KZT, оптовая=800 KZT. | Записи в ProductPrice. |
| **Обязательная розничная цена** | POST без розничной. | 400 «требуется розничная цена» (если PriceType.IsRequired=true). |
| **Артикул вручную** | POST с article="ABC-001". | 201, без автогенерации. |
| **Дубль артикула** | Два товара с article="ABC-001". | 409. |
| **Поиск по barcode** | GET `/api/catalog/products/by-barcode/4607034630092`. | Возвращает товар. |
| **Quick search** | GET `/api/catalog/products/quick-search?q=хле`. | Ранжирование: exact barcode → article → name prefix → name contains. |
| **Фильтр по группе** | GET `?groupId=X`. | Только товары группы X. |
| **Фильтр по упаковке** | `?packaging=Weight`. | Только весовые. |
| **Удаление товара с приёмками** | DELETE товара, на который есть SupplyLine. | 409 «нельзя удалить, есть документы». |
| **Удаление без документов** | DELETE свежесозданного. | 204. |
| **Изображения товара** | POST `/api/catalog/products/{id}/images` с jpg <10MB. | 201, файл в /uploads, ImageUrl обновлён. |
| **Загрузка > 10MB** | POST с 15MB файлом. | 400. |
| **Установка main image** | POST `/images/{imageId}/main`. | Product.ImageUrl ← путь, IsMain переброшен. |
#### 2.3.2. Группы товаров (ProductGroups)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание корневой** | POST с name="Хлебобулочные", parentId=null. | 201, Path="Хлебобулочные". |
| **Создание дочерней** | POST с parentId="<root>", name="Хлеб". | 201, Path="Хлебобулочные/Хлеб". |
| **Циклическая ссылка** | PUT группы с parentId=своим id или потомка. | 400 «цикл». |
| **Удаление с подгруппами** | DELETE родителя у которого есть дети. | 409. |
| **Удаление с товарами** | DELETE группы с привязанными товарами. | 409. |
| **Системная группа SuperAdmin** | SuperAdmin POST с OrganizationId=null. Login org → видна, но Edit/Delete недоступны. | Корректное поведение. |
| **Markup percent** | Группа с MarkupPercent=20%. Cost=1000 → RecalcRetail = 1200. | Round up до целых (по AllowFractionalPrices). |
#### 2.3.3. PriceTypes
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Только один IsRetail** | Создать два PriceType с IsRetail=true. | 409 на втором. |
| **IsSystem нельзя удалить** | DELETE PriceType с IsSystem=true. | 409. |
| **Переименование системного** | PUT name. | 200 (можно). |
| **Toggle IsRequired системного** | PUT IsRequired=false для системного. | 409 (зафиксирован). |
#### 2.3.4. UnitsOfMeasure
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Enable global unit** | POST `/api/catalog/units-of-measure/{id}/enable`. | 204, junction record создан. |
| **Disable enabled unit с ссылками** | DELETE enable у unit'а, который используется товаром. | 409 «нельзя — товары используют». |
| **SuperAdmin: создать global unit** | POST `/api/super-admin/units-of-measure` с code="МЛ". | 201. |
| **SuperAdmin: удалить global unit с ссылками** | DELETE того же если ссылается товар. | 409 со списком орг. |
#### 2.3.5. Counterparties
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Юрлицо с БИН** | POST с type=LegalEntity, bin="123456789012". | 201. |
| **БИН не 12 цифр** | bin="1234". | 400. |
| **Физлицо с ИИН** | type=Individual, iin="850101300123". | 201. |
| **Невалидный KZ телефон** | phone="+79161234567". | 400. |
---
### 2.4. Документы: Приёмки (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST Supply с supplier, store, currency, 2 lines. | 201, status=Draft, Total=sum(qty*price), Stock не изменился. |
| **Posting** | POST `/posts/{id}/post`. | Stock.Quantity += sum(qty). StockMovement +N записей с type=Supply. Cost пересчитан (weighted avg). |
| **Cost weighted average** | До: Stock.Cost=100, qty=10. Приёмка: qty=10, price=120. После: Cost = (10*100+10*120)/20 = 110. | Соответствие. |
| **ReferencePrice при первой приёмке** | Product без приёмок. После Supply.Post: Product.ReferencePrice = UnitPrice. | Соответствие. |
| **Автонаценка розничной** | ProductGroup.MarkupPercent=30, товар без override розницы, Cost=100. После Post: ProductPrice (IsRetail) = 130. | Соответствие. |
| **Unpost** | POST `/{id}/unpost` на провёденной. | StockMovement удалены/инвертированы, Stock.Quantity вернулся, status=Draft. |
| **Edit проведённой** | PUT провёденной (status=Posted). | 409 «нельзя редактировать проведённую». |
| **Delete Draft** | DELETE Draft без посту. | 204. |
| **Delete проведённой** | DELETE Posted. | 409. |
| **Posting → отрицательный остаток после unpost** | Post Supply (+10), затем Sale (-15), затем Unpost Supply. | 409 «нельзя расковать, остаток уйдёт в минус» (по дизайну). |
| **FK на удалённого поставщика** | Supplier удалён (если бы это было возможно), Supply ссылается. | Корректная обработка (запрет удаления поставщика или nullable). |
| **Пустые lines** | POST с lines=[]. | 400. |
| **Quantity ≤ 0** | line.qty=0 или -5. | 400. |
| **Параллельный Post одного Supply** | Два запроса /post одновременно. | Один 200, второй 409 (concurrency-конфликт после P1 RowVersion). |
---
### 2.5. Документы: Розничные продажи (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST RetailSale с retailPoint, lines, payments. | 201, status=Draft. |
| **Posting с достаточным остатком** | Stock=10. POST с qty=5, /post. | Stock=5, StockMovement type=RetailSale qty=-5. |
| **Posting с overselling** | Stock=3. POST с qty=5, /post. | 409 «недостаточно товара» (по фиксу из git history). |
| **PaidCash + PaidCard ≠ Total** | Total=1000, paidCash=300, paidCard=500. | 400 «суммы не сходятся». |
| **Расчёт суммы со скидкой** | Line qty=2, price=500, discount=100. Subtotal=1000, DiscountTotal=100, Total=900. | Соответствие. |
| **VAT snapshot в строке** | На момент продажи Product.VatPercent=12, после Sale.Post страну поменяли → VAT=0. Старая RetailSaleLine.VatPercent = 12. | Снимок цены/НДС сохраняется. |
| **Cashier из другого RetailPoint** | Cashier привязан к RP1. POST RetailSale с retailPointId=RP2. | 403 (после реализации Permission). |
| **Unpost продажи** | POST /unpost. | Stock возвращается, StockMovement инвертирован. |
| **Stats эндпоинт** | GET /stats?days=30. | Daily series 30 дней, revenueToday, transactionsToday, avgTicketThisMonth корректны. |
| **Возврат по чеку (после P1-6)** | POST CustomerReturn ссылается на RetailSale. | Stock возвращается, новый StockMovement type=CustomerReturn. |
---
### 2.6. Остатки и движения (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Целостность Stock vs Movement** | После N операций: для любых (ProductId, StoreId) `Stock.Quantity == SUM(StockMovement.Quantity)`. | Соответствие (инвариант). |
| **Reserved** | (Если реализуется в P1) Сейчас Reserved=0 везде. | После резервирования через Demand: Reserved += qty. |
| **Доступно (Available)** | Available = Quantity - Reserved. | Корректно. |
| **Фильтр includeZero=false** | Stock.Quantity=0. GET /stock?includeZero=false. | Не включается. |
| **Movements: пагинация** | 1000 движений. GET ?page=1&pageSize=50. | total=1000, items=50. |
| **Сортировка по occurredAt desc** | GET ?sort=-occurredAt. | Свежие сверху. |
| **Фильтр по storeId** | Два склада, по 10 movements в каждом. GET ?storeId=X. | Только 10 из X. |
---
### 2.7. Сотрудники и роли (P1)
#### 2.7.1. CRUD сотрудников
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание без учётки** | POST с createAccount=false. | 201, Employee.UserId=null. |
| **Создание с учёткой** | POST с createAccount=true, email. | 201, User создан с temp password, password возвращён в response (один раз). |
| **Email обязателен при createAccount=true** | POST с email="" и createAccount=true. | 400. |
| **Дубль email** | Два сотрудника, одинаковый email при createAccount. | 409. |
| **Soft-delete (увольнение)** | DELETE employeeId. | IsActive=false, FiredAt=now, IsDeleted=false. User блокируется. |
| **Полное удаление (после увольнения)** | DELETE дважды (после Fired). | IsDeleted=true, DeletedAt=now. |
| **Архивный сотрудник в документах** | После soft-delete: старые Supply показывают «Иванов И. (удалён)». | Подпись сохраняется. |
| **Защита OwnerUser** | DELETE главного администратора (Organization.AccountOwnerUserId == Employee.UserId). | 409 «только SuperAdmin». |
| **Защита самого себя** | Залогинен Иванов, пытается DELETE сам себя. | 409 «нельзя удалить себя». |
#### 2.7.2. Роли и Permission-based authz (после P0-5)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Системные роли созданы** | После signup: 4-6 системных ролей (Admin, Manager, Storekeeper, Cashier, ...). | IsSystem=true, не удаляются. |
| **Кастомная роль** | POST роль "Менеджер по продажам" с permissions {productsView:true, suppliesView:true, all_else:false}. | 201. |
| **Permission DENY** | Employee с ролью "Менеджер по продажам" → POST Product. | 403 «нет права ProductsEdit». |
| **Permission ALLOW** | Тот же → GET /api/catalog/products. | 200. |
| **Изменение прав роли** | PUT permissions роли. | Применяется ко всем сотрудникам этой роли (без revoke токенов). |
| **Удаление роли с сотрудниками** | DELETE используемой роли. | 409. |
| **Cashier → RetailPoint** | Cashier с EmployeeRetailPointAssignment (RP1). POST RetailSale (RP1). | 200. С RP2: 403. |
---
### 2.8. SuperAdmin Console (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание организации** | POST /api/super-admin/organizations с org+admin данными. | 201, temp password возвращён, organization в БД. |
| **Аудит создания** | SELECT FROM super_admin_audit_log WHERE action_type='OrganizationCreated'. | Запись с reason, before/after JSON. |
| **Архивирование** | POST /{id}/archive с confirmationName=правильное имя org. | IsArchived=true, ArchivedAt=now. Логины этой org перестают работать. |
| **Архивирование с неверным confirmationName** | confirmationName="wrong". | 400. |
| **Восстановление** | POST /{id}/restore. | IsArchived=false. |
| **Hard delete после retention period** | Через SystemSettings.ArchiveRetentionDays=0, DELETE архивной. | 204, организация физически удалена (CASCADE). |
| **Hard delete до retention** | DELETE архивной до истечения. | 409 «до удаления ещё N дней». |
| **Смена владельца** | POST /change-owner с newOwnerUserId, reason. | Organization.AccountOwnerUserId обновлён, запись в audit log. |
| **Reason требуется** | POST /change-owner без reason. | 400. |
| **Reason < 10 символов** | POST с reason="ok". | 400. |
| **Audit log фильтр** | GET /audit-log?orgId=X&actionType=Y. | Корректная фильтрация. |
| **Audit log CSV экспорт** | Через UI кнопка «Экспорт». | CSV-файл скачивается. |
---
### 2.9. SuperAdmin Platform Settings — SMTP (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение SMTP** | PUT с host, port, ssl, username, newSmtpPassword="pass123". | 200, hasSmtpPassword=true. Пароль в БД зашифрован. |
| **Получение настроек** | GET. | Все поля кроме password. password не возвращается. |
| **Очистка пароля** | PUT с newSmtpPassword="__clear__". | hasSmtpPassword=false. |
| **Test send** | POST /test-send. | Письмо приходит на адрес супер-админа. |
| **Test send без настроек** | POST /test-send когда SMTP не настроен. | 400 «SMTP не настроен». |
| **Forgot password после настройки** | Юзер делает forgot-password → письмо со ссылкой приходит. | Соответствие. |
---
### 2.10. MoySklad интеграция (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение токена** | PUT /api/admin/moysklad/settings с token. | Token сохранён в Organization, masked при GET. |
| **Test connection** | POST /test с валидным токеном. | 200, возвращает {organization, inn}. |
| **Test с невалидным токеном** | POST /test с "abc". | 400 или 401 с понятным сообщением. |
| **Import counterparties** | POST /import/counterparties. | 202, jobId возвращён. |
| **Job progress polling** | GET /api/admin/jobs/{id} раз в 1.5 сек. | Status: InProgress → Succeeded. Stage обновляется. |
| **Импортированные контрагенты** | GET /api/catalog/counterparties после import. | N новых контрагентов с правильными BIN, телефонами. |
| **OverwriteExisting=true** | Повторный import с overwrite. | Обновляются по name (case-insensitive), не дубли. |
| **Импорт товаров** | POST /import/products. | Товары + группы + штрихкоды + остатки импортированы. |
| **Архивные товары** | МойСклад имеет archived товары. | Импортируются в Product.IsArchived=true. |
| **Прерывание job (после P1)** | Рестарт API во время импорта. | Job маркируется как Failed, можно перезапустить. |
---
### 2.11. Складские документы (P1, после реализации)
#### 2.11.1. Оприходование (Enter)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание/Post** | POST Enter с lines, /post. | Stock += qty, StockMovement type=Enter. |
| **Без поставщика** | POST без supplierId. | 201 (Enter не требует supplier). |
#### 2.11.2. Списание (Loss)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Списание брака** | POST Loss с reason="брак", lines с qty. | Stock -= qty, StockMovement type=WriteOff. |
| **Списание сверх остатка** | Stock=3, Loss qty=5. | 409 «недостаточно». |
#### 2.11.3. Перемещение (Transfer)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Между складами** | POST Transfer (FromStore=A, ToStore=B), lines. /post. | Stock[A] -= qty, Stock[B] += qty. Два StockMovement (TransferOut + TransferIn). |
| **Атомарность** | Если ToStore Stock записать не удалось (например, БД упала между). | Транзакция откатывается, Stock[A] не изменён. |
#### 2.11.4. Инвентаризация (Inventory)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание с импортом текущих** | POST Inventory storeId=X. | InventoryLine на каждый товар склада X с bookQty=Stock.Quantity, actualQty=null. |
| **Заполнение фактических** | PUT с actualQty по каждой строке. | Diff = actual - book. |
| **Post** | POST /post. | StockMovement type=InventoryAdjustment с qty=diff. Stock актуализирован. |
| **CSV-импорт фактических** | UI: загрузка CSV (sku, qty). | Заполнение строк. |
---
### 2.12. Отчёты (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Отчёт по продажам** | GET /api/reports/sales?from=...&to=...&groupBy=day. | Series выручки по дням. |
| **Отчёт по продажам с фильтром по кассиру** | ?cashierId=X. | Только продажи этого кассира. |
| **Отчёт «остатки на дату»** | GET /stock?date=2026-04-01. | Stock восстановлен через `SUM(Movement WHERE occurredAt <= date)`. |
| **Отчёт «прибыль»** | GET /profit?from=...&to=... | Выручка - себестоимость (использует Cost snapshot из RetailSaleLine). |
| **ABC-анализ** | GET /abc?metric=revenue&period=last_quarter. | Топ-20% товаров (группа A), следующие 30% (B), остаток (C). |
| **Экспорт CSV/XLSX** | UI: кнопка «Экспорт». | Скачивается файл с теми же данными. |
| **Большой объём** | 100k продаж в периоде. | <5 секунд ответ (с агрегацией на стороне БД). |
---
### 2.13. POS Sync API (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Initial sync** | POS первый раз: GET /api/pos/sync?since=0. | Все товары, цены, остатки, контрагенты. |
| **Incremental sync** | GET /api/pos/sync?since=<lastSyncTimestamp>. | Только изменения после timestamp. |
| **Batch upload sales** | POST /api/pos/sales [{idempotencyKey, ...}, ...]. | Все продажи провёдены, idempotency повторных вызовов. |
| **Idempotency** | POST с тем же idempotencyKey дважды. | Второй вызов возвращает оригинальный результат, без дубля. |
| **Offline → online** | POS работает offline 1 час, накопил 20 продаж. После online: upload. | Все 20 синхронизированы корректно. |
| **Conflict: товар удалён** | POS отправил продажу товара, который SuperAdmin удалил. | 409, POS откатывает локально или маркирует как ошибку. |
---
### 2.14. Web-админка UI/UX (P2)
| Сценарий | Что проверить |
|---|---|
| **Темная тема** | Все 35 страниц — переключение dark mode через кнопку. Без артефактов, контраст AAA. |
| **Адаптив** | Каждая страница на мобильном (375px), планшете (768px), десктопе (1280px). |
| **onBlur валидация форм** | После только что внедрённого ФЛК (см. git ff44afc): SignupForm, LoginPage, ResetPasswordPage, ForgotPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage, PriceTypesPage, EmployeeRolesPage, RetailPointsPage, OrganizationSettingsPage, SuperAdminOrgCreatePage. Каждое поле показывает ошибку при потере фокуса. |
| **onChange сбрасывает ошибку** | После показа ошибки, начать вводить — ошибка должна убираться. |
| **Обработка 401 в axios** | Истёкший токен → автоматический refresh → повторный запрос. |
| **Обработка 403** | Понятная страница «нет прав». |
| **Обработка 500** | Не белый экран, а toast/alert. |
| **Loading states** | Спиннеры/skeleton'ы на всех таблицах при загрузке. |
| **Empty state** | Пустые списки показывают «Нет данных», а не пустую таблицу. |
| **Pagination** | Корректные счётчики, переходы. На последней странице нет «next». |
| **Sticky header** | Заголовок таблицы остаётся при скролле длинных списков. |
| **Локализация** | Все строки на русском. Числа в `toLocaleString('ru-KZ')` (1 234,56). Даты в `dd.MM.yyyy`. |
---
### 2.15. Public site (Astro) (P3)
| Сценарий | Что проверить |
|---|---|
| **Маршруты** | /, /pricing, /features, /pos, /import, /integrations, /about, /contacts, /signup, /blog/[slug], /kb/[slug], /legal/[slug] — все открываются. |
| **SignupForm на /signup** | Заполнить корректно → редирект на admin.food-market.kz с токенами. |
| **Tariff builder на /pricing** | Изменение количества касс/складов → перерасчёт стоимости. |
| **SEO** | Каждая страница имеет title, description, canonical, og-image. |
| **sitemap.xml** | GET /sitemap.xml → валидный XML со всеми статичными страницами. |
| **robots.txt** | GET /robots.txt → ожидаемое содержимое. |
| **Lighthouse** | Performance 90+, Accessibility 95+, Best Practices 95+, SEO 100. |
---
### 2.16. Infrastructure / DevOps (P2)
| Сценарий | Что проверить |
|---|---|
| **/health** | 200 OK, JSON {status, time}. |
| **/health/ready (после P0-4)** | 200 если БД и миграции OK. 503 если БД упала. |
| **CI: build на push** | Зелёный pipeline на каждый push в main. |
| **CI: deploy on push api** | После push в `src/food-market.api/*` → docker-api workflow → деплой на stage → smoke /health → 200. |
| **Backup script** | Запуск `deploy/backup.sh local`. Файл *.sql.gz создан, размер > 0. |
| **Backup restore** | Восстановить из бэкапа в чистую БД, поднять API, проверить логин. |
| **Postgres healthcheck в compose** | docker-compose ps → postgres healthy. |
| **Persistent volumes** | После `docker compose down && up` данные сохраняются. |
| **Persistent OpenIddict ключ** | После рестарта API: refresh_token продолжает работать. |
| **Логи Serilog** | tail -f /app/logs/food-market-*.log — структурированные строки. |
| **Ротация логов** | После 14 дней старые логи удаляются. |
| **Локальный docker registry** | curl 127.0.0.1:5001/v2/_catalog → список образов. |
---
### 2.17. Безопасность (P0+P1)
| Сценарий | Что проверить |
|---|---|
| **HTTPS-only (после P0-2)** | HTTP → 301 redirect на HTTPS. HSTS-header установлен. |
| **JWT signature tampering** | Изменить body токена, не подписать. | 401. |
| **JWT expired** | Использовать токен старше 1 часа. | 401, фронт делает refresh. |
| **SQL injection** | Поиск со значением `'; DROP TABLE products;--`. | Безопасно (EF параметризует). |
| **XSS в формах** | Создать товар с name=`<script>alert(1)</script>`. | На UI выводится как текст, не как HTML. |
| **CSRF на /connect/token** | Cookie-based auth не используется, CSRF неактуален. | OK. |
| **CORS** | Запрос из http://evil.com → блокирован. |
| **Path traversal в /uploads** | GET /uploads/../../etc/passwd. | 404. |
| **Unauthenticated endpoints** | Список AllowAnonymous: /health, /api/auth/signup, /api/auth/forgot-password, /connect/token, /uploads/*. | Других — нет. |
| **Rate limit login (после P0-3)** | 10 попыток за минуту → 429. |
| **Перебор email на signup** | 100 signup-запросов с разными email — должен 429 после 20. |
| **Утечка через 404 vs 403** | GET /api/catalog/products/<существующий-чужого-tenant> возвращает 404, не 403 (чтобы не подтверждать существование). |
---
### 2.18. Производительность (P2)
| Сценарий | Цель |
|---|---|
| **GET /api/catalog/products при 10k товаров** | < 500 мс с пагинацией pageSize=50. |
| **POST Supply.Post с 100 lines** | < 2 сек. |
| **GET /api/inventory/movements при 100k записей** | < 1 сек с пагинацией. |
| **Quick search в каталоге** | < 200 мс при 10k товаров. |
| **Dashboard `/stats`** | < 500 мс при 100k продаж. |
| **N+1 запросы** | EF Profiler / Serilog log: на GET /products нет N+1 для prices/barcodes. |
| **Concurrent signup** | 50 параллельных signup → все 201, БД консистентна. |
---
## 3. Регрессионный чек-лист (перед каждым релизом)
```
[ ] Все миграции применяются на чистую БД
[ ] Smoke: GET /health → 200
[ ] Smoke: signup → новая org → login → /api/me → roles[admin]
[ ] Регрессия: создать товар → создать приёмку → /post → Stock обновился
[ ] Регрессия: создать продажу → /post → Stock уменьшился, чек создан
[ ] Регрессия: запрос с другого org_id → 404
[ ] Регрессия: SuperAdmin без override видит все организации
[ ] Регрессия: SuperAdmin с override read-only — мутация 403
[ ] Регрессия: SuperAdmin с reason — мутация 200, audit log запись
[ ] MoySklad: test connection → 200
[ ] Email: forgot password → письмо приходит
[ ] UI: все 35 страниц открываются, onBlur валидация работает
[ ] CI: docker-api workflow зелёный, smoke на /health → 200
[ ] Backup за последние сутки существует, размер > предыдущего > 50%
[ ] Логи за последний час: нет 5xx без log-entry
[ ] Метрики (после P1-17): error_rate < 1%, p95 latency < 1s
```
---
## 4. Стратегия покрытия тестами
### 4.1. Unit-тесты (xUnit, цель 70% coverage критичной логики)
```
food-market.tests.unit/
├── Domain/
│ ├── ProductTests.cs — валидация конструктора, computed properties
│ ├── StockMovementTests.cs — invariants
│ └── RolePermissionsTests.cs — расчёт прав
├── Application/ — после миграции на MediatR
│ ├── SuppliesPostHandlerTests.cs — Cost weighted average, авто-наценка
│ ├── RetailSalePostHandlerTests.cs — Stock update, overselling check
│ └── ImportJobsTests.cs — состояния job'а
└── Infrastructure/
├── TenantFilterTests.cs — query filter включается/исключается правильно
├── StockServiceTests.cs — атомарность ApplyMovement
└── MoySkladClientTests.cs — pagination, error handling (с mock HttpMessageHandler)
```
### 4.2. Integration-тесты (Testcontainers.PostgreSql + WebApplicationFactory)
```
food-market.tests.integration/
├── Auth/
│ ├── SignupFlowTests.cs — POST /signup → bootstrap data
│ └── LoginFlowTests.cs — token + refresh + revoke
├── Catalog/
│ ├── ProductsCrudTests.cs
│ └── ProductGroupsHierarchyTests.cs
├── Documents/
│ ├── SupplyPostUnpostTests.cs — Stock consistency
│ └── RetailSalePostTests.cs — overselling 409
├── Tenancy/
│ ├── MultiTenantIsolationTests.cs — org A не видит org B
│ └── SuperAdminOverrideTests.cs — read-only / edit mode
└── Authorization/
└── PermissionBasedAuthzTests.cs — после P0-5
```
### 4.3. E2E (расширить существующий full-cycle)
```
tests/e2e/scenarios/
├── full-cycle.yml — текущий: signup → import → supply → sale → report
├── multi-tenant-isolation.yml — два юзера, два org, попытки кросс-доступа
├── superadmin-flow.yml — создание org, архив, реор, edit-mode
├── permission-checks.yml — кастомные роли, разрешения/отказы
└── moysklad-sync.yml — конец-в-конец импорт + проверки в БД
```
---
## 5. Инструменты тестирования
| Инструмент | Назначение | Готовность |
|---|---|---|
| **xUnit + FluentAssertions** | Backend unit-тесты | ❌ нет (нужно завести проект) |
| **Testcontainers.PostgreSql** | Integration-тесты с реальной БД в Docker | ❌ нет |
| **WebApplicationFactory<Program>** | In-memory test server | ❌ нет |
| **Playwright** | E2E браузерные тесты | ✅ есть (через `tests/e2e/run.sh full-cycle`) |
| **axios + pg (TS)** | E2E API + DB-проверки | ✅ есть (`tests/e2e/lib/`) |
| **Bombardier / k6** | Нагрузочное тестирование | ❌ нет |
| **OWASP ZAP** | Сканер уязвимостей | ❌ нет (рекомендуется использовать перед prod) |
| **Lighthouse CI** | Public site performance | ❌ нет |
| **Codecov / Coverlet** | Coverage report | ❌ нет |
---
## 6. Метрики качества тестирования
```
Цели после внедрения P1-20, P1-21:
Unit-тесты бизнес-логики: ≥ 70% (сейчас 0%)
Integration-тесты API: ≥ 60% (сейчас 0%)
E2E сценариев: ≥ 5 (сейчас 1)
Время прогона полного CI: ≤ 15 мин
Время smoke в PR: ≤ 2 мин
Регрессионный SLA после релиза:
Severity 1 (блокирующие): обнаружение → фикс ≤ 2 часа
Severity 2 (важные): обнаружение → фикс ≤ 1 рабочий день
Severity 3 (косметика): в плановый спринт
```
---
## 7. Финальный чек-лист «готовности к продакшен»
Прежде чем сказать «можно запускать прод»:
```
БЕЗОПАСНОСТЬ
[ ] HTTPS forced, HSTS установлен
[ ] OpenIddict prod-сертификаты
[ ] Rate limiting на login/signup
[ ] Permission-based authorization работает
[ ] Multi-tenant изоляция проверена (см. п. 2.2)
[ ] OWASP Top-10 сканер пройден
ФУНКЦИОНАЛ
[ ] Складские документы (Enter/Loss/Transfer/Inventory) реализованы
[ ] Отчёты (Sales/Stock/Profit/ABC) реализованы
[ ] ОФД фискализация чеков работает
[ ] Email-нотификации настроены и тестированы
ИНФРАСТРУКТУРА
[ ] Backup автоматический (cron/timer)
[ ] Restore-сценарий отрепетирован
[ ] Health checks детальные (ready/live)
[ ] Метрики Prometheus + Grafana dashboard
[ ] Алерты на error_rate, latency p95
ПРОЦЕССЫ
[ ] CI зелёный на main
[ ] Coverage > 70% unit, > 60% integration
[ ] E2E full-cycle зелёный
[ ] Regression checklist пройден
[ ] Release notes написаны
[ ] Документация .env.example актуальна
[ ] Rollback plan описан
```