Два документа после полного обхода кодовой базы: - 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>
631 lines
46 KiB
Markdown
631 lines
46 KiB
Markdown
# ТЗ на тестирование 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, архив, реcтор, 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 описан
|
||
```
|