ТЗ на тестирование Food Market
Дата составления: 2026-05-22
Автор: Claude Opus 4.7
Документ парный к 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="", 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 = (10100+10120)/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 токена, не подписать. |
| JWT expired |
Использовать токен старше 1 часа. |
| SQL injection |
Поиск со значением '; DROP TABLE products;--. |
| XSS в формах |
Создать товар с name=<script>alert(1)</script>. |
| CSRF на /connect/token |
Cookie-based auth не используется, CSRF неактуален. |
| CORS |
Запрос из http://evil.com → блокирован. |
| Path traversal в /uploads |
GET /uploads/../../etc/passwd. |
| 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 описан