# ТЗ на тестирование 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: `. | Видит товары org X. | | **SuperAdmin с override (write)** | PUT `/api/catalog/products/{id}` с `X-Org-Override: ` без `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="", 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=. | Только изменения после 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=``. | На 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** | 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 описан ```