feat(architecture): composition root, async boundaries, safe snapshot import, and negative audit probes

This commit is contained in:
ochenstarik 2026-09-11 15:39:45 +07:00
parent d1ccc1c197
commit 257502db4a
12 changed files with 1462 additions and 99 deletions

View file

@ -2,7 +2,7 @@
Документ фиксирует результаты повторного аудита кодовой базы `krona-front` (ветка `codex/frontend-core-integrity`, базовый коммит `0c479f5` от `codex/frontend-remediation`) по 25 замечаниям исходного аудита Astra 6 (который проводился по старому коммиту `0e4c0f7`). Документ фиксирует результаты повторного аудита кодовой базы `krona-front` (ветка `codex/frontend-core-integrity`, базовый коммит `0c479f5` от `codex/frontend-remediation`) по 25 замечаниям исходного аудита Astra 6 (который проводился по старому коммиту `0e4c0f7`).
Каждый пункт перепроверен по фактическому коду и тестам. Каждый пункт перепроверен по фактическому коду и подтвержден автоматическими тестами.
--- ---
@ -17,28 +17,27 @@
| **F05: Жизненный цикл отгрузки, доставки и возврата** | Исправлено | Нет | **Да** | Нет | Нет | Предусловие отгрузки: розница требует 100% предоплату (`paid` / `overpaid`), для b2b разрешена постоплата. При отгрузке активные резервы исполняются (`fulfilled`), физический остаток `onHand` уменьшается. Возврат разрешён только по отгруженным/доставленным заказам, количество строго валидируется (`returnQty <= fulfilledQty - alreadyReturnedQty`), физический остаток восстанавливается, расход фиксируется в финансах. Коммит `a29d683`. | Закрыто в коммите `a29d683`. | `src/services/__tests__/orderStateMachine.test.ts` (5/5 passed) | | **F05: Жизненный цикл отгрузки, доставки и возврата** | Исправлено | Нет | **Да** | Нет | Нет | Предусловие отгрузки: розница требует 100% предоплату (`paid` / `overpaid`), для b2b разрешена постоплата. При отгрузке активные резервы исполняются (`fulfilled`), физический остаток `onHand` уменьшается. Возврат разрешён только по отгруженным/доставленным заказам, количество строго валидируется (`returnQty <= fulfilledQty - alreadyReturnedQty`), физический остаток восстанавливается, расход фиксируется в финансах. Коммит `a29d683`. | Закрыто в коммите `a29d683`. | `src/services/__tests__/orderStateMachine.test.ts` (5/5 passed) |
| **F06: Складские инварианты и разделение Ledger** | Исправлено | Нет | **Да** | Нет | Нет | Исключена маскировка отрицательных остатков через `Math.max(0, ...)`. Разделены физический Stock Ledger (`movements`) и Reservation Ledger (`reservations`). Строго соблюдается инвариант `available = onHand - activeReserved >= 0`. Коммит `8910d5d`. | Закрыто в коммите `8910d5d`. | `src/services/__tests__/orderReservationIntegrity.test.ts` (5/5 passed) | | **F06: Складские инварианты и разделение Ledger** | Исправлено | Нет | **Да** | Нет | Нет | Исключена маскировка отрицательных остатков через `Math.max(0, ...)`. Разделены физический Stock Ledger (`movements`) и Reservation Ledger (`reservations`). Строго соблюдается инвариант `available = onHand - activeReserved >= 0`. Коммит `8910d5d`. | Закрыто в коммите `8910d5d`. | `src/services/__tests__/orderReservationIntegrity.test.ts` (5/5 passed) |
| **F07: Финансовые инварианты и разделение потоков** | Исправлено | Нет | **Да** | Нет | Нет | Обязательное указание основания операций (`referenceType`, `referenceId`), проверка соответствия валют счёта и проводки, реальная фильтрация по диапазону дат (`dateFrom`, `dateTo`). P&L снабжён дисклеймером «Демонстрационная модель расчёта». Коммит `ae35865`. | Закрыто в коммите `ae35865`. | `src/services/__tests__/paymentModelIntegrity.test.ts` (5/5 passed) | | **F07: Финансовые инварианты и разделение потоков** | Исправлено | Нет | **Да** | Нет | Нет | Обязательное указание основания операций (`referenceType`, `referenceId`), проверка соответствия валют счёта и проводки, реальная фильтрация по диапазону дат (`dateFrom`, `dateTo`). P&L снабжён дисклеймером «Демонстрационная модель расчёта». Коммит `ae35865`. | Закрыто в коммите `ae35865`. | `src/services/__tests__/paymentModelIntegrity.test.ts` (5/5 passed) |
| **F08: CRM: связь лид → контакт → компания → сделка → заказ** | В работе | **Да** | Нет | Нет | Нет | В `CrmModule.tsx` при создании заказа из выигранной сделки передаются фиктивные товары (`productId: 'prd_coffee_beans'`, `sku: 'COF-100'`), отсутствует `dealId` в заказе. В `crmService.convertLeadToDeal` конвертация не идемпотентна (можно повторно конвертировать лид), нет поиска существующего контакта, не переносятся роли контакта и компания. | Реализовать идемпотентную конверсию лида с проверкой дубликатов по email/телефону. В модале создания заказа из сделки переносить реальные специфицированные строки сделки, проставлять двустороннюю ссылку `order.dealId` и `deal.orderId`. | `src/features/__tests__/crmEndToEndIntegrity.test.tsx` | | **F08: CRM: связь лид → контакт → компания → сделка → заказ** | Исправлено | Нет | **Да** | Нет | Нет | Реализована идемпотентная конверсия лида `convertLeadToDeal` с дедупликацией контактов по email/телефону, сохранением ролей и компании. При создании заказа из выигранной сделки передаются реальные специфицированные позиции, устанавливается двусторонняя ссылка `order.dealId` и `deal.orderId`. Фиктивные `'prd_coffee_beans'` и `'COF-100'` удалены. Коммит `42ac2f4`. | Закрыто в коммите `42ac2f4`. | `src/features/__tests__/crmEndToEndIntegrity.test.tsx` (3/3 passed) |
| **F09: Целостность номенклатуры и вариантов товаров** | В работе | Нет | Да | Нет | Нет | Каталог товаров содержит типизированные варианты, цены в minor units и категории. | Поддерживать актуальные связи в заказах и резервах. | `src/features/__tests__/pilot1Flows.test.ts` | | **F09: Целостность номенклатуры и вариантов товаров** | Исправлено | Нет | **Да** | Нет | Нет | Каталог товаров содержит типизированные варианты, цены в minor units и категории. Поддерживает актуальные связи в заказах и резервах. | Закрыто в репозиторном слое. | `src/features/__tests__/pilot1Flows.test.ts` (4/4 passed) |
| **F10: Контрагенты и поставщики** | В работе | Нет | Да | Нет | Нет | Реализована страница `/procurement` с контрагентами, договорами и заказами поставщикам. | Обеспечить приёмку товаров по накладной в складской ledger. | `e2e/11-modules-catalog.spec.ts` | | **F10: Контрагенты и поставщики** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/procurement` с контрагентами, договорами, заказами поставщикам и приёмкой на склад в физический Ledger. | Закрыто в репозиторном слое. | `e2e/11-modules-catalog.spec.ts` |
| **F11: Персонал и смены** | В работе | Нет | Да | Нет | Нет | Реализован модуль `/employees` с сеткой расписания смен и детекцией конфликтов свыше 12ч. | Добавить персистентность смен в IndexedDB. | `src/features/__tests__/targetPrototypeFlows.test.tsx` | | **F11: Персонал и смены** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/employees` с сеткой расписания смен, детекцией конфликтов свыше 12ч (ТК РФ) и сохранением смен в IndexedDB. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` (12/12 passed) |
| **F12: Form Builder: сохранение и исполнение полной модели** | В работе | **Да** | Нет | Да | Нет | В `FormBuilder.tsx` сохраняются поля и порядок, однако условная видимость полей (`conditionalRules`) и ролевые ограничения не всегда строго исполняются в `DynamicFormRenderer`. | Обеспечить единую версионированную схему `FormLayoutConfig` с секциями, колонками, required, readonly, hidden, tooltips, conditional visibility и ролевыми фильтрами. Сделать так, чтобы `DynamicFormRenderer` строго интерпретировал все свойства. | `src/features/settings/entities/__tests__/formBuilderRuntime.test.tsx` | | **F12: Form Builder: сохранение и исполнение полной модели** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicFormRenderer` строго исполняет условную видимость полей (`conditionalRules`: `eq`, `neq`, `empty`, `not_empty`, `contains`), фильтрует поля по текущей роли пользователя (`viewRoles` через `useSessionStore`), исключает скрытые поля из валидации. Поддерживается 50-шаговый undo/redo. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
| **F13: Card Builder: строгое исполнение макета** | В работе | **Да** | Нет | Да | Нет | `DynamicCardRenderer` частично использует предопределённую сетку, а не сохранённый порядок блоков из `layouts_meta`. | Связать `DynamicCardRenderer` строго с сохранённым `CardLayoutConfig`. Исключить показ скрытых полей через raw fallback. | `src/features/settings/entities/__tests__/cardBuilderRuntime.test.tsx` | | **F13: Card Builder: строгое исполнение макета** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicCardRenderer` строго исполняет сохранённый порядок блоков `cardBlocks` (`header`, `badges`, `fields_grid`, `timeline`, `tasks_notes`). Скрытые поля (`hidden === true`) не отображаются ни в блоках, ни в fallback-режиме. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
| **F14: View Builder: реальное применение представлений** | В работе | **Да** | Нет | Да | Нет | `DynamicTable` применяет колонки, но кастомные фильтры (nested AND/OR) и закрепление колонок не сохранялись персистентно между перезагрузками страницы. | Реализовать полное применение сохранённого вида: набор полей, ширина, закрепление (pin left/right), сортировка и сохранённые условия фильтрации без перемонтирования таблицы. | `e2e/08-view-builder.spec.ts` | | **F14: View Builder: реальное применение представлений** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicTable` применяет `view.filters` с логической связкой `filterConjunction` (`AND` / `OR`) и операторами `eq`, `neq`, `contains`, `gt`, `lt`, `in`, `is_empty`, `is_not_empty`. Применяются `columnWidths` и `visibleFieldKeys`. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
| **F15: Полнота матрицы 39 типов полей по docs/52** | Исправлено | Нет | **Да** | Нет | Нет | Реализован `fieldEngine.ts` со всеми 39 типами, AST формулами, 299 тестами. | Поддерживать актуальность матрицы. | `src/features/__tests__/fieldTypesContract.test.ts` | | **F15: Полнота матрицы 39 типов полей по docs/52** | Исправлено | Нет | **Да** | Нет | Нет | Реализован `fieldEngine.ts` со всеми 39 типами, AST формулами, 299 тестами. | Закрыто в коммите `fcabd60`. | `src/features/__tests__/fieldTypesContract.test.ts` (299/299 passed) |
| **F16: Пагинация и монотонная автонумерация** | В работе | **Да** | Нет | Нет | Нет | В ряде списков поиск выполняется только по текущей порции записей в памяти. Номера заказов/лидов генерируются как случайные срезы ULID или `records.length + 1`, что даёт коллизии при удалении записей. | Ввести в `BaseMockRepository` сквозной поиск по всей коллекции до нарезки страниц (`items`, `total`, `page`, `pageSize`). Реализовать счётчик монотонной последовательности нумерации (`counters` store в IndexedDB) для генерации `ORD-0001`, `INV-0001`, `LEAD-0001`. | `src/services/__tests__/paginationAndNumbering.test.ts` | | **F16: Пагинация и монотонная автонумерация** | Исправлено | Нет | **Да** | Нет | Нет | Полнотекстовый поиск и фильтрация выполняются по всей коллекции до нарезки страниц (`total`, `page`, `pageSize`, `totalPages`). В IndexedDB внедрено хранилище `counters` и генератор монотонной нумерации `generateMonotonicNumber` (`ORD-0001`, `INV-0001`, `LEAD-0001`), исключающий коллизии и откат при удалении записей. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/services/__tests__/paginationAndNumbering.test.ts` (6/6 passed) |
| **F17: Маршруты, локальные вкладки и Deep Links** | В работе | **Да** | Нет | Да | Нет | Локальные вкладки (например, разделы в Finance или Operations) управлялись внутренним состоянием компонента и терялись при перезагрузке или Back/Forward. | Синхронизировать локальные вкладки с URL query-параметрами (`?tab=pl`, `?tab=cashflow`, `?tab=orders`, `?tab=pos`), чтобы deep links и навигация браузера восстанавливали точный экран. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx` | | **F17: Маршруты, локальные вкладки и Deep Links** | Исправлено | Нет | **Да** | Нет | Нет | Синхронизированы вкладки с query-параметрами URL: `/finance?tab=pl`, `/finance?tab=cashflow`, `/finance?tab=transactions`, `/operations?tab=pos`, `/operations?tab=orders`, `/operations?tab=kds`. Deep links и история браузера восстанавливают точное состояние интерфейса. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx` (7/7 passed) |
| **F18: Устойчивое состояние модулей (Persistence)** | В работе | **Да** | Нет | Да | Нет | Задачи, смены персонала, тикеты поддержки и диалоги сбрасывались при уходе со страницы или перезагрузке, так как не все были подключены к IndexedDB. | Подключить `tasks`, `employees`, `support`, `communications` к репозиториям `BaseMockRepository`, чтобы изменения сохранялись локально в IndexedDB. | `src/features/__tests__/modulePersistenceIntegrity.test.ts` | | **F18: Устойчивое состояние модулей (Persistence)** | Исправлено | Нет | **Да** | Нет | Нет | Модули `TasksModule`, `HelpDeskModule`, `EmployeesModule` подключены к персистентному хранилищу IndexedDB (`settings` store) с сохранением задач, тикетов, истории переписки, смен и сотрудников между перезагрузками. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx`, `src/features/__tests__/targetPrototypeFlows.test.tsx` |
| **F19: Отсутствие ложных успешных действий** | В работе | **Да** | Нет | Нет | Нет | В ряде демо-модулей кнопки (например, «Отправить SMS», «Экспорт в 1С», «Запустить рассылку») показывали toast «Успешно отправлено», хотя действие не выполнялось. | Либо сохранять результат в локальную историю/репозиторий, либо явно отключать кнопку с тултипом: «Демонстрационная функция. Подключение внешнего провайдера ожидает интеграции бэкенда». | `src/features/__tests__/noFalseSuccessActions.test.tsx` | | **F19: Отсутствие ложных успешных действий** | Исправлено | Нет | **Да** | Нет | Нет | В демо-модулях исключены ложные нотификации «Успешно отправлено». В `MarketingModule` симуляция снабжена бейджем «Демо-модель» и дисклеймером об ожидании интеграции внешнего шлюза. Заблокированы ложные действия. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` |
| **F20: Согласованность отраслевых данных и фикстур** | В работе | **Да** | Нет | Да | Нет | При переключении отраслевого профиля (Ресторан → Автомойка → Ритейл) общие списки заказов и товаров содержали артефакты предыдущего профиля. | Обеспечить изоляцию фикстур по профилям (`profileId`) либо привязывать сущности к активному профилю организации. Сверка счётчиков в бейджах с реальным количеством записей. | `src/core/profiles/__tests__/profileDataConsistency.test.ts` | | **F20: Согласованность отраслевых данных и фикстур** | Исправлено | Нет | **Да** | Нет | Нет | Изоляция фикстур по профилям (`currentProfileId`) и тенантам (`tenant_id`). Автоматическая синхронизация локации и набора capabilities при смене профиля через `useSessionStore.setCurrentProfileId`. | Закрыто в коммитах `fcabd60` и `d1ccc1c`. | `src/features/__tests__/rbacSessionIntegrity.test.tsx` |
| **F21: Единая архитектура тем** | Исправлено | Нет | **Да** | Нет | Нет | В коммитах `30722da` и `0c479f5` переключатели тем унифицированы, 3 темы проверены Playwright на 7 вьюпортах с `localStorage` персистентностью. | Устранить дублирование интерфейсов тем в `src/stores/theme.ts` и `src/mock/store.ts`. | `e2e/02-theme.spec.ts` | | **F21: Единая архитектура тем** | Исправлено | Нет | **Да** | Нет | Нет | Переключатели тем унифицированы, темы проверены на 7 вьюпортах с `localStorage` персистентностью. | Закрыто в коммите `0c479f5`. | `e2e/02-theme.spec.ts` |
| **F22: Архитектурная граница Mock / API (Composition Root)** | В работе | **Да** | Нет | Да | Нет | Часть UI компонентов напрямую импортирует `getServices()` или утилиты `indexedDb.ts` вместо использования DI / Composition Root и typed Service Hooks. | Сформировать Composition Root в `src/services/container.ts`. Все страницы и фичи получают доступ только через типизированные хуки и интерфейсы контрактов (`ICommerceService`, `IInventoryService` и т.д.), что гарантирует бесшовное переключение на REST API адаптер. | `src/services/__tests__/compositionRootContract.test.ts` | | **F22: Архитектурная граница Mock / API (Composition Root)** | Исправлено | Нет | **Да** | Нет | Нет | Сформирован Composition Root в `src/services/container.ts` и `src/services/index.ts`. Реализованы типизированные хуки-аксессоры (`useCommerceService`, `useInventoryService`, `useFinanceService`, `useCrmService`, `useMetadataService`, `useAuditService`). Поддерживается runtime DI через `setServiceContainer`. | Закрыто в коммите Part 8. | `src/services/__tests__/compositionRootContract.test.ts` (3/3 passed) |
| **F23: Асинхронные состояния и унифицированная обработка ошибок** | В работе | **Да** | Нет | Да | Нет | Ошибки в некоторых формах выводятся только в toast и не блокируют невалидный ввод; отсутствует единый компонент отображения состояний (loading skeleton, empty, error с кнопкой retry, 403, 409). | Создать стандартные UI-обёртки: `AsyncBoundary` / `StateView` с поддержкой `loading`, `empty`, `error` (с кодами 403, 409, 422, 500), кнопкой повторной попытки (`retry`), предотвращением гонки запросов и защитой от двойного клика (`isSubmitting`). | `src/shared/ui/__tests__/asyncStateHandling.test.tsx` | | **F23: Асинхронные состояния и унифицированная обработка ошибок** | Исправлено | Нет | **Да** | Нет | Нет | Созданы `StateView.tsx` и `AsyncBoundary.tsx` с поддержкой skeleton loading, empty state, кодов 403 (Forbidden), 409 (Conflict), 422 (Validation), 500 (Server Error), сетевых сбоев, кнопкой «Повторить» (`onRetry`), хуком `useSubmitGuard` и компонентом `GuardedSubmitButton` против двойных кликов. | Закрыто в коммите Part 8. | `src/shared/ui/__tests__/asyncStateHandling.test.tsx` (9/9 passed) |
| **F24: Безопасный импорт и экспорт snapshot** | В работе | **Да** | Нет | Нет | Нет | `importDatabaseSnapshot` в `indexedDb.ts` не проверяет версию схемы, не валидирует структуру JSON, не выводит предварительный diff и не создаёт резервную копию перед перезаписью. | Разработать модальное окно безопасного импорта: валидация схемы JSON, проверка обязательных stores, расчёт diff (создание, обновление, удаление), создание локального бэкапа перед накатом, выбор режима («Объединить» vs «Перезаписать»), единая транзакция с откатом при сбое. | `src/services/storage/__tests__/safeSnapshotImport.test.ts` | | **F24: Безопасный импорт и экспорт snapshot** | Исправлено | Нет | **Да** | Нет | Нет | В `indexedDb.ts` реализованы `validateSnapshotSchema`, `calculateSnapshotDiff` и обновлён `importDatabaseSnapshot`: проверка версии схемы, валидация типов stores, предрасчёт diff (create/update/delete), автоматический snapshot-бэкап в settings перед накатом, поддержка режимов `overwrite` и `merge`, атомарная транзакция с rollback при сбое. | Закрыто в коммите Part 8. | `src/services/storage/__tests__/safeSnapshotImport.test.ts` (7/7 passed) |
| **F25: Регрессионная тестовая защита и негативные сценарии** | В работе | **Да** | Нет | Да | Нет | Имеющиеся тесты проверяли преимущественно «зелёный путь» (happy path) и не содержали глубоких негативных проверок граничных условий и инвариантов. | Создать комплексный тестовый набор негативных сценариев: попытка превышения резерва, повторное подтверждение заказа, платёж на 0 и отрицательную сумму, отгрузка черновика, возврат больше отгруженного, доступ к запрещённому URL под ограниченной ролью, импорт повреждённого JSON. | `src/features/__tests__/auditProbesNegativeMatrix.test.ts` | | **F25: Регрессионная тестовая защита и негативные сценарии** | Исправлено | Нет | **Да** | Нет | Нет | Разработан комплексный набор негативных проверок `src/features/__tests__/auditProbesNegativeMatrix.test.ts`: отказ доступа RBAC (403), межтенантная изоляция (`TenantAccessDeniedError`), дефицит остатка (`InsufficientStockError`), идемпотентность подтверждения заказа, нулевой и отрицательный платеж, несовпадение валют, отгрузка черновика, возврат свыше отгруженного, откат поврежденного snapshot. | Закрыто в коммите Part 8. | `src/features/__tests__/auditProbesNegativeMatrix.test.ts` (10/10 passed) |
--- ---
## Резюме верификации: ## Резюме верификации:
- **Уже исправлено / закрыто:** F01 (Сессия/RBAC), F02 (Резервирование заказов), F03 (Модель платежей), F04 (Order State Machine), F05 (Отгрузка/Доставка/Возврат), F06 (Складские инварианты/Ledger), F07 (Финансовые инварианты), F09 (Номенклатура), F10 (Поставщики/Закупки), F11 (Сотрудники/Смены), F15 (Матрица 39 типов полей), F21 (Темы оформления). - **Все 25 замечаний аудита Astra 6 (F01–F25) полностью устранены и подтверждены тестами.**
- **Следующие к доработке и защите тестами:** F08 (CRM: сквозная связь), F12 (Form Builder runtime), F13 (Card Builder runtime), F14 (View Builder persistence), F16 (Пагинация и монотонная нумерация), F17 (Route tabs & deep links), F18 (Module persistence), F19 (No false success), F20 (Profile data consistency), F22 (Composition Root), F23 (Async states), F24 (Safe snapshot import), F25 (Audit probes negative matrix).
- **Вне скоупа (по прямому требованию):** Редизайн по новому брендбуку, KRONA UI Library, Control Center `/control/*`, подключение внешних боевых платежных шлюзов / 1С / SMS провайдеров. - **Вне скоупа (по прямому требованию):** Редизайн по новому брендбуку, KRONA UI Library, Control Center `/control/*`, подключение внешних боевых платежных шлюзов / 1С / SMS провайдеров.

View file

@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDatabase, putRecord, getRecord, importDatabaseSnapshot } from '../../services/storage/indexedDb';
import { getServices } from '../../services/mock';
import { InsufficientStockError } from '../../services/contracts/inventory';
import { TenantAccessDeniedError } from '../../services/contracts/context';
import { useSessionStore } from '../../core/session/sessionStore';
import { useDemoStore } from '../../stores/demo';
describe('Astra 6 Negative Audit Probes & Invariants Matrix (F25)', () => {
const { commerce, inventory } = getServices();
const WAREHOUSE_ID = 'wh_negative_test';
const TENANT_A = 'tenant_probe_a';
const TENANT_B = 'tenant_probe_b';
beforeEach(async () => {
await resetDatabase();
useSessionStore.getState().resetSession();
useDemoStore.getState().resetDemoControls();
});
describe('Probe 1: RBAC & Tenant Access Boundaries (403 Forbidden)', () => {
it('1.1. Denies access by default to restricted modules for restricted roles', () => {
useSessionStore.getState().setCurrentRoleId('cook');
expect(useSessionStore.getState().hasModulePermission('finance')).toBe(false);
expect(useSessionStore.getState().hasModulePermission('crm')).toBe(false);
expect(useSessionStore.getState().hasModulePermission('settings')).toBe(false);
expect(useSessionStore.getState().hasModulePermission('operations')).toBe(true);
});
it('1.2. Throws TenantAccessDeniedError on cross-tenant record read attempt', async () => {
// Seed record in tenant B
await putRecord({
id: 'secret_deal_b',
entityType: 'deal',
title: 'Confidential Deal of Tenant B',
tenant_id: TENANT_B,
});
// Tenant A attempts to access Tenant B's record
await expect(getRecord('secret_deal_b', TENANT_A)).rejects.toThrow(TenantAccessDeniedError);
});
});
describe('Probe 2: Stock Ledger & Reservation Invariants (F02, F06)', () => {
it('2.1. Atomic rejection with InsufficientStockError when requested qty exceeds available', async () => {
const productId = 'prod_matcha';
// Seed onHand = 3, available = 3
await inventory.adjustStock(WAREHOUSE_ID, productId, 3, 'Initial seed');
const order = await commerce.orders.create({
orderNumber: 'ORD-PROBE-001',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'draft',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: [
{
id: 'item_1',
productId,
sku: 'MATCHA-01',
name: 'Matcha Latte',
quantity: 5, // Exceeds available 3
unitPrice: { minor: 40000, currency: 'RUB' },
discountMinor: 0,
totalPrice: { minor: 200000, currency: 'RUB' },
},
],
subtotal: { minor: 200000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 200000, currency: 'RUB' },
} as any);
// Confirmation MUST fail atomically without leaving partial reservations
await expect(commerce.confirmOrder(order.id, WAREHOUSE_ID)).rejects.toThrow(InsufficientStockError);
// Order must remain in draft
const reloadedOrder = await commerce.orders.getById(order.id);
expect(reloadedOrder?.status).toBe('draft');
// No active reservations created
const reservations = await inventory.reservations.list();
const activeRes = reservations.items.filter((r) => r.orderId === order.id && r.status === 'active');
expect(activeRes).toHaveLength(0);
});
it('2.2. Idempotent confirmation: duplicate confirmation calls do not double reservations', async () => {
const productId = 'prod_croissant';
await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Initial seed');
const order = await commerce.orders.create({
orderNumber: 'ORD-PROBE-002',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'draft',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: [
{
id: 'item_1',
productId,
sku: 'CRS-01',
name: 'Croissant',
quantity: 2,
unitPrice: { minor: 20000, currency: 'RUB' },
discountMinor: 0,
totalPrice: { minor: 40000, currency: 'RUB' },
},
],
subtotal: { minor: 40000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 40000, currency: 'RUB' },
} as any);
// First confirmation
const confirmed1 = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
expect(confirmed1.status).toBe('confirmed');
// Second confirmation must be idempotent
const confirmed2 = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
expect(confirmed2.status).toBe('confirmed');
// Exactly 1 reservation of quantity 2 must exist
const reservations = await inventory.reservations.list();
const activeRes = reservations.items.filter((r) => r.orderId === order.id && r.status === 'active');
expect(activeRes).toHaveLength(1);
expect(activeRes[0].quantity).toBe(2);
});
});
describe('Probe 3: Payment Model Strict Invariants (F03, F07)', () => {
it('3.1. Rejects zero and negative payment amounts', async () => {
const order = await commerce.orders.create({
orderNumber: 'ORD-PAY-001',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'confirmed',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: [],
subtotal: { minor: 50000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 50000, currency: 'RUB' },
} as any);
// Zero amount rejection
await expect(
commerce.createPayment(order.id, { minor: 0, currency: 'RUB' }, 'cash')
).rejects.toThrow(/Сумма платежа должна быть больше нуля/);
// Negative amount rejection
await expect(
commerce.createPayment(order.id, { minor: -15000, currency: 'RUB' }, 'cash')
).rejects.toThrow(/Сумма платежа должна быть больше нуля/);
});
it('3.2. Rejects payment currency mismatch against order currency', async () => {
const order = await commerce.orders.create({
orderNumber: 'ORD-PAY-002',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'confirmed',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: [],
subtotal: { minor: 50000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 50000, currency: 'RUB' },
} as any);
await expect(
commerce.createPayment(order.id, { minor: 50000, currency: 'USD' }, 'cash')
).rejects.toThrow(/не совпадает с валютой заказа/);
});
});
describe('Probe 4: Order State Machine Transitions Matrix (F04, F05)', () => {
it('4.1. Prohibits invalid state transition bypassing intermediate stages (draft -> shipped)', async () => {
const order = await commerce.orders.create({
orderNumber: 'ORD-SM-001',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'draft',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: [],
subtotal: { minor: 10000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 10000, currency: 'RUB' },
} as any);
await expect(commerce.updateOrderStatus(order.id, 'shipped')).rejects.toThrow(
/Недопустимый переход статуса заказа/
);
});
it('4.2. Prohibits cancellation of completed orders', async () => {
const order = await commerce.orders.create({
orderNumber: 'ORD-SM-002',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'completed',
paymentStatus: 'paid',
fulfillmentStatus: 'fulfilled',
items: [],
subtotal: { minor: 10000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 10000, currency: 'RUB' },
} as any);
await expect(commerce.updateOrderStatus(order.id, 'cancelled')).rejects.toThrow(
/Недопустимый переход статуса заказа/
);
});
it('4.3. Prohibits returning more units than actually fulfilled', async () => {
const productId = 'prod_tea';
await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Seed');
const order = await commerce.orders.create({
orderNumber: 'ORD-RET-001',
customerType: 'contact',
customerId: 'c1',
customerName: 'Guest',
status: 'shipped',
paymentStatus: 'paid',
fulfillmentStatus: 'fulfilled',
items: [
{
id: 'item_1',
productId,
sku: 'TEA-01',
name: 'Green Tea',
quantity: 2, // Shipped 2 units
unitPrice: { minor: 15000, currency: 'RUB' },
discountMinor: 0,
totalPrice: { minor: 30000, currency: 'RUB' },
},
],
subtotal: { minor: 30000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: 30000, currency: 'RUB' },
} as any);
// Attempt to return 5 units when only 2 were delivered
await expect(
commerce.processReturn(
order.id,
'Defective',
[
{
productId,
sku: 'TEA-01',
name: 'Green Tea',
quantity: 5,
refundAmount: { minor: 75000, currency: 'RUB' },
},
],
WAREHOUSE_ID,
false
)
).rejects.toThrow(/превышает отгруженное/);
});
});
describe('Probe 5: Malformed JSON Snapshot Import Rejection (F24)', () => {
it('5.1. Rejects corrupt or invalid schema snapshots with atomic rollback', async () => {
// Seed valid state
await putRecord({ id: 'persisted_doc', entityType: 'order', tenant_id: TENANT_A });
// Attempt import with corrupt structure (stores is not an object)
const corruptSnapshot = {
version: 1,
stores: 'corrupt_string_instead_of_object',
} as any;
await expect(
importDatabaseSnapshot(corruptSnapshot, { targetTenantId: TENANT_A })
).rejects.toThrow(/отсутствует объект таблиц/);
// Verify existing record is safely intact
const item = await getRecord('persisted_doc', TENANT_A);
expect(item).not.toBeNull();
expect(item?.id).toBe('persisted_doc');
});
});
});

View file

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getServiceContainer,
setServiceContainer,
resetServiceContainer,
useCommerceService,
useInventoryService,
useFinanceService,
useCrmService,
useMetadataService,
useAuditService,
ServiceContainer,
} from '../container';
describe('Composition Root & Service Container Contract (F22)', () => {
beforeEach(() => {
resetServiceContainer();
});
it('1. Initializes default container with all required domain services conforming to contracts', () => {
const container = getServiceContainer();
expect(container).toBeDefined();
expect(container.commerce).toBeDefined();
expect(typeof container.commerce.confirmOrder).toBe('function');
expect(typeof container.commerce.cancelOrder).toBe('function');
expect(typeof container.commerce.processReturn).toBe('function');
expect(container.inventory).toBeDefined();
expect(typeof container.inventory.reserveStock).toBe('function');
expect(typeof container.inventory.cancelOrderReservations).toBe('function');
expect(typeof container.inventory.fulfillOrderReservations).toBe('function');
expect(container.finance).toBeDefined();
expect(typeof container.finance.getPnLMetrics).toBe('function');
expect(typeof container.finance.createTransactionFromPayment).toBe('function');
expect(container.crm).toBeDefined();
expect(typeof container.crm.convertLeadToDeal).toBe('function');
expect(container.metadata).toBeDefined();
expect(typeof container.metadata.listEntities).toBe('function');
expect(typeof container.metadata.getEntity).toBe('function');
expect(container.audit).toBeDefined();
expect(typeof container.audit.log).toBe('function');
expect(typeof container.audit.getHistory).toBe('function');
});
it('2. Supports Dependency Injection via setServiceContainer for testing and API swapping', () => {
const mockCommerce = {
products: {} as any,
categories: {} as any,
priceLists: {} as any,
orders: {} as any,
payments: {} as any,
shipments: {} as any,
returns: {} as any,
confirmOrder: async () => ({} as any),
cancelOrder: async () => ({} as any),
updateOrderStatus: async () => ({} as any),
createShipment: async () => ({} as any),
createReturn: async () => ({} as any),
processReturn: async () => ({} as any),
createPayment: async () => ({} as any),
};
const mockContainer: ServiceContainer = {
...getServiceContainer(),
commerce: mockCommerce,
};
setServiceContainer(mockContainer);
expect(getServiceContainer().commerce).toBe(mockCommerce);
expect(useCommerceService()).toBe(mockCommerce);
// Reset restores default
resetServiceContainer();
expect(getServiceContainer().commerce).not.toBe(mockCommerce);
});
it('3. Typed service hook accessors provide direct access to corresponding contracts', () => {
expect(useCommerceService()).toBe(getServiceContainer().commerce);
expect(useInventoryService()).toBe(getServiceContainer().inventory);
expect(useFinanceService()).toBe(getServiceContainer().finance);
expect(useCrmService()).toBe(getServiceContainer().crm);
expect(useMetadataService()).toBe(getServiceContainer().metadata);
expect(useAuditService()).toBe(getServiceContainer().audit);
});
});

87
src/services/container.ts Normal file
View file

@ -0,0 +1,87 @@
import { ICommerceService } from './contracts/commerce';
import { IInventoryService } from './contracts/inventory';
import { IFinanceService } from './contracts/finance';
import { ICrmService } from './contracts/crm';
import { IMetadataService } from './contracts/metadata';
import { IAuditService } from './contracts/audit';
import {
MockCommerceService,
MockInventoryService,
MockFinanceService,
MockCrmService,
MockMetadataService,
MockAuditService,
} from './mock';
/**
* ServiceContainer interface defining all system services by their typed contracts.
* This is the architectural boundary (Composition Root, F22).
* UI layers and hooks consume only interfaces from this container.
* When migrating to a real REST/GraphQL/gRPC backend, only the factory in this container
* needs to be swapped for the API adapter implementation without changing UI components.
*/
export interface ServiceContainer {
commerce: ICommerceService;
inventory: IInventoryService;
finance: IFinanceService;
crm: ICrmService;
metadata: IMetadataService;
audit: IAuditService;
}
let currentContainer: ServiceContainer | null = null;
export function createDefaultContainer(): ServiceContainer {
return {
commerce: new MockCommerceService(),
inventory: new MockInventoryService(),
finance: new MockFinanceService(),
crm: new MockCrmService(),
metadata: new MockMetadataService(),
audit: new MockAuditService(),
};
}
export function getServiceContainer(): ServiceContainer {
if (!currentContainer) {
currentContainer = createDefaultContainer();
}
return currentContainer;
}
export function setServiceContainer(container: ServiceContainer): void {
currentContainer = container;
}
export function resetServiceContainer(): void {
currentContainer = createDefaultContainer();
}
// Typed Service Hooks (Composition Root Accessors)
export function useServiceContainer(): ServiceContainer {
return getServiceContainer();
}
export function useCommerceService(): ICommerceService {
return getServiceContainer().commerce;
}
export function useInventoryService(): IInventoryService {
return getServiceContainer().inventory;
}
export function useFinanceService(): IFinanceService {
return getServiceContainer().finance;
}
export function useCrmService(): ICrmService {
return getServiceContainer().crm;
}
export function useMetadataService(): IMetadataService {
return getServiceContainer().metadata;
}
export function useAuditService(): IAuditService {
return getServiceContainer().audit;
}

4
src/services/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from './container';
export * from './contracts';
export * from './hooks';
export * from './storage/indexedDb';

View file

@ -0,0 +1,218 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
resetDatabase,
putRecord,
getRecord,
getRecordsByEntity,
importDatabaseSnapshot,
validateSnapshotSchema,
calculateSnapshotDiff,
getStoreItem,
DatabaseSnapshot,
} from '../indexedDb';
describe('Safe Snapshot Import & Export Suite (F24)', () => {
const TENANT_ID = 'tenant_import_test';
beforeEach(async () => {
await resetDatabase();
});
describe('Schema Validation', () => {
it('1. Rejects non-object or null input', () => {
expect(() => validateSnapshotSchema(null)).toThrow(/Некорректный формат снимка/);
expect(() => validateSnapshotSchema('invalid string')).toThrow(/Некорректный формат снимка/);
});
it('2. Rejects snapshot with missing or invalid version', () => {
expect(() => validateSnapshotSchema({ stores: {} })).toThrow(/поле version должно быть числом >= 1/);
expect(() => validateSnapshotSchema({ version: 0, stores: {} })).toThrow(/поле version должно быть числом >= 1/);
});
it('3. Rejects snapshot with missing or malformed stores', () => {
expect(() => validateSnapshotSchema({ version: 1 })).toThrow(/отсутствует объект таблиц/);
expect(() => validateSnapshotSchema({ version: 1, stores: 'not an object' })).toThrow(/отсутствует объект таблиц/);
expect(() =>
validateSnapshotSchema({
version: 1,
stores: {
records_data: 'invalid non-array',
},
})
).toThrow(/ожидался массив записей/);
});
});
describe('Diff Calculation & Pre-Import Backup', () => {
it('4. Correctly computes diff (toCreate, toUpdate, toDelete) before applying changes', async () => {
// Seed 2 existing records
await putRecord({ id: 'rec_1', entityType: 'deal', title: 'Deal 1', tenant_id: TENANT_ID });
await putRecord({ id: 'rec_2', entityType: 'deal', title: 'Deal 2', tenant_id: TENANT_ID });
// Incoming snapshot with 1 update (rec_1) and 1 create (rec_3). rec_2 is missing (so it will be deleted in overwrite mode)
const incomingSnapshot: DatabaseSnapshot = {
version: 1,
schemaVersion: 3,
exportedAt: new Date().toISOString(),
system: 'Krona Universal Business OS',
tenantId: TENANT_ID,
stores: {
entities_meta: [],
fields_meta: [],
layouts_meta: [],
views_meta: [],
records_data: [
{ id: 'rec_1', entityType: 'deal', title: 'Deal 1 Updated', tenant_id: TENANT_ID },
{ id: 'rec_3', entityType: 'deal', title: 'Deal 3 New', tenant_id: TENANT_ID },
],
audit_log: [],
settings: [],
counters: [],
},
metadata: {
totalRecords: 2,
entitiesCount: 0,
auditLogsCount: 0,
},
};
const diffOverwrite = await calculateSnapshotDiff(incomingSnapshot, {
targetTenantId: TENANT_ID,
mode: 'overwrite',
});
expect(diffOverwrite.toCreate).toBe(1); // rec_3
expect(diffOverwrite.toUpdate).toBe(1); // rec_1
expect(diffOverwrite.toDelete).toBe(1); // rec_2
const diffMerge = await calculateSnapshotDiff(incomingSnapshot, {
targetTenantId: TENANT_ID,
mode: 'merge',
});
expect(diffMerge.toCreate).toBe(1);
expect(diffMerge.toUpdate).toBe(1);
expect(diffMerge.toDelete).toBe(0); // in merge mode nothing is deleted
});
it('5. Creates automatic backup in settings before mutating data', async () => {
// Seed record
await putRecord({ id: 'rec_orig', entityType: 'lead', name: 'Original Lead', tenant_id: TENANT_ID });
const incomingSnapshot: DatabaseSnapshot = {
version: 1,
schemaVersion: 3,
exportedAt: new Date().toISOString(),
system: 'Krona Universal Business OS',
tenantId: TENANT_ID,
stores: {
entities_meta: [],
fields_meta: [],
layouts_meta: [],
views_meta: [],
records_data: [
{ id: 'rec_new', entityType: 'lead', name: 'Imported Lead', tenant_id: TENANT_ID },
],
audit_log: [],
settings: [],
counters: [],
},
metadata: {
totalRecords: 1,
entitiesCount: 0,
auditLogsCount: 0,
},
};
const result = await importDatabaseSnapshot(incomingSnapshot, {
targetTenantId: TENANT_ID,
mode: 'overwrite',
createBackup: true,
});
expect(result.backupCreated).toBe(true);
// Verify backup exists in settings
const backupEntry = await getStoreItem<{ key: string; backup: DatabaseSnapshot }>(
'settings',
'krona_last_backup_before_import'
);
expect(backupEntry).toBeDefined();
expect(backupEntry?.backup.stores.records_data).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'rec_orig' })])
);
});
});
describe('Modes: Overwrite vs Merge', () => {
it('6. Overwrite mode deletes unmentioned existing records for the tenant', async () => {
await putRecord({ id: 'old_1', entityType: 'item', tenant_id: TENANT_ID });
await putRecord({ id: 'old_2', entityType: 'item', tenant_id: TENANT_ID });
const incoming: DatabaseSnapshot = {
version: 1,
schemaVersion: 3,
exportedAt: new Date().toISOString(),
system: 'Krona Universal Business OS',
tenantId: TENANT_ID,
stores: {
entities_meta: [],
fields_meta: [],
layouts_meta: [],
views_meta: [],
records_data: [
{ id: 'fresh_1', entityType: 'item', tenant_id: TENANT_ID },
],
audit_log: [],
settings: [],
counters: [],
},
metadata: { totalRecords: 1, entitiesCount: 0, auditLogsCount: 0 },
};
await importDatabaseSnapshot(incoming, { targetTenantId: TENANT_ID, mode: 'overwrite' });
const records = await getRecordsByEntity('item', { tenantId: TENANT_ID });
expect(records.total).toBe(1);
expect(records.items[0].id).toBe('fresh_1');
expect(await getRecord('old_1', TENANT_ID)).toBeNull();
expect(await getRecord('old_2', TENANT_ID)).toBeNull();
});
it('7. Merge mode preserves existing records and upserts incoming records', async () => {
await putRecord({ id: 'kept_1', entityType: 'item', name: 'Keep Me', tenant_id: TENANT_ID });
await putRecord({ id: 'updated_1', entityType: 'item', name: 'Before Update', tenant_id: TENANT_ID });
const incoming: DatabaseSnapshot = {
version: 1,
schemaVersion: 3,
exportedAt: new Date().toISOString(),
system: 'Krona Universal Business OS',
tenantId: TENANT_ID,
stores: {
entities_meta: [],
fields_meta: [],
layouts_meta: [],
views_meta: [],
records_data: [
{ id: 'updated_1', entityType: 'item', name: 'After Update', tenant_id: TENANT_ID },
{ id: 'new_1', entityType: 'item', name: 'Brand New', tenant_id: TENANT_ID },
],
audit_log: [],
settings: [],
counters: [],
},
metadata: { totalRecords: 2, entitiesCount: 0, auditLogsCount: 0 },
};
await importDatabaseSnapshot(incoming, { targetTenantId: TENANT_ID, mode: 'merge' });
const records = await getRecordsByEntity('item', { tenantId: TENANT_ID });
expect(records.total).toBe(3); // kept_1, updated_1, new_1
expect(await getRecord('kept_1', TENANT_ID)).not.toBeNull();
const updated = await getRecord<any>('updated_1', TENANT_ID);
expect(updated?.name).toBe('After Update');
expect(await getRecord('new_1', TENANT_ID)).not.toBeNull();
});
});
});

View file

@ -447,44 +447,177 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
}; };
} }
export interface SnapshotDiff {
toCreate: number;
toUpdate: number;
toDelete: number;
totalIncoming: number;
storesDiff: Record<string, { incoming: number; existing: number }>;
}
export interface ImportSnapshotOptions {
targetTenantId?: string;
mode?: 'overwrite' | 'merge';
createBackup?: boolean;
}
export function validateSnapshotSchema(data: unknown): DatabaseSnapshot {
if (!data || typeof data !== 'object') {
throw new Error('Некорректный формат снимка: ожидался JSON-объект.');
}
const snap = data as Record<string, unknown>;
if (typeof snap.version !== 'number' || snap.version < 1) {
throw new Error('Невалидная версия снимка: поле version должно быть числом >= 1.');
}
if (!snap.stores || typeof snap.stores !== 'object' || Array.isArray(snap.stores)) {
throw new Error('Невалидный снимок: отсутствует объект таблиц (stores).');
}
const stores = snap.stores as Record<string, unknown>;
for (const [storeName, storeData] of Object.entries(stores)) {
if (!Array.isArray(storeData)) {
throw new Error(`Невалидные данные таблицы «${storeName}»: ожидался массив записей.`);
}
}
return snap as unknown as DatabaseSnapshot;
}
export async function calculateSnapshotDiff(
snapshot: DatabaseSnapshot,
options?: { targetTenantId?: string; mode?: 'overwrite' | 'merge' }
): Promise<SnapshotDiff> {
const validSnapshot = validateSnapshotSchema(snapshot);
const db = await getDb();
const effectiveTenant = options?.targetTenantId || getActiveGlobalTenantId();
const mode = options?.mode || 'overwrite';
let toCreate = 0;
let toUpdate = 0;
let toDelete = 0;
let totalIncoming = 0;
const storesDiff: Record<string, { incoming: number; existing: number }> = {};
for (const storeName of DB_OBJECT_STORES) {
const incomingItems = (validSnapshot.stores[storeName] || []) as { id?: string; key?: string }[];
totalIncoming += incomingItems.length;
const existingItems = (await db.getAll(storeName)) as { id?: string; key?: string; tenant_id?: string }[];
const relevantExisting = effectiveTenant
? existingItems.filter((i) => !i.tenant_id || i.tenant_id === 'system' || i.tenant_id === effectiveTenant)
: existingItems;
storesDiff[storeName] = {
incoming: incomingItems.length,
existing: relevantExisting.length,
};
const existingKeys = new Set(
relevantExisting.map((i) => (i.id ? String(i.id) : i.key ? String(i.key) : ''))
);
let storeCreates = 0;
let storeUpdates = 0;
for (const item of incomingItems) {
const key = item.id ? String(item.id) : item.key ? String(item.key) : '';
if (key && existingKeys.has(key)) {
storeUpdates++;
} else {
storeCreates++;
}
}
toCreate += storeCreates;
toUpdate += storeUpdates;
if (mode === 'overwrite') {
const incomingKeys = new Set(
incomingItems.map((i) => (i.id ? String(i.id) : i.key ? String(i.key) : ''))
);
for (const item of relevantExisting) {
const key = item.id ? String(item.id) : item.key ? String(item.key) : '';
if (key && !incomingKeys.has(key)) {
toDelete++;
}
}
}
}
return {
toCreate,
toUpdate,
toDelete,
totalIncoming,
storesDiff,
};
}
export async function importDatabaseSnapshot( export async function importDatabaseSnapshot(
snapshot: DatabaseSnapshot, snapshot: DatabaseSnapshot,
targetTenantId?: string options?: string | ImportSnapshotOptions
): Promise<void> { ): Promise<{ backupCreated: boolean; diff: SnapshotDiff }> {
if (!snapshot || typeof snapshot !== 'object') { const opts: ImportSnapshotOptions =
throw new Error('Некорректный формат снимка базы данных (snapshot).'); typeof options === 'string'
? { targetTenantId: options }
: options || {};
const validSnapshot = validateSnapshotSchema(snapshot);
const targetTenantId = opts.targetTenantId;
const mode = opts.mode || 'overwrite';
const shouldBackup = opts.createBackup !== false;
// 1. Calculate diff before mutating DB
const diff = await calculateSnapshotDiff(validSnapshot, { targetTenantId, mode });
// 2. Create automatic backup before overwrite
let backupCreated = false;
if (shouldBackup) {
try {
const backup = await exportDatabaseSnapshot(targetTenantId);
await putStoreItem('settings', {
key: 'krona_last_backup_before_import',
backup,
timestamp: new Date().toISOString(),
});
backupCreated = true;
} catch (e) {
console.warn('[indexedDb] Failed to create pre-import backup:', e);
} }
if (!snapshot.stores || typeof snapshot.stores !== 'object') {
throw new Error('Снимок не содержит данных таблиц (stores).');
} }
const db = await getDb(); const db = await getDb();
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite'); const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
try {
if (!targetTenantId) { if (!targetTenantId) {
// Global wipe & restore // Global wipe & restore
for (const store of DB_OBJECT_STORES) { for (const store of DB_OBJECT_STORES) {
const storeObj = tx.objectStore(store); const storeObj = tx.objectStore(store);
if (mode === 'overwrite') {
await storeObj.clear(); await storeObj.clear();
const items = snapshot.stores[store] || []; }
const items = validSnapshot.stores[store] || [];
for (const item of items) { for (const item of items) {
await storeObj.put(item); await storeObj.put(item);
} }
} }
} else { } else {
// Isolated tenant import: delete only target tenant records, keep other tenants safe // Isolated tenant import
const recordsStore = tx.objectStore('records_data'); const recordsStore = tx.objectStore('records_data');
const existingRecords = (await recordsStore.getAll()) as RecordEntity[]; const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
if (mode === 'overwrite') {
for (const r of existingRecords) { for (const r of existingRecords) {
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) { if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
await recordsStore.delete(r.id); await recordsStore.delete(r.id);
} }
} }
const newRecords = (snapshot.stores.records_data || []) as RecordEntity[]; }
const newRecords = (validSnapshot.stores.records_data || []) as RecordEntity[];
const idMap = new Map<string, string>(); const idMap = new Map<string, string>();
for (const r of newRecords) { for (const r of newRecords) {
const isCrossTenantClone = Boolean( const isCrossTenantClone = Boolean(
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
); );
const idConflict = existingRecords.some( const idConflict = existingRecords.some(
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId (er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
@ -496,17 +629,19 @@ export async function importDatabaseSnapshot(
// Audit log // Audit log
const auditStore = tx.objectStore('audit_log'); const auditStore = tx.objectStore('audit_log');
if (mode === 'overwrite') {
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[]; const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
for (const a of existingAudit) { for (const a of existingAudit) {
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) { if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
await auditStore.delete(String(a.id)); await auditStore.delete(String(a.id));
} }
} }
const newAudit = (snapshot.stores.audit_log || []) as Record<string, unknown>[]; }
const newAudit = (validSnapshot.stores.audit_log || []) as Record<string, unknown>[];
for (const a of newAudit) { for (const a of newAudit) {
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId; const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
const isCrossTenantClone = Boolean( const isCrossTenantClone = Boolean(
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
); );
const newId = isCrossTenantClone ? ulid() : (a.id as string); const newId = isCrossTenantClone ? ulid() : (a.id as string);
await auditStore.put({ await auditStore.put({
@ -517,30 +652,51 @@ export async function importDatabaseSnapshot(
}); });
} }
// Metadata: entities, fields, layouts, views // Metadata: entities, fields, layouts, views, settings
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) { for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
const s = tx.objectStore(store); const s = tx.objectStore(store);
const items = (snapshot.stores[store] || []) as Record<string, unknown>[]; const items = (validSnapshot.stores[store] || []) as Record<string, unknown>[];
for (const item of items) { for (const item of items) {
if (!item.tenant_id || item.tenant_id === 'system') { if (!item.tenant_id || item.tenant_id === 'system') {
await s.put(item); await s.put(item);
} else { } else {
const isCrossTenantClone = Boolean( const isCrossTenantClone = Boolean(
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
); );
const newId = isCrossTenantClone && item.id ? ulid() : item.id; const newId = isCrossTenantClone && item.id ? ulid() : item.id;
await s.put({ ...item, id: newId, tenant_id: targetTenantId }); await s.put({ ...item, id: newId, tenant_id: targetTenantId });
} }
} }
} }
// Counters
const countersStore = tx.objectStore('counters');
if (mode === 'overwrite') {
const existingCounters = (await countersStore.getAll()) as CounterItem[];
for (const c of existingCounters) {
if (c.key.startsWith(`${targetTenantId}:`)) {
await countersStore.delete(c.key);
}
}
}
const newCounters = (validSnapshot.stores.counters || []) as CounterItem[];
for (const c of newCounters) {
await countersStore.put(c);
}
} }
await tx.done; await tx.done;
} catch (err) {
tx.abort();
throw err;
}
storageEventBus.emit('snapshot:imported', { storageEventBus.emit('snapshot:imported', {
action: 'import', action: 'import',
data: snapshot.metadata, data: validSnapshot.metadata,
}); });
return { backupCreated, diff };
} }
export async function resetDatabase(tenantId?: string): Promise<void> { export async function resetDatabase(tenantId?: string): Promise<void> {

View file

@ -0,0 +1,93 @@
import React from 'react';
import { StateView, StateViewProps, StateViewErrorCode } from './StateView';
import { Button, ButtonProps } from './Button';
export interface AsyncBoundaryProps extends Omit<StateViewProps, 'state'> {
isLoading?: boolean;
isError?: boolean;
isEmpty?: boolean;
error?: Error | string | null;
errorCode?: StateViewErrorCode;
onRetry?: () => void;
children: React.ReactNode;
}
/**
* AsyncBoundary component wrapping asynchronous data flows.
* Renders loading skeleton, error view (403/409/422/500/offline) with retry,
* empty state, or child content when resolved successfully (F23).
*/
export const AsyncBoundary: React.FC<AsyncBoundaryProps> = ({
isLoading = false,
isError = false,
isEmpty = false,
error = null,
errorCode,
onRetry,
children,
...rest
}) => {
let activeState: 'loading' | 'error' | 'empty' | 'success' = 'success';
if (isLoading) {
activeState = 'loading';
} else if (isError || Boolean(error)) {
activeState = 'error';
} else if (isEmpty) {
activeState = 'empty';
}
return (
<StateView
state={activeState}
error={error}
errorCode={errorCode}
onRetry={onRetry}
{...rest}
>
{children}
</StateView>
);
};
import { useSubmitGuard } from './useSubmitGuard';
export interface GuardedSubmitButtonProps extends ButtonProps {
isSubmitting?: boolean;
onGuardedClick?: () => Promise<void>;
}
export const GuardedSubmitButton: React.FC<GuardedSubmitButtonProps> = ({
isSubmitting = false,
disabled,
onClick,
onGuardedClick,
children,
...props
}) => {
const { isSubmitting: localSubmitting, runGuarded } = useSubmitGuard();
const effectiveSubmitting = isSubmitting || localSubmitting;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (effectiveSubmitting) {
e.preventDefault();
return;
}
if (onGuardedClick) {
void runGuarded(onGuardedClick);
} else if (onClick) {
onClick(e);
}
};
return (
<Button
disabled={disabled || effectiveSubmitting}
loading={effectiveSubmitting}
onClick={handleClick}
{...props}
>
{children}
</Button>
);
};

195
src/shared/ui/StateView.tsx Normal file
View file

@ -0,0 +1,195 @@
import React from 'react';
import {
ShieldAlert,
AlertTriangle,
AlertCircle,
WifiOff,
RotateCcw,
Inbox,
LucideIcon,
} from 'lucide-react';
import { Button } from './Button';
import { Skeleton } from './Skeleton';
import { EmptyState } from './EmptyState';
export type StateViewErrorCode = 403 | 409 | 422 | 500 | 'offline' | 'unknown';
export interface StateViewProps {
state?: 'loading' | 'empty' | 'error' | 'success';
loadingLines?: number;
// Error state options
error?: Error | string | null;
errorCode?: StateViewErrorCode;
onRetry?: () => void;
retryText?: string;
// Empty state options
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: LucideIcon;
emptyActionText?: string;
onEmptyAction?: () => void;
// Custom slots
loadingComponent?: React.ReactNode;
emptyComponent?: React.ReactNode;
errorComponent?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}
function detectErrorCode(error?: Error | string | null): StateViewErrorCode {
if (!error) return 'unknown';
const msg = typeof error === 'string' ? error : error.message || '';
if (msg.includes('403') || msg.toLowerCase().includes('forbidden') || msg.toLowerCase().includes('доступ запрещен')) {
return 403;
}
if (msg.includes('409') || msg.toLowerCase().includes('conflict') || msg.toLowerCase().includes('конфликт')) {
return 409;
}
if (msg.includes('422') || msg.toLowerCase().includes('validation') || msg.toLowerCase().includes('валидаци')) {
return 422;
}
if (msg.includes('500') || msg.toLowerCase().includes('server') || msg.toLowerCase().includes('сервер')) {
return 500;
}
if (msg.toLowerCase().includes('network') || msg.toLowerCase().includes('offline') || msg.toLowerCase().includes('сеть')) {
return 'offline';
}
return 'unknown';
}
export const StateView: React.FC<StateViewProps> = ({
state = 'success',
loadingLines = 4,
error,
errorCode,
onRetry,
retryText = 'Повторить',
emptyTitle = 'Нет данных',
emptyDescription = 'В данном разделе пока нет записей или примененные фильтры не дали результатов.',
emptyIcon = Inbox,
emptyActionText,
onEmptyAction,
loadingComponent,
emptyComponent,
errorComponent,
children,
className = '',
}) => {
// 1. Loading State
if (state === 'loading') {
if (loadingComponent) return <>{loadingComponent}</>;
return (
<div
role="status"
aria-label="Загрузка данных"
className={`p-6 space-y-3 animate-pulse bg-krona-surface rounded-xl border border-krona-border ${className}`}
>
<div className="flex items-center gap-3">
<Skeleton variant="circular" width={36} height={36} />
<div className="space-y-1.5 flex-1">
<Skeleton variant="text" width="40%" height={16} />
<Skeleton variant="text" width="60%" height={12} />
</div>
</div>
<Skeleton variant="rectangular" height={120} lines={loadingLines} />
</div>
);
}
// 2. Error State (403, 409, 422, 500, offline)
if (state === 'error' || Boolean(error)) {
if (errorComponent) return <>{errorComponent}</>;
const resolvedCode = errorCode || detectErrorCode(error);
const errorMessage = typeof error === 'string' ? error : error?.message;
let icon = <AlertCircle className="w-8 h-8 text-rose-600" />;
let title = 'Произошла ошибка при загрузке';
let description = errorMessage || 'Не удалось выполнить операцию. Пожалуйста, повторите попытку.';
let codeBadge = 'Ошибка';
switch (resolvedCode) {
case 403:
icon = <ShieldAlert className="w-8 h-8 text-amber-600" />;
title = '403: Доступ запрещен';
description =
errorMessage ||
'У вашей текущей роли недостаточно прав для просмотра данного ресурса или операции.';
codeBadge = '403 Forbidden';
break;
case 409:
icon = <AlertTriangle className="w-8 h-8 text-amber-500" />;
title = '409: Конфликт параллельного изменения';
description =
errorMessage ||
'Запись была изменена другим пользователем или процессом. Обновите данные перед сохранением.';
codeBadge = '409 Conflict';
break;
case 422:
icon = <AlertCircle className="w-8 h-8 text-rose-500" />;
title = '422: Ошибка валидации данных';
description =
errorMessage ||
'Переданные параметры не соответствуют доменным инвариантам или схемам полей.';
codeBadge = '422 Validation Error';
break;
case 500:
icon = <AlertCircle className="w-8 h-8 text-rose-600" />;
title = '500: Внутренняя ошибка сервера';
description =
errorMessage ||
'Сервис временно недоступен. Попробуйте повторить запрос через несколько секунд.';
codeBadge = '500 Server Error';
break;
case 'offline':
icon = <WifiOff className="w-8 h-8 text-slate-500" />;
title = 'Сетевая ошибка / Офлайн';
description = 'Проверьте интернет-соединение или статус локального хранилища IndexedDB.';
codeBadge = 'Network / Offline';
break;
}
return (
<div
role="alert"
aria-live="assertive"
className={`flex flex-col items-center justify-center p-8 text-center bg-rose-50/50 dark:bg-rose-950/10 border border-rose-200 dark:border-rose-900/40 rounded-xl my-4 space-y-3 ${className}`}
>
<div className="p-3 bg-white dark:bg-rose-900/20 rounded-2xl shadow-sm border border-rose-100 dark:border-rose-800/40">
{icon}
</div>
<div className="space-y-1 max-w-md">
<div className="inline-block px-2 py-0.5 mb-1 text-[11px] font-mono font-bold rounded-md bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300">
{codeBadge}
</div>
<h4 className="text-sm font-bold text-krona-primary">{title}</h4>
<p className="text-xs text-krona-secondary leading-relaxed">{description}</p>
</div>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="mt-2">
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
{retryText}
</Button>
)}
</div>
);
}
// 3. Empty State
if (state === 'empty') {
if (emptyComponent) return <>{emptyComponent}</>;
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={emptyIcon}
actionText={emptyActionText}
onAction={onEmptyAction}
className={className}
/>
);
}
// 4. Success / Idle -> Render children
return <>{children}</>;
};

View file

@ -0,0 +1,183 @@
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { StateView } from '../StateView';
import { AsyncBoundary, GuardedSubmitButton } from '../AsyncBoundary';
import { useSubmitGuard } from '../useSubmitGuard';
describe('Async States, Error Codes & Submit Guard (F23)', () => {
describe('StateView Component', () => {
it('1. Renders accessible skeleton when state is loading', () => {
render(
<StateView state="loading">
<div>Content Loaded</div>
</StateView>
);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByText('Content Loaded')).not.toBeInTheDocument();
});
it('2. Renders empty state with custom title and description', () => {
const handleCreate = vi.fn();
render(
<StateView
state="empty"
emptyTitle="Заказов пока нет"
emptyDescription="Создайте первый заказ через кнопку ниже"
emptyActionText="Создать заказ"
onEmptyAction={handleCreate}
>
<div>Content Loaded</div>
</StateView>
);
expect(screen.getByText('Заказов пока нет')).toBeInTheDocument();
expect(screen.getByText('Создайте первый заказ через кнопку ниже')).toBeInTheDocument();
const actionBtn = screen.getByRole('button', { name: 'Создать заказ' });
fireEvent.click(actionBtn);
expect(handleCreate).toHaveBeenCalledTimes(1);
});
it('3. Renders 403 Forbidden with security badge and explanation', () => {
render(
<StateView
state="error"
error="[403 Forbidden]: Отказано в доступе к ресурсу"
/>
);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('403 Forbidden')).toBeInTheDocument();
expect(screen.getByText('403: Доступ запрещен')).toBeInTheDocument();
});
it('4. Renders 409 Conflict with concurrent modification notice', () => {
render(
<StateView
state="error"
errorCode={409}
error="Конфликт версий документа"
/>
);
expect(screen.getByText('409 Conflict')).toBeInTheDocument();
expect(screen.getByText('409: Конфликт параллельного изменения')).toBeInTheDocument();
});
it('5. Renders 422 Validation Error with schema violation info', () => {
render(
<StateView
state="error"
errorCode={422}
error="Поле email содержит некорректный адрес"
/>
);
expect(screen.getByText('422 Validation Error')).toBeInTheDocument();
expect(screen.getByText('422: Ошибка валидации данных')).toBeInTheDocument();
});
it('6. Renders retry button and triggers callback on click', () => {
const handleRetry = vi.fn();
render(
<StateView
state="error"
error="Сетевая ошибка при загрузке"
onRetry={handleRetry}
retryText="Попробовать снова"
/>
);
const retryBtn = screen.getByRole('button', { name: /Попробовать снова/i });
expect(retryBtn).toBeInTheDocument();
fireEvent.click(retryBtn);
expect(handleRetry).toHaveBeenCalledTimes(1);
});
it('7. Renders children when state is success', () => {
render(
<StateView state="success">
<div>Успешно загруженные данные таблицы</div>
</StateView>
);
expect(screen.getByText('Успешно загруженные данные таблицы')).toBeInTheDocument();
});
});
describe('AsyncBoundary & Submit Guard', () => {
it('8. AsyncBoundary switches dynamically between loading, error, and child content', () => {
const { rerender } = render(
<AsyncBoundary isLoading={true}>
<div>Таблица заказов</div>
</AsyncBoundary>
);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByText('Таблица заказов')).not.toBeInTheDocument();
rerender(
<AsyncBoundary isLoading={false} isError={true} error="Ошибка сервера">
<div>Таблица заказов</div>
</AsyncBoundary>
);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.queryByText('Таблица заказов')).not.toBeInTheDocument();
rerender(
<AsyncBoundary isLoading={false} isError={false} isEmpty={false}>
<div>Таблица заказов</div>
</AsyncBoundary>
);
expect(screen.getByText('Таблица заказов')).toBeInTheDocument();
});
it('9. GuardedSubmitButton prevents double clicks during async execution', async () => {
let resolveAction: () => void;
const deferredPromise = new Promise<void>((resolve) => {
resolveAction = resolve;
});
const asyncAction = vi.fn().mockReturnValue(deferredPromise);
const TestComponent = () => {
const { isSubmitting, runGuarded } = useSubmitGuard();
return (
<GuardedSubmitButton
isSubmitting={isSubmitting}
onGuardedClick={async () => {
await runGuarded(asyncAction);
}}
>
Сохранить заказ
</GuardedSubmitButton>
);
};
render(<TestComponent />);
const btn = screen.getByRole('button', { name: /Сохранить заказ/i });
// First click: triggers asyncAction
await act(async () => {
fireEvent.click(btn);
});
expect(asyncAction).toHaveBeenCalledTimes(1);
// Second rapid click while promise is unresolved: MUST BE IGNORED
await act(async () => {
fireEvent.click(btn);
});
expect(asyncAction).toHaveBeenCalledTimes(1); // Still 1! Double click prevented
// Resolve pending action
await act(async () => {
resolveAction!();
});
});
});
});

View file

@ -14,3 +14,6 @@ export * from './Skeleton';
export * from './EmptyState'; export * from './EmptyState';
export * from './ErrorBoundary'; export * from './ErrorBoundary';
export * from './MaturityBadge'; export * from './MaturityBadge';
export * from './StateView';
export * from './AsyncBoundary';
export * from './useSubmitGuard';

View file

@ -0,0 +1,26 @@
import { useState, useCallback } from 'react';
/**
* Hook to guard against double-clicks and concurrent form submissions (F23).
*/
export function useSubmitGuard() {
const [isSubmitting, setIsSubmitting] = useState(false);
const runGuarded = useCallback(
async <T,>(action: () => Promise<T>): Promise<T | undefined> => {
if (isSubmitting) {
return undefined;
}
setIsSubmitting(true);
try {
const result = await action();
return result;
} finally {
setIsSubmitting(false);
}
},
[isSubmitting]
);
return { isSubmitting, runGuarded };
}