# ТЗ на тестирование 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 описан
```
