feat(architecture): composition root, async boundaries, safe snapshot import, and negative audit probes
This commit is contained in:
parent
d1ccc1c197
commit
257502db4a
12 changed files with 1462 additions and 99 deletions
|
|
@ -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 провайдеров.
|
||||||
|
|
|
||||||
308
src/features/__tests__/auditProbesNegativeMatrix.test.ts
Normal file
308
src/features/__tests__/auditProbesNegativeMatrix.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/services/__tests__/compositionRootContract.test.ts
Normal file
91
src/services/__tests__/compositionRootContract.test.ts
Normal 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
87
src/services/container.ts
Normal 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
4
src/services/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './container';
|
||||||
|
export * from './contracts';
|
||||||
|
export * from './hooks';
|
||||||
|
export * from './storage/indexedDb';
|
||||||
218
src/services/storage/__tests__/safeSnapshotImport.test.ts
Normal file
218
src/services/storage/__tests__/safeSnapshotImport.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -447,100 +447,256 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importDatabaseSnapshot(
|
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,
|
snapshot: DatabaseSnapshot,
|
||||||
targetTenantId?: string
|
options?: { targetTenantId?: string; mode?: 'overwrite' | 'merge' }
|
||||||
): Promise<void> {
|
): Promise<SnapshotDiff> {
|
||||||
if (!snapshot || typeof snapshot !== 'object') {
|
const validSnapshot = validateSnapshotSchema(snapshot);
|
||||||
throw new Error('Некорректный формат снимка базы данных (snapshot).');
|
|
||||||
}
|
|
||||||
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 effectiveTenant = options?.targetTenantId || getActiveGlobalTenantId();
|
||||||
|
const mode = options?.mode || 'overwrite';
|
||||||
|
|
||||||
if (!targetTenantId) {
|
let toCreate = 0;
|
||||||
// Global wipe & restore
|
let toUpdate = 0;
|
||||||
for (const store of DB_OBJECT_STORES) {
|
let toDelete = 0;
|
||||||
const storeObj = tx.objectStore(store);
|
let totalIncoming = 0;
|
||||||
await storeObj.clear();
|
const storesDiff: Record<string, { incoming: number; existing: number }> = {};
|
||||||
const items = snapshot.stores[store] || [];
|
|
||||||
for (const item of items) {
|
for (const storeName of DB_OBJECT_STORES) {
|
||||||
await storeObj.put(item);
|
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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Isolated tenant import: delete only target tenant records, keep other tenants safe
|
|
||||||
const recordsStore = tx.objectStore('records_data');
|
|
||||||
const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
|
|
||||||
for (const r of existingRecords) {
|
|
||||||
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
|
||||||
await recordsStore.delete(r.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newRecords = (snapshot.stores.records_data || []) as RecordEntity[];
|
|
||||||
const idMap = new Map<string, string>();
|
|
||||||
for (const r of newRecords) {
|
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
|
||||||
);
|
|
||||||
const idConflict = existingRecords.some(
|
|
||||||
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone || idConflict ? ulid() : r.id;
|
|
||||||
idMap.set(r.id, newId);
|
|
||||||
await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
toCreate += storeCreates;
|
||||||
const auditStore = tx.objectStore('audit_log');
|
toUpdate += storeUpdates;
|
||||||
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
|
||||||
for (const a of existingAudit) {
|
|
||||||
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
|
||||||
await auditStore.delete(String(a.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newAudit = (snapshot.stores.audit_log || []) as Record<string, unknown>[];
|
|
||||||
for (const a of newAudit) {
|
|
||||||
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
|
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone ? ulid() : (a.id as string);
|
|
||||||
await auditStore.put({
|
|
||||||
...a,
|
|
||||||
id: newId,
|
|
||||||
entityId: mappedEntityId,
|
|
||||||
tenant_id: targetTenantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata: entities, fields, layouts, views
|
if (mode === 'overwrite') {
|
||||||
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
const incomingKeys = new Set(
|
||||||
const s = tx.objectStore(store);
|
incomingItems.map((i) => (i.id ? String(i.id) : i.key ? String(i.key) : ''))
|
||||||
const items = (snapshot.stores[store] || []) as Record<string, unknown>[];
|
);
|
||||||
for (const item of items) {
|
for (const item of relevantExisting) {
|
||||||
if (!item.tenant_id || item.tenant_id === 'system') {
|
const key = item.id ? String(item.id) : item.key ? String(item.key) : '';
|
||||||
await s.put(item);
|
if (key && !incomingKeys.has(key)) {
|
||||||
} else {
|
toDelete++;
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
|
||||||
await s.put({ ...item, id: newId, tenant_id: targetTenantId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.done;
|
return {
|
||||||
|
toCreate,
|
||||||
|
toUpdate,
|
||||||
|
toDelete,
|
||||||
|
totalIncoming,
|
||||||
|
storesDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDatabaseSnapshot(
|
||||||
|
snapshot: DatabaseSnapshot,
|
||||||
|
options?: string | ImportSnapshotOptions
|
||||||
|
): Promise<{ backupCreated: boolean; diff: SnapshotDiff }> {
|
||||||
|
const opts: ImportSnapshotOptions =
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!targetTenantId) {
|
||||||
|
// Global wipe & restore
|
||||||
|
for (const store of DB_OBJECT_STORES) {
|
||||||
|
const storeObj = tx.objectStore(store);
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
await storeObj.clear();
|
||||||
|
}
|
||||||
|
const items = validSnapshot.stores[store] || [];
|
||||||
|
for (const item of items) {
|
||||||
|
await storeObj.put(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Isolated tenant import
|
||||||
|
const recordsStore = tx.objectStore('records_data');
|
||||||
|
const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||||
|
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
for (const r of existingRecords) {
|
||||||
|
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||||
|
await recordsStore.delete(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecords = (validSnapshot.stores.records_data || []) as RecordEntity[];
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
for (const r of newRecords) {
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
||||||
|
);
|
||||||
|
const idConflict = existingRecords.some(
|
||||||
|
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone || idConflict ? ulid() : r.id;
|
||||||
|
idMap.set(r.id, newId);
|
||||||
|
await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
const auditStore = tx.objectStore('audit_log');
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
||||||
|
for (const a of existingAudit) {
|
||||||
|
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||||
|
await auditStore.delete(String(a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newAudit = (validSnapshot.stores.audit_log || []) as Record<string, unknown>[];
|
||||||
|
for (const a of newAudit) {
|
||||||
|
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone ? ulid() : (a.id as string);
|
||||||
|
await auditStore.put({
|
||||||
|
...a,
|
||||||
|
id: newId,
|
||||||
|
entityId: mappedEntityId,
|
||||||
|
tenant_id: targetTenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata: entities, fields, layouts, views, settings
|
||||||
|
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
||||||
|
const s = tx.objectStore(store);
|
||||||
|
const items = (validSnapshot.stores[store] || []) as Record<string, unknown>[];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.tenant_id || item.tenant_id === 'system') {
|
||||||
|
await s.put(item);
|
||||||
|
} else {
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
||||||
|
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;
|
||||||
|
} 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> {
|
||||||
|
|
|
||||||
93
src/shared/ui/AsyncBoundary.tsx
Normal file
93
src/shared/ui/AsyncBoundary.tsx
Normal 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
195
src/shared/ui/StateView.tsx
Normal 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}</>;
|
||||||
|
};
|
||||||
183
src/shared/ui/__tests__/asyncStateHandling.test.tsx
Normal file
183
src/shared/ui/__tests__/asyncStateHandling.test.tsx
Normal 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!();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
26
src/shared/ui/useSubmitGuard.ts
Normal file
26
src/shared/ui/useSubmitGuard.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue