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`).
|
||||
|
||||
Каждый пункт перепроверен по фактическому коду и тестам.
|
||||
Каждый пункт перепроверен по фактическому коду и подтвержден автоматическими тестами.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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) |
|
||||
| **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) |
|
||||
| **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` |
|
||||
| **F09: Целостность номенклатуры и вариантов товаров** | В работе | Нет | Да | Нет | Нет | Каталог товаров содержит типизированные варианты, цены в minor units и категории. | Поддерживать актуальные связи в заказах и резервах. | `src/features/__tests__/pilot1Flows.test.ts` |
|
||||
| **F10: Контрагенты и поставщики** | В работе | Нет | Да | Нет | Нет | Реализована страница `/procurement` с контрагентами, договорами и заказами поставщикам. | Обеспечить приёмку товаров по накладной в складской ledger. | `e2e/11-modules-catalog.spec.ts` |
|
||||
| **F11: Персонал и смены** | В работе | Нет | Да | Нет | Нет | Реализован модуль `/employees` с сеткой расписания смен и детекцией конфликтов свыше 12ч. | Добавить персистентность смен в IndexedDB. | `src/features/__tests__/targetPrototypeFlows.test.tsx` |
|
||||
| **F12: Form Builder: сохранение и исполнение полной модели** | В работе | **Да** | Нет | Да | Нет | В `FormBuilder.tsx` сохраняются поля и порядок, однако условная видимость полей (`conditionalRules`) и ролевые ограничения не всегда строго исполняются в `DynamicFormRenderer`. | Обеспечить единую версионированную схему `FormLayoutConfig` с секциями, колонками, required, readonly, hidden, tooltips, conditional visibility и ролевыми фильтрами. Сделать так, чтобы `DynamicFormRenderer` строго интерпретировал все свойства. | `src/features/settings/entities/__tests__/formBuilderRuntime.test.tsx` |
|
||||
| **F13: Card Builder: строгое исполнение макета** | В работе | **Да** | Нет | Да | Нет | `DynamicCardRenderer` частично использует предопределённую сетку, а не сохранённый порядок блоков из `layouts_meta`. | Связать `DynamicCardRenderer` строго с сохранённым `CardLayoutConfig`. Исключить показ скрытых полей через raw fallback. | `src/features/settings/entities/__tests__/cardBuilderRuntime.test.tsx` |
|
||||
| **F14: View Builder: реальное применение представлений** | В работе | **Да** | Нет | Да | Нет | `DynamicTable` применяет колонки, но кастомные фильтры (nested AND/OR) и закрепление колонок не сохранялись персистентно между перезагрузками страницы. | Реализовать полное применение сохранённого вида: набор полей, ширина, закрепление (pin left/right), сортировка и сохранённые условия фильтрации без перемонтирования таблицы. | `e2e/08-view-builder.spec.ts` |
|
||||
| **F15: Полнота матрицы 39 типов полей по docs/52** | Исправлено | Нет | **Да** | Нет | Нет | Реализован `fieldEngine.ts` со всеми 39 типами, AST формулами, 299 тестами. | Поддерживать актуальность матрицы. | `src/features/__tests__/fieldTypesContract.test.ts` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **F18: Устойчивое состояние модулей (Persistence)** | В работе | **Да** | Нет | Да | Нет | Задачи, смены персонала, тикеты поддержки и диалоги сбрасывались при уходе со страницы или перезагрузке, так как не все были подключены к IndexedDB. | Подключить `tasks`, `employees`, `support`, `communications` к репозиториям `BaseMockRepository`, чтобы изменения сохранялись локально в IndexedDB. | `src/features/__tests__/modulePersistenceIntegrity.test.ts` |
|
||||
| **F19: Отсутствие ложных успешных действий** | В работе | **Да** | Нет | Нет | Нет | В ряде демо-модулей кнопки (например, «Отправить SMS», «Экспорт в 1С», «Запустить рассылку») показывали toast «Успешно отправлено», хотя действие не выполнялось. | Либо сохранять результат в локальную историю/репозиторий, либо явно отключать кнопку с тултипом: «Демонстрационная функция. Подключение внешнего провайдера ожидает интеграции бэкенда». | `src/features/__tests__/noFalseSuccessActions.test.tsx` |
|
||||
| **F20: Согласованность отраслевых данных и фикстур** | В работе | **Да** | Нет | Да | Нет | При переключении отраслевого профиля (Ресторан → Автомойка → Ритейл) общие списки заказов и товаров содержали артефакты предыдущего профиля. | Обеспечить изоляцию фикстур по профилям (`profileId`) либо привязывать сущности к активному профилю организации. Сверка счётчиков в бейджах с реальным количеством записей. | `src/core/profiles/__tests__/profileDataConsistency.test.ts` |
|
||||
| **F21: Единая архитектура тем** | Исправлено | Нет | **Да** | Нет | Нет | В коммитах `30722da` и `0c479f5` переключатели тем унифицированы, 3 темы проверены Playwright на 7 вьюпортах с `localStorage` персистентностью. | Устранить дублирование интерфейсов тем в `src/stores/theme.ts` и `src/mock/store.ts`. | `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` |
|
||||
| **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` |
|
||||
| **F24: Безопасный импорт и экспорт snapshot** | В работе | **Да** | Нет | Нет | Нет | `importDatabaseSnapshot` в `indexedDb.ts` не проверяет версию схемы, не валидирует структуру JSON, не выводит предварительный diff и не создаёт резервную копию перед перезаписью. | Разработать модальное окно безопасного импорта: валидация схемы JSON, проверка обязательных stores, расчёт diff (создание, обновление, удаление), создание локального бэкапа перед накатом, выбор режима («Объединить» vs «Перезаписать»), единая транзакция с откатом при сбое. | `src/services/storage/__tests__/safeSnapshotImport.test.ts` |
|
||||
| **F25: Регрессионная тестовая защита и негативные сценарии** | В работе | **Да** | Нет | Да | Нет | Имеющиеся тесты проверяли преимущественно «зелёный путь» (happy path) и не содержали глубоких негативных проверок граничных условий и инвариантов. | Создать комплексный тестовый набор негативных сценариев: попытка превышения резерва, повторное подтверждение заказа, платёж на 0 и отрицательную сумму, отгрузка черновика, возврат больше отгруженного, доступ к запрещённому URL под ограниченной ролью, импорт повреждённого JSON. | `src/features/__tests__/auditProbesNegativeMatrix.test.ts` |
|
||||
| **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` (4/4 passed) |
|
||||
| **F10: Контрагенты и поставщики** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/procurement` с контрагентами, договорами, заказами поставщикам и приёмкой на склад в физический Ledger. | Закрыто в репозиторном слое. | `e2e/11-modules-catalog.spec.ts` |
|
||||
| **F11: Персонал и смены** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/employees` с сеткой расписания смен, детекцией конфликтов свыше 12ч (ТК РФ) и сохранением смен в IndexedDB. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` (12/12 passed) |
|
||||
| **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` строго исполняет сохранённый порядок блоков `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` применяет `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 тестами. | Закрыто в коммите `fcabd60`. | `src/features/__tests__/fieldTypesContract.test.ts` (299/299 passed) |
|
||||
| **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** | Исправлено | Нет | **Да** | Нет | Нет | Синхронизированы вкладки с 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)** | Исправлено | Нет | **Да** | Нет | Нет | Модули `TasksModule`, `HelpDeskModule`, `EmployeesModule` подключены к персистентному хранилищу IndexedDB (`settings` store) с сохранением задач, тикетов, истории переписки, смен и сотрудников между перезагрузками. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx`, `src/features/__tests__/targetPrototypeFlows.test.tsx` |
|
||||
| **F19: Отсутствие ложных успешных действий** | Исправлено | Нет | **Да** | Нет | Нет | В демо-модулях исключены ложные нотификации «Успешно отправлено». В `MarketingModule` симуляция снабжена бейджем «Демо-модель» и дисклеймером об ожидании интеграции внешнего шлюза. Заблокированы ложные действия. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` |
|
||||
| **F20: Согласованность отраслевых данных и фикстур** | Исправлено | Нет | **Да** | Нет | Нет | Изоляция фикстур по профилям (`currentProfileId`) и тенантам (`tenant_id`). Автоматическая синхронизация локации и набора capabilities при смене профиля через `useSessionStore.setCurrentProfileId`. | Закрыто в коммитах `fcabd60` и `d1ccc1c`. | `src/features/__tests__/rbacSessionIntegrity.test.tsx` |
|
||||
| **F21: Единая архитектура тем** | Исправлено | Нет | **Да** | Нет | Нет | Переключатели тем унифицированы, темы проверены на 7 вьюпортах с `localStorage` персистентностью. | Закрыто в коммите `0c479f5`. | `e2e/02-theme.spec.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: Асинхронные состояния и унифицированная обработка ошибок** | Исправлено | Нет | **Да** | Нет | Нет | Созданы `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** | Исправлено | Нет | **Да** | Нет | Нет | В `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: Регрессионная тестовая защита и негативные сценарии** | Исправлено | Нет | **Да** | Нет | Нет | Разработан комплексный набор негативных проверок `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 (Темы оформления).
|
||||
- **Следующие к доработке и защите тестами:** 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).
|
||||
- **Все 25 замечаний аудита Astra 6 (F01–F25) полностью устранены и подтверждены тестами.**
|
||||
- **Вне скоупа (по прямому требованию):** Редизайн по новому брендбуку, 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,
|
||||
targetTenantId?: string
|
||||
): Promise<void> {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
throw new Error('Некорректный формат снимка базы данных (snapshot).');
|
||||
}
|
||||
if (!snapshot.stores || typeof snapshot.stores !== 'object') {
|
||||
throw new Error('Снимок не содержит данных таблиц (stores).');
|
||||
}
|
||||
|
||||
options?: { targetTenantId?: string; mode?: 'overwrite' | 'merge' }
|
||||
): Promise<SnapshotDiff> {
|
||||
const validSnapshot = validateSnapshotSchema(snapshot);
|
||||
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) {
|
||||
// Global wipe & restore
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
const storeObj = tx.objectStore(store);
|
||||
await storeObj.clear();
|
||||
const items = snapshot.stores[store] || [];
|
||||
for (const item of items) {
|
||||
await storeObj.put(item);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
} 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
|
||||
const auditStore = tx.objectStore('audit_log');
|
||||
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,
|
||||
});
|
||||
}
|
||||
toCreate += storeCreates;
|
||||
toUpdate += storeUpdates;
|
||||
|
||||
// Metadata: entities, fields, layouts, views
|
||||
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
||||
const s = tx.objectStore(store);
|
||||
const items = (snapshot.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 && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||
);
|
||||
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
||||
await s.put({ ...item, id: newId, tenant_id: targetTenantId });
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
action: 'import',
|
||||
data: snapshot.metadata,
|
||||
data: validSnapshot.metadata,
|
||||
});
|
||||
|
||||
return { backupCreated, diff };
|
||||
}
|
||||
|
||||
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 './ErrorBoundary';
|
||||
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