diff --git a/BUG_FIX_PROGRESS.md b/BUG_FIX_PROGRESS.md index 430a417..c3f724b 100644 --- a/BUG_FIX_PROGRESS.md +++ b/BUG_FIX_PROGRESS.md @@ -1,6 +1,6 @@ # Журнал исправления проблем -Обновлено: 2026-07-15 (Europe/Moscow). +Обновлено: 2026-07-17 (Europe/Moscow). ## Область и неизменяемые данные @@ -56,9 +56,9 @@ | 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. | | 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. | | 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. | -| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. | +| 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. | | 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. | -| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. | +| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. | | 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. | | 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. | | 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. | @@ -324,8 +324,8 @@ маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают components, домены, JDBC URL или credentials. - AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и - `docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего - домена явно оставлено проблеме № 13. + `docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials + существующего домена было оставлено проблеме № 13 и устранено следующим этапом. - В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись и Kustomize не запускался. Для production обязательны directory mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness / @@ -343,18 +343,92 @@ | Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. | | `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. | +### № 13 — полная и повторяемая синхронизация tenant-конфигурации + +- `TenantLifecycleService.synchronizeFromPersistedConfig()` валидирует весь входной список, + отклоняет нормализованные дубли до изменения runtime/readiness и сравнивает `name`, `domain`, + `url`, `username`, `password`. Новый или изменённый tenant проходит candidate prepare, + проверку соединения, Flyway и атомарный swap; persisted-путь не вызывает `TenantConfigStore`. +- Hash mounted `tenants.json` становится применённым только после успешного разбора и полной + lifecycle-синхронизации. Ошибка сохраняет прежний hash и повторяется с экспоненциальной + задержкой 30–300 секунд; новая ревизия обходит backoff. Required missing/read/empty также + наблюдаемы через readiness и русский лог без credentials, hash или fingerprint. +- Перед `POST`/`DELETE` watcher под общим reentrant monitor применяет ещё не обработанный + semantic baseline. Ошибка чтения/разбора/sync возвращает безопасный `503` до начала мутации. + `TenantLifecycleMutationResult` возвращает controller-у immutable-квитанцию персистенции, + но не раскрывает её в HTTP. +- Snapshot fence ставится только для реально сохранённой изменённой версии + (`persisted && changed`) и сравнивает нормализованные `previous/committed` snapshots, а не + сырые JSON-хеши. Поэтому H0/H1 не откатывают две быстрые мутации до H2, форматирование JSON + не влияет на решение, merged snapshot другого pod применяется, а local/no-op receipt ничего + не блокирует. +- Ошибка публикации readiness после успешного swap больше не помечает активный новый pool как + `FAILED`: повтор проверяет действующее соединение и восстанавливает readiness без второго + Flyway/swap. Старый pool передаётся на drain сразу после успешной атомарной публикации. +- PostgreSQL 16.3/Testcontainers подтверждает сохранность прежнего route при неверных + credentials и Flyway checksum, успешную замену на валидную БД, работу in-flight соединения, + drain старого pool и отсутствие повторной записи Secret в persisted-пути. +- Windows/Docker portability TLS-теста стабилизирована явным `https://localhost:`, + совпадающим с SAN тестового сертификата; production hostname verification не ослаблялась. +- AutoUpdateDocs повторно синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и + `docs/INFRASTRUCTURE.md` с финальной receipt/snapshot-схемой. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `TenantConfigWatcherTest,TenantLifecycleServiceTest,DatabaseControllerTest,TenantRoutingDataSourceTest` | 53 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `TenantLifecyclePostgreSqlIntegrationTest` | 2 теста, 0 failures, 0 errors, 0 skipped; реальный PostgreSQL 16.3/Testcontainers. | +| `KubernetesTenantSecretUpdaterTest` | 21 тест, 0 failures, 0 errors, 0 skipped; TLS, CAS, reconciliation и компенсация. | +| Полный `mvn -Dapi.version=1.44 test` | 200 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. | +| `git diff --check` | Успешно. | +| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями; миграционные файлы не изменялись. | + +### № 11 — достоверный статус проверки конфликтов на дашборде + +- `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date` без + `toISOString()`. `currentWeekDateRange()` создаёт отдельный понедельник и вычисляет + воскресенье как `понедельник + 6 дней`, поэтому начало месяца и переход года не мутируют + исходную дату повторно. +- Расписания кафедр загружаются независимо через `Promise.allSettled`. Результат хранит число + ответивших кафедр и одно из состояний: `COMPLETE`, `PARTIAL` или `NOT_RUN`; технические + причины отказов не входят в UI-модель. +- `conflictCheckPresentation()` проверяет диапазон, количество конфликтов и согласованность + счётчиков fail-closed. Зелёная карточка разрешена только для `COMPLETE`, когда проверены все + кафедры и конфликтов нет. `PARTIAL` всегда показывает предупреждение и не скрывает найденные + по доступной части конфликты, а полный отказ отображается как «Проверка не выполнена». +- Карточки используют русские безопасные тексты, экранирование, явные success/warning/error + состояния, период и число проверенных кафедр. Контейнер получил live-region и `aria-busy`, + кнопка — `type="button"`; после ошибки начальной загрузки «Перепроверить» повторно получает + справочники, а не анализирует пустой список. +- Тот же локальный formatter устранит сдвиг даты сегодняшнего расписания до 03:00 по Москве. + Остальная часть общей timezone-проблемы остаётся в объёме № 29. +- Добавлены dependency-free тесты на встроенном `node:test` и воспроизводимые npm-команды. + AutoUpdateDocs синхронизировал структуру, контракт Red Zone и команды проверки в + `docs/FRONTEND.md`. + +Фактические проверки этапа: + +| Команда | Результат | +|---|---| +| `npm test` в `frontend/` | 12 тестов, 12 passed, 0 failed; локальная полночь `2026-07-02 00:15 Europe/Moscow`, диапазон `2026-06-29…2026-07-05`, переход года и состояния отказов. | +| `npm run check` в `frontend/` | Синтаксис `dashboard-conflicts.js` и `dashboard.js`, затем 12 тестов — успешно. | +| Рекурсивный `node --check` для `frontend/**/*.js` и `frontend/**/*.mjs` | 25 файлов, ошибок нет. | +| Полный backend-регресс | Backend на этапе № 11 не изменялся; последний полный прогон этапа № 13 остаётся зелёным: 200/0/0/0. | +| `git diff --check` | Успешно. | +| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. | + ## Точка продолжения -Текущий этап: **№ 13 — полная синхронизация изменённых tenant credentials/URL watcher-ом**. +Текущий этап: **№ 12 — access JWT и удалённый JavaScript в одном origin**. Следующая операция: -1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только - набор доменов; -2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без - повторной записи Secret; -3. считать hash применённым только после полной успешной синхронизации и не подтверждать - произвольное содержимое mount через `refreshHash()` после API-мутации; -4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего - файла после ошибки и межподового merged snapshot; -5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13. +1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и + inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации; +2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly + refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload; +3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без + `unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки; +4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP + перед AutoUpdateDocs и изменением статуса № 12. diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java index 7d067ac..64b205b 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java @@ -3,6 +3,7 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.magistr.app.config.tenant.health.TenantReadinessRegistry; +import com.magistr.app.service.TenantLifecycleException; import com.magistr.app.service.TenantLifecycleService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,24 +11,35 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HexFormat; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.function.LongSupplier; /** - * Периодически перечитывает tenants.json (mounted Secret). - * Если Secret был обновлён через K8s API, этот компонент - * подхватит изменения и синхронизирует in-memory datasource'ы. - * - * Также отвечает за инициализацию БД (init.sql) для новых тенантов. + * Периодически применяет конфигурацию тенантов из смонтированного tenants.json. + * Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса. */ @Component public class TenantConfigWatcher { private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class); + private static final String MISSING_FAILURE_KEY = "missing"; + private static final String READ_FAILURE_KEY = "read"; + + static final long RETRY_BASE_DELAY_MILLIS = 30_000L; + static final long RETRY_MAX_DELAY_MILLIS = 300_000L; private final TenantLifecycleService tenantLifecycleService; private final TenantReadinessRegistry readinessRegistry; @@ -39,9 +51,23 @@ public class TenantConfigWatcher { @Value("${app.tenants.config-required:false}") private boolean tenantsConfigRequired; - // Хеш последнего прочитанного конфига — чтобы не перезагружать зря + /** Хеш означает только полностью применённое содержимое файла. */ private String lastConfigHash = ""; + private ConfigSnapshot lastAppliedSnapshot; + + private String failedRevisionKey = ""; + private int consecutiveFailures; + private long retryNotBeforeMillis; + + /** + * Fence хранит семантические снимки, а не байтовые представления JSON. + * Поэтому форматирование файла не может ошибочно снять или установить fence. + */ + private ConfigSnapshot expectedCommittedSnapshot; + private final Set deferredSnapshots = new LinkedHashSet<>(); + private boolean configurationUnavailable; + private LongSupplier currentTimeMillis = System::currentTimeMillis; public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService, TenantReadinessRegistry readinessRegistry) { @@ -49,73 +75,135 @@ public class TenantConfigWatcher { this.readinessRegistry = readinessRegistry; } - /** - * Каждые 30 секунд проверяет, изменился ли tenants.json. - */ @Scheduled(fixedDelay = 30_000, initialDelay = 30_000) public void watchForChanges() { tenantLifecycleService.executeSerialized(this::watchForChangesSerialized); } private void watchForChangesSerialized() { + long now = currentTimeMillis.getAsLong(); + Path path = Path.of(tenantsConfigPath); + if (!Files.exists(path)) { + if (tenantsConfigRequired) { + handleRequiredMissingFile(now); + } + return; + } + + if (isRetryDeferred(READ_FAILURE_KEY, now)) { + logDeferredRetry(now); + return; + } + + String failureKey = READ_FAILURE_KEY; try { - File file = new File(tenantsConfigPath); - if (!file.exists()) { - if (tenantsConfigRequired) { - readinessRegistry.markConfigurationFailure(); - if (!configurationUnavailable) { - log.error("Обязательный файл конфигурации тенантов недоступен"); - } - configurationUnavailable = true; - } + String content = Files.readString(path, StandardCharsets.UTF_8); + String hash = configHash(content); + failureKey = hash; + Projection projection = parseProjection(content, hash); + + // Даже неизменившееся содержимое сначала разбирается: fence никогда не + // принимает решение по сырым байтам до проверки структуры JSON. + if (hash.equals(lastConfigHash) && !configurationUnavailable) { + clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot()); + return; + } + if (isRetryDeferred(hash, now)) { + logDeferredRetry(now); + return; + } + if (!hash.equals(failedRevisionKey)) { + resetRetryState(); + } + if (shouldDefer(projection.snapshot())) { return; } - String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - String hash = configHash(content); - - if (hash.equals(lastConfigHash) && !configurationUnavailable) { - return; // Ничего не изменилось - } - - log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash); - List newTenants = objectMapper.readValue(content, new TypeReference<>() {}); - syncTenants(newTenants); - lastConfigHash = hash; - configurationUnavailable = false; - - } catch (Exception e) { - readinessRegistry.markConfigurationFailure(); - configurationUnavailable = true; - log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName()); - log.debug("Технические детали синхронизации конфига тенантов", e); + log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов"); + syncTenants(projection.tenants()); + markApplied(projection); + } catch (Exception failure) { + recordFailure(failureKey, failure); } } /** - * Обновляет хеш конфига после ручного обновления Secret с этого же пода. + * Перед API-мутацией приводит runtime к актуальной безопасной проекции файла. + * Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться + * с неизвестного baseline. */ - public void refreshHash() { - tenantLifecycleService.executeSerialized(this::refreshHashSerialized); + public void prepareForApiMutation() { + tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized); } - private void refreshHashSerialized() { - try { - File file = new File(tenantsConfigPath); - if (file.exists()) { - String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - lastConfigHash = configHash(content); - } else if (tenantsConfigRequired) { - readinessRegistry.markConfigurationFailure(); - configurationUnavailable = true; + private void prepareForApiMutationSerialized() { + Path path = Path.of(tenantsConfigPath); + if (!Files.exists(path)) { + if (!tenantsConfigRequired) { + return; } - } catch (Exception e) { - readinessRegistry.markConfigurationFailure(); - configurationUnavailable = true; - log.warn("Не удалось обновить хеш конфига тенантов: errorType={}", - e.getClass().getSimpleName()); - log.debug("Технические детали обновления хеша тенантов", e); + TenantLifecycleException failure = new TenantLifecycleException( + "Обязательный файл конфигурации тенантов недоступен" + ); + recordFailure(MISSING_FAILURE_KEY, failure); + throw failure; } + + String failureKey = READ_FAILURE_KEY; + try { + String content = Files.readString(path, StandardCharsets.UTF_8); + String hash = configHash(content); + failureKey = hash; + Projection projection = parseProjection(content, hash); + + if (hash.equals(lastConfigHash) && !configurationUnavailable) { + clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot()); + return; + } + if (shouldDefer(projection.snapshot())) { + return; + } + + syncTenants(projection.tenants()); + markApplied(projection); + } catch (Exception failure) { + recordFailure(failureKey, failure); + if (failure instanceof TenantLifecycleException lifecycleFailure) { + throw lifecycleFailure; + } + throw new TenantLifecycleException( + "Не удалось подготовить актуальную конфигурацию тенантов перед изменением", + failure + ); + } + } + + /** + * Устанавливает snapshot fence только после подтверждённой записи в общее + * хранилище. Локальные и no-op операции не создают ложных ожиданий от mount. + */ + public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) { + Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана"); + tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt)); + } + + private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) { + if (!receipt.persisted() || !receipt.changed()) { + return; + } + + ConfigSnapshot previous = snapshotOf(receipt.previousTenants()); + ConfigSnapshot committed = snapshotOf(receipt.committedTenants()); + + if (lastAppliedSnapshot != null) { + deferredSnapshots.add(lastAppliedSnapshot); + } + if (expectedCommittedSnapshot != null) { + deferredSnapshots.add(expectedCommittedSnapshot); + } + deferredSnapshots.add(previous); + deferredSnapshots.remove(committed); + expectedCommittedSnapshot = committed; } static String configHash(String content) { @@ -127,11 +215,158 @@ public class TenantConfigWatcher { } } - /** - * Синхронизирует in-memory тенантов с конфигом из файла. - */ - private void syncTenants(List newTenants) { - tenantLifecycleService.synchronizeFromPersistedConfig(newTenants); + private Projection parseProjection(String content, String hash) throws IOException { + List tenants = objectMapper.readValue(content, new TypeReference<>() { }); + if (tenants == null) { + throw new IllegalArgumentException( + "Корневое значение конфигурации тенантов должно быть массивом" + ); + } + if (tenantsConfigRequired && tenants.isEmpty()) { + throw new IllegalArgumentException( + "Обязательная конфигурация тенантов не может быть пустой" + ); + } + return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants)); } + private ConfigSnapshot snapshotOf(List tenants) { + Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан"); + List normalized = new ArrayList<>(tenants.size()); + for (TenantConfig tenant : tenants) { + Objects.requireNonNull(tenant, "Конфигурация тенанта не задана"); + String domain = normalizeDomain(tenant.getDomain()); + String name = tenant.getName() == null || tenant.getName().isBlank() + ? domain + : tenant.getName().trim(); + String url = tenant.getUrl() == null ? null : tenant.getUrl().trim(); + normalized.add(new NormalizedTenantConfig( + name, + domain, + url, + tenant.getUsername(), + tenant.getPassword() + )); + } + normalized.sort(NormalizedTenantConfig.ORDER); + return new ConfigSnapshot(normalized); + } + + private String normalizeDomain(String domain) { + return domain == null ? null : domain.trim().toLowerCase(Locale.ROOT); + } + + private void syncTenants(List tenants) { + tenantLifecycleService.synchronizeFromPersistedConfig(tenants); + } + + private boolean shouldDefer(ConfigSnapshot snapshot) { + return expectedCommittedSnapshot != null && deferredSnapshots.contains(snapshot); + } + + private void clearFenceWhenExpectedSnapshotIsAlreadyApplied(ConfigSnapshot snapshot) { + if (snapshot.equals(expectedCommittedSnapshot)) { + clearSnapshotFence(); + } + } + + private void markApplied(Projection projection) { + lastConfigHash = projection.hash(); + lastAppliedSnapshot = projection.snapshot(); + configurationUnavailable = false; + resetRetryState(); + clearSnapshotFence(); + } + + private void handleRequiredMissingFile(long now) { + if (isRetryDeferred(MISSING_FAILURE_KEY, now)) { + logDeferredRetry(now); + return; + } + recordFailure( + MISSING_FAILURE_KEY, + new TenantLifecycleException("Обязательный файл конфигурации тенантов недоступен") + ); + } + + private boolean isRetryDeferred(String failureKey, long now) { + return failureKey.equals(failedRevisionKey) && now < retryNotBeforeMillis; + } + + private void logDeferredRetry(long now) { + log.debug("Повторная синхронизация tenants.json отложена ещё на {} мс", + Math.max(0L, retryNotBeforeMillis - now)); + } + + private void recordFailure(String failureKey, Exception failure) { + readinessRegistry.markConfigurationFailure(); + configurationUnavailable = true; + long retryDelay = registerFailure(failureKey); + log.error("Ошибка синхронизации конфигурации тенантов: попытка={}, " + + "следующий повтор не ранее чем через {} мс, errorType={}", + consecutiveFailures, + retryDelay, + failure.getClass().getSimpleName()); + log.debug("Технические детали синхронизации конфигурации тенантов", failure); + } + + private long registerFailure(String failureKey) { + if (failureKey.equals(failedRevisionKey) && consecutiveFailures > 0) { + consecutiveFailures = Math.min(consecutiveFailures + 1, 31); + } else { + failedRevisionKey = failureKey; + consecutiveFailures = 1; + } + long delay = retryDelayMillis(consecutiveFailures); + retryNotBeforeMillis = safeAdd(currentTimeMillis.getAsLong(), delay); + return delay; + } + + static long retryDelayMillis(int failureCount) { + long delay = RETRY_BASE_DELAY_MILLIS; + for (int attempt = 1; attempt < failureCount && delay < RETRY_MAX_DELAY_MILLIS; attempt++) { + delay = Math.min(delay * 2, RETRY_MAX_DELAY_MILLIS); + } + return delay; + } + + private long safeAdd(long value, long increment) { + return value > Long.MAX_VALUE - increment ? Long.MAX_VALUE : value + increment; + } + + private void resetRetryState() { + failedRevisionKey = ""; + consecutiveFailures = 0; + retryNotBeforeMillis = 0L; + } + + private void clearSnapshotFence() { + expectedCommittedSnapshot = null; + deferredSnapshots.clear(); + } + + private record Projection(String hash, List tenants, ConfigSnapshot snapshot) { + } + + private record ConfigSnapshot(List tenants) { + private ConfigSnapshot { + tenants = List.copyOf(tenants); + } + } + + private record NormalizedTenantConfig( + String name, + String domain, + String url, + String username, + String password + ) { + private static final Comparator NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo); + private static final Comparator ORDER = + Comparator.comparing(NormalizedTenantConfig::domain, NULL_SAFE_TEXT) + .thenComparing(NormalizedTenantConfig::name, NULL_SAFE_TEXT) + .thenComparing(NormalizedTenantConfig::url, NULL_SAFE_TEXT) + .thenComparing(NormalizedTenantConfig::username, NULL_SAFE_TEXT) + .thenComparing(NormalizedTenantConfig::password, NULL_SAFE_TEXT); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java index 48ecbc1..df34969 100755 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -7,6 +7,7 @@ import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantRoutingDataSource; import com.magistr.app.model.Role; import com.magistr.app.service.TenantLifecycleException; +import com.magistr.app.service.TenantLifecycleMutationResult; import com.magistr.app.service.TenantLifecycleService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -90,8 +91,11 @@ public class DatabaseController { public ResponseEntity> addTenant(@RequestBody TenantConfig config) { try { return tenantLifecycleService.executeSerialized(() -> { - TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config); - tenantConfigWatcher.refreshHash(); + tenantConfigWatcher.prepareForApiMutation(); + TenantLifecycleMutationResult mutation = + tenantLifecycleService.addOrUpdateTenantWithReceipt(config); + tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt()); + TenantConfig saved = mutation.tenant(); Map result = new HashMap<>(); result.put("success", true); result.put("message", "Тенант '" + saved.getDomain() + "' добавлен"); @@ -111,8 +115,11 @@ public class DatabaseController { public ResponseEntity> removeTenant(@PathVariable("domain") String domain) { try { return tenantLifecycleService.executeSerialized(() -> { - TenantConfig removed = tenantLifecycleService.removeTenant(domain); - tenantConfigWatcher.refreshHash(); + tenantConfigWatcher.prepareForApiMutation(); + TenantLifecycleMutationResult mutation = + tenantLifecycleService.removeTenantWithReceipt(domain); + tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt()); + TenantConfig removed = mutation.tenant(); Map result = new HashMap<>(); result.put("success", true); result.put("message", "Тенант '" + removed.getDomain() + "' удалён"); diff --git a/backend/src/main/java/com/magistr/app/service/TenantLifecycleMutationResult.java b/backend/src/main/java/com/magistr/app/service/TenantLifecycleMutationResult.java new file mode 100644 index 0000000..da8ea0b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TenantLifecycleMutationResult.java @@ -0,0 +1,41 @@ +package com.magistr.app.service; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantSecretUpdateReceipt; + +import java.util.Objects; + +/** + * Результат успешной lifecycle-мутации тенанта вместе с квитанцией персистенции. + * + *

Конфигурация копируется при создании и чтении, чтобы вызывающий код не мог изменить + * уже активированный runtime-снимок через изменяемый DTO.

+ */ +public final class TenantLifecycleMutationResult { + + private final TenantConfig tenant; + private final TenantSecretUpdateReceipt receipt; + + public TenantLifecycleMutationResult(TenantConfig tenant, TenantSecretUpdateReceipt receipt) { + this.tenant = copy(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")); + this.receipt = Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана"); + } + + public TenantConfig tenant() { + return copy(tenant); + } + + public TenantSecretUpdateReceipt receipt() { + return receipt; + } + + private static TenantConfig copy(TenantConfig source) { + return new TenantConfig( + source.getName(), + source.getDomain(), + source.getUrl(), + source.getUsername(), + source.getPassword() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java index 4de1592..2105925 100644 --- a/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java +++ b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java @@ -21,10 +21,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.NoSuchElementException; -import java.util.Set; +import java.util.Objects; import java.util.regex.Pattern; import java.util.function.Supplier; -import java.util.stream.Collectors; @Service public class TenantLifecycleService { @@ -72,6 +71,13 @@ public class TenantLifecycleService { * Добавляет или обновляет tenant без разрыва действующего подключения. */ public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) { + return addOrUpdateTenantWithReceipt(requestedConfig).tenant(); + } + + /** + * Добавляет или обновляет tenant и возвращает подтверждённый результат персистенции. + */ + public synchronized TenantLifecycleMutationResult addOrUpdateTenantWithReceipt(TenantConfig requestedConfig) { TenantConfig config = validateAndNormalize(requestedConfig); Map oldConfigs = routingDataSource.snapshotTenantConfigs(); Map desiredConfigs = new LinkedHashMap<>(oldConfigs); @@ -96,7 +102,7 @@ public class TenantLifecycleService { publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config); retireDataSource(previous == null ? null : previous.dataSource()); log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain()); - return copyConfig(config); + return new TenantLifecycleMutationResult(config, receipt); } finally { if (!activated) { closeCandidateDataSource(candidate); @@ -108,6 +114,13 @@ public class TenantLifecycleService { * Сначала сохраняет desired-конфигурацию, затем локально отключает tenant. */ public synchronized TenantConfig removeTenant(String requestedDomain) { + return removeTenantWithReceipt(requestedDomain).tenant(); + } + + /** + * Удаляет tenant и возвращает подтверждённый результат персистенции. + */ + public synchronized TenantLifecycleMutationResult removeTenantWithReceipt(String requestedDomain) { String domain = validateAndNormalizeDomain(requestedDomain); Map oldConfigs = routingDataSource.snapshotTenantConfigs(); TenantConfig existing = oldConfigs.get(domain); @@ -132,13 +145,14 @@ public class TenantLifecycleService { publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null); retireDataSource(removed.dataSource()); log.info("Lifecycle тенанта '{}' удалён", domain); - return copyConfig(existing); + return new TenantLifecycleMutationResult(existing, receipt); } /** * Синхронизирует уже персистированный список из mounted Secret. Метод не пишет * Secret повторно и использует тот же monitor, что ручные API add/update/remove. - * Обновление параметров существующего domain остаётся отдельной задачей watcher №13. + * Для нового или изменённого домена подключение сначала полностью подготавливается, + * проверяется и мигрируется, после чего атомарно заменяет прежний runtime-снимок. */ public synchronized void synchronizeFromPersistedConfig(List requestedConfigs) { if (requestedConfigs == null) { @@ -146,47 +160,55 @@ public class TenantLifecycleService { } List configs = requestedConfigs.stream() .map(this::validateAndNormalize) + .sorted(Comparator.comparing(TenantConfig::getDomain)) .toList(); - Set desiredDomains = configs.stream() - .map(TenantConfig::getDomain) - .collect(Collectors.toSet()); - - readinessRegistry.clearConfigurationFailure(); - readinessRegistry.replaceDesired(configs); - if (desiredDomains.size() != configs.size()) { - throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов"); - } + Map desiredConfigs = new LinkedHashMap<>(); for (TenantConfig config : configs) { - if (!routingDataSource.hasTenant(config.getDomain())) { - log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain()); - activatePersistedConfig(config); + if (desiredConfigs.put(config.getDomain(), config) != null) { + throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов"); } } - for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) { - if (!desiredDomains.contains(existingDomain)) { + Map currentConfigs = routingDataSource.snapshotTenantConfigs(); + readinessRegistry.replaceDesired(configs); + + for (TenantConfig config : configs) { + TenantConfig currentConfig = currentConfigs.get(config.getDomain()); + if (currentConfig == null) { + log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain()); + activatePersistedConfig(config); + } else if (!sameConfig(currentConfig, config)) { + log.info("Заменяем подключение тенанта '{}' из persisted-конфигурации", config.getDomain()); + activatePersistedConfig(config); + } else { + recoverPersistedReadinessIfNeeded(config); + } + } + + for (String existingDomain : new ArrayList<>(currentConfigs.keySet())) { + if (!desiredConfigs.containsKey(existingDomain)) { log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain); TenantRoutingDataSource.TenantState removed = routingDataSource.removeTenantAtomically(existingDomain); retireDataSource(removed == null ? null : removed.dataSource()); } } + + readinessRegistry.clearConfigurationFailure(); } private void activatePersistedConfig(TenantConfig config) { HikariDataSource candidate = null; boolean activated = false; + TenantRoutingDataSource.TenantState previous; readinessRegistry.markMigrationStarted(config); try { candidate = prepareCandidate(config); verifyConnection(candidate); migrateCandidate(candidate); - TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate); + previous = routingDataSource.swapTenant(config, candidate); activated = true; - readinessRegistry.markMigrationSucceeded(config); - readinessRegistry.markConnectivity(config.getDomain(), true); - retireDataSource(previous == null ? null : previous.dataSource()); } catch (RuntimeException activationFailure) { readinessRegistry.markMigrationFailed(config); throw activationFailure; @@ -195,6 +217,29 @@ public class TenantLifecycleService { closeCandidateDataSource(candidate); } } + + // После swap новый pool уже является рабочим. Ошибка публикации readiness не должна + // ошибочно помечать успешно мигрированный pool как FAILED: следующий retry восстановит + // состояние по активному подключению без повторной замены DataSource. + retireDataSource(previous == null ? null : previous.dataSource()); + readinessRegistry.markMigrationSucceeded(config); + readinessRegistry.markConnectivity(config.getDomain(), true); + } + + private void recoverPersistedReadinessIfNeeded(TenantConfig config) { + TenantReadinessRegistry.TenantStatus status = + readinessRegistry.snapshot().get(config.getDomain()); + if (status == null + || status.migrationState() == TenantReadinessRegistry.MigrationState.SUCCEEDED) { + return; + } + if (!routingDataSource.testConnection(config.getDomain())) { + throw new TenantLifecycleException( + "Активное подключение тенанта не прошло проверку после предыдущей синхронизации" + ); + } + readinessRegistry.markMigrationSucceeded(config); + readinessRegistry.markConnectivity(config.getDomain(), true); } private HikariDataSource prepareCandidate(TenantConfig config) { @@ -398,9 +443,24 @@ public class TenantLifecycleService { ); } + private boolean sameConfig(TenantConfig first, TenantConfig second) { + TenantConfig normalizedFirst = validateAndNormalize(first); + TenantConfig normalizedSecond = validateAndNormalize(second); + return Objects.equals(normalizedFirst.getName(), normalizedSecond.getName()) + && Objects.equals(normalizedFirst.getDomain(), normalizedSecond.getDomain()) + && Objects.equals(normalizedFirst.getUrl(), normalizedSecond.getUrl()) + && Objects.equals(normalizedFirst.getUsername(), normalizedSecond.getUsername()) + && Objects.equals(normalizedFirst.getPassword(), normalizedSecond.getPassword()); + } + private void retireDataSource(DataSource dataSource) { if (dataSource != null) { - retiredPoolService.retire(dataSource); + try { + retiredPoolService.retire(dataSource); + } catch (RuntimeException retirementFailure) { + logTechnicalFailure("Не удалось передать прежний tenant pool на отложенное закрытие", + retirementFailure); + } } } diff --git a/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java index 2db3531..04e14e0 100644 --- a/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java +++ b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java @@ -511,7 +511,10 @@ class KubernetesTenantSecretUpdaterTest { files.tokenPath(), files.namespacePath(), files.caPath(), - server.url("/").toString(), + // MockWebServer на Windows может вернуть canonical loopback hostname + // kubernetes.docker.internal. Фиксируем имя из SAN тестового сертификата, + // сохраняя реальную проверку hostname в клиенте. + "https://localhost:" + server.getPort(), "tenants-secret", objectMapper, maxAttempts, diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java index 79349c1..067d7f7 100644 --- a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java @@ -1,33 +1,35 @@ package com.magistr.app.config.tenant; import com.magistr.app.config.tenant.health.TenantReadinessRegistry; -import com.magistr.app.service.RetiredTenantPoolService; -import com.magistr.app.service.TenantDatabaseMigrationService; +import com.magistr.app.service.TenantLifecycleException; import com.magistr.app.service.TenantLifecycleService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.test.util.ReflectionTestUtils; import java.nio.file.Files; import java.nio.file.Path; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.never; +@ExtendWith(OutputCaptureExtension.class) class TenantConfigWatcherTest { @TempDir @@ -48,29 +50,62 @@ class TenantConfigWatcherTest { .isNotEqualTo(TenantConfigWatcher.configHash("tenant-b")); } + @Test + void prepareForApiMutationSynchronizesUnappliedBaselineOnce() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + String content = json("alpha"); + Files.writeString(configPath, content); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + + watcher.prepareForApiMutation(); + watcher.prepareForApiMutation(); + + verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) + .isEqualTo(TenantConfigWatcher.configHash(content)); + assertThat(ReflectionTestUtils.getField(watcher, "lastAppliedSnapshot")).isNotNull(); + } + + @Test + void prepareForApiMutationPropagatesReadFailure() throws Exception { + Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class); + TenantConfigWatcher watcher = watcher( + lifecycleService, + readinessRegistry, + configPath, + new AtomicLong(1_000L) + ); + + assertThatThrownBy(watcher::prepareForApiMutation) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось подготовить актуальную конфигурацию тенантов перед изменением"); + + verify(readinessRegistry).markConfigurationFailure(); + verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(true); + } + @Test void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception { Path configPath = tempDirectory.resolve("tenants.json"); - String content = """ - [{ - "name": "Тестовый tenant", - "domain": "alpha", - "url": "jdbc:postgresql://db/alpha", - "username": "alpha", - "password": "тестовый-пароль" - }] - """; + String content = json("alpha"); Files.writeString(configPath, content); TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); doThrow(new IllegalStateException("тестовая ошибка синхронизации")) .doNothing() .when(lifecycleService).synchronizeFromPersistedConfig(anyList()); - TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + AtomicLong now = new AtomicLong(1_000L); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now); watcher.watchForChanges(); assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo(""); + now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS); watcher.watchForChanges(); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) .isEqualTo(TenantConfigWatcher.configHash(content)); verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList()); @@ -80,112 +115,441 @@ class TenantConfigWatcherTest { } @Test - void successfulHashPreventsRepeatedSynchronizationOfSameContent() throws Exception { + void retriesSameRevisionOnlyAfterBackoffAndResetsStateAfterSuccess() throws Exception { Path configPath = tempDirectory.resolve("tenants.json"); String content = "[]"; Files.writeString(configPath, content); TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); - doNothing().when(lifecycleService).synchronizeFromPersistedConfig(anyList()); - TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + doThrow(new IllegalStateException("первая тестовая ошибка")) + .doThrow(new IllegalStateException("вторая тестовая ошибка")) + .doNothing() + .when(lifecycleService).synchronizeFromPersistedConfig(anyList()); + AtomicLong now = new AtomicLong(10_000L); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now); watcher.watchForChanges(); watcher.watchForChanges(); - verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + + now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS); + watcher.watchForChanges(); + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList()); + + now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS * 2 - 1); + watcher.watchForChanges(); + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList()); + + now.incrementAndGet(); + watcher.watchForChanges(); + + verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(0); + assertThat(ReflectionTestUtils.getField(watcher, "retryNotBeforeMillis")).isEqualTo(0L); assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) .isEqualTo(TenantConfigWatcher.configHash(content)); } @Test - void watcherCannotReadBetweenSerializedApiMutationAndRefreshHash() throws Exception { + void retryDelayGrowsExponentiallyAndIsCapped() { + assertThat(TenantConfigWatcher.retryDelayMillis(1)).isEqualTo(30_000L); + assertThat(TenantConfigWatcher.retryDelayMillis(2)).isEqualTo(60_000L); + assertThat(TenantConfigWatcher.retryDelayMillis(3)).isEqualTo(120_000L); + assertThat(TenantConfigWatcher.retryDelayMillis(4)).isEqualTo(240_000L); + assertThat(TenantConfigWatcher.retryDelayMillis(5)).isEqualTo(300_000L); + assertThat(TenantConfigWatcher.retryDelayMillis(31)).isEqualTo(300_000L); + } + + @Test + void newRevisionBypassesBackoffImmediately() throws Exception { Path configPath = tempDirectory.resolve("tenants.json"); - String content = "[]"; - Files.writeString(configPath, content); - TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); - TenantLifecycleService lifecycleService = new TenantLifecycleService( - routingDataSource, - mock(TenantConfigStore.class), - mock(TenantDatabaseMigrationService.class), - mock(RetiredTenantPoolService.class), - mock(TenantReadinessRegistry.class) - ); - TenantConfigWatcher watcher = new TenantConfigWatcher( + Files.writeString(configPath, "[]"); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + doThrow(new IllegalStateException("тестовая ошибка")) + .doNothing() + .when(lifecycleService).synchronizeFromPersistedConfig(anyList()); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L)); + + watcher.watchForChanges(); + Files.writeString(configPath, "[ ]"); + watcher.watchForChanges(); + + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) + .isEqualTo(TenantConfigWatcher.configHash("[ ]")); + } + + @Test + void restoredLastAppliedRevisionIsReconciledAfterFailedChange() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + String firstRevision = "[]"; + String failedRevision = "[ ]"; + Files.writeString(configPath, firstRevision); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + doNothing() + .doThrow(new IllegalStateException("тестовая ошибка новой ревизии")) + .doNothing() + .when(lifecycleService).synchronizeFromPersistedConfig(anyList()); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L)); + + watcher.watchForChanges(); + Files.writeString(configPath, failedRevision); + watcher.watchForChanges(); + Files.writeString(configPath, firstRevision); + watcher.watchForChanges(); + + verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) + .isEqualTo(TenantConfigWatcher.configHash(firstRevision)); + assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(false); + } + + @Test + void semanticFenceDefersStaleProjectionWithDifferentFormatting() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + String baseline = json("alpha"); + Files.writeString(configPath, baseline); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); + + Files.writeString(configPath, prettyJson("alpha")); + watcher.watchForChanges(); + + verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) + .isEqualTo(TenantConfigWatcher.configHash(baseline)); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNotNull(); + } + + @Test + void prepareForNextMutationDoesNotApplyKnownStaleProjection() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, json("alpha")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); + + watcher.prepareForApiMutation(); + + verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + } + + @Test + void nonPersistedReceiptDoesNotArmFence() { + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher( lifecycleService, - mock(TenantReadinessRegistry.class) + tempDirectory.resolve("tenants.json") ); - ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString()); - CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1); - CountDownLatch allowApiRefresh = new CountDownLatch(1); - AtomicReference watcherThread = new AtomicReference<>(); - ExecutorService executor = Executors.newFixedThreadPool(2); - try { - Future api = executor.submit(() -> lifecycleService.executeSerialized(() -> { - apiMutationReachedRefreshWindow.countDown(); - await(allowApiRefresh, "Ожидание разрешения refreshHash прервано"); - watcher.refreshHash(); - })); - assertThat(apiMutationReachedRefreshWindow.await(5, TimeUnit.SECONDS)).isTrue(); + watcher.registerSuccessfulMutation(receipt( + false, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); - Future concurrentWatcher = executor.submit(() -> { - watcherThread.set(Thread.currentThread()); - watcher.watchForChanges(); - }); - awaitBlocked(watcherThread); - assertThat(concurrentWatcher).isNotDone(); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull(); + assertThat((Iterable) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty(); + } - allowApiRefresh.countDown(); - api.get(5, TimeUnit.SECONDS); - concurrentWatcher.get(5, TimeUnit.SECONDS); + @Test + void persistedNoOpDoesNotSuppressMergedProjection() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, json("alpha")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + watcher.registerSuccessfulMutation(receipt( + true, + false, + tenants("alpha"), + tenants("alpha") + )); - assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) - .isEqualTo(TenantConfigWatcher.configHash(content)); - verify(routingDataSource, never()).snapshotTenantConfigs(); - } finally { - allowApiRefresh.countDown(); - executor.shutdownNow(); - assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); - } + Files.writeString(configPath, json("alpha", "gamma")); + watcher.watchForChanges(); + + ArgumentCaptor> captor = tenantListCaptor(); + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture()); + assertThat(captor.getAllValues().get(1)) + .extracting(TenantConfig::getDomain) + .containsExactly("alpha", "gamma"); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull(); + } + + @Test + void twoRapidMutationsDeferIntermediateProjectionUntilLatestCommit() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, json("alpha")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha", "beta"), + tenants("alpha", "beta", "gamma") + )); + + Files.writeString(configPath, json("alpha", "beta")); + watcher.watchForChanges(); + verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + + Files.writeString(configPath, json("alpha", "beta", "gamma")); + watcher.watchForChanges(); + + ArgumentCaptor> captor = tenantListCaptor(); + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture()); + assertThat(captor.getAllValues().get(1)) + .extracting(TenantConfig::getDomain) + .containsExactly("alpha", "beta", "gamma"); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull(); + assertThat((Iterable) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty(); + } + + @Test + void cyclicRapidMutationsClearFenceWhenLatestCommitIsAlreadyApplied() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, json("alpha")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha", "beta"), + tenants("alpha") + )); + + watcher.watchForChanges(); + + verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull(); + assertThat((Iterable) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty(); + } + + @Test + void unknownConcurrentSnapshotSupersedesExpectedAndIsApplied() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, json("alpha")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath); + watcher.prepareForApiMutation(); + watcher.registerSuccessfulMutation(receipt( + true, + true, + tenants("alpha"), + tenants("alpha", "beta") + )); + + Files.writeString(configPath, json("alpha", "gamma")); + watcher.watchForChanges(); + + ArgumentCaptor> captor = tenantListCaptor(); + verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture()); + assertThat(captor.getAllValues().get(1)) + .extracting(TenantConfig::getDomain) + .containsExactly("alpha", "gamma"); + assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull(); + assertThat((Iterable) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty(); + } + + @Test + void requiredMissingFileUsesBackoff() { + Path configPath = tempDirectory.resolve("tenants.json"); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class); + AtomicLong now = new AtomicLong(1_000L); + TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now); + ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true); + + watcher.watchForChanges(); + watcher.watchForChanges(); + + verify(readinessRegistry).markConfigurationFailure(); + assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1); + + now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS); + watcher.watchForChanges(); + + verify(readinessRegistry, times(2)).markConfigurationFailure(); + assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2); + } + + @Test + void readFailureUsesBackoff() throws Exception { + Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json")); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class); + AtomicLong now = new AtomicLong(1_000L); + TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now); + + watcher.watchForChanges(); + watcher.watchForChanges(); + + verify(readinessRegistry).markConfigurationFailure(); + assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1); + + now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS); + watcher.watchForChanges(); + + verify(readinessRegistry, times(2)).markConfigurationFailure(); + assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2); + } + + @Test + void requiredEmptyConfigMarksFailureAndKeepsHashUnapplied() throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + Files.writeString(configPath, "[]"); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class); + TenantConfigWatcher watcher = watcher( + lifecycleService, + readinessRegistry, + configPath, + new AtomicLong(1_000L) + ); + ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true); + + watcher.watchForChanges(); + + verify(readinessRegistry).markConfigurationFailure(); + verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList()); + assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo(""); + } + + @Test + void failureLogsRetryMetadataWithoutSecretsOrFingerprints(CapturedOutput output) throws Exception { + Path configPath = tempDirectory.resolve("tenants.json"); + String content = """ + [{"domain":"alpha","url":"jdbc:postgresql://secret-host/db", + "username":"secret-user","password":"secret-password"}] + """; + Files.writeString(configPath, content); + TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); + doThrow(new IllegalStateException("секретные технические детали")) + .when(lifecycleService).synchronizeFromPersistedConfig(anyList()); + TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L)); + + watcher.watchForChanges(); + + assertThat(output) + .contains("Ошибка синхронизации конфигурации тенантов") + .contains("попытка=1") + .contains("через 30000 мс") + .doesNotContain("secret-host") + .doesNotContain("secret-user") + .doesNotContain("secret-password") + .doesNotContain(TenantConfigWatcher.configHash(content)) + .doesNotContain("revision=") + .doesNotContain("fingerprint") + .doesNotContain("хеш:"); } private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) { + return watcher(lifecycleService, configPath, new AtomicLong(1_000L)); + } + + private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, + Path configPath, + AtomicLong now) { + return watcher(lifecycleService, mock(TenantReadinessRegistry.class), configPath, now); + } + + private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, + TenantReadinessRegistry readinessRegistry, + Path configPath, + AtomicLong now) { doAnswer(invocation -> { invocation.getArgument(0).run(); return null; }).when(lifecycleService).executeSerialized(any(Runnable.class)); - TenantConfigWatcher watcher = new TenantConfigWatcher( - lifecycleService, - mock(TenantReadinessRegistry.class) - ); + TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService, readinessRegistry); ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString()); + ReflectionTestUtils.setField(watcher, "currentTimeMillis", (LongSupplier) now::get); return watcher; } - private void await(CountDownLatch latch, String message) { - try { - if (!latch.await(5, TimeUnit.SECONDS)) { - throw new IllegalStateException(message); - } - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(message, exception); - } + private TenantSecretUpdateReceipt receipt(boolean persisted, + boolean changed, + List previous, + List committed) { + return new TenantSecretUpdateReceipt( + persisted, + changed, + persisted ? "resource-version" : null, + previous, + committed + ); } - private void awaitBlocked(AtomicReference threadReference) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (System.nanoTime() < deadline) { - Thread thread = threadReference.get(); - if (thread != null && thread.getState() == Thread.State.BLOCKED) { - return; - } - try { - Thread.sleep(10); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Ожидание блокировки watcher прервано", exception); - } - } - throw new IllegalStateException("Watcher не заблокировался на общем lifecycle coordinator"); + private List tenants(String... domains) { + return java.util.Arrays.stream(domains).map(this::tenant).toList(); + } + + private TenantConfig tenant(String domain) { + return new TenantConfig( + domain, + domain, + "jdbc:postgresql://db/" + domain, + domain, + "password-" + domain + ); + } + + private String json(String... domains) { + return "[" + java.util.Arrays.stream(domains) + .map(domain -> "{\"name\":\"" + domain + + "\",\"domain\":\"" + domain + + "\",\"url\":\"jdbc:postgresql://db/" + domain + + "\",\"username\":\"" + domain + + "\",\"password\":\"password-" + domain + "\"}") + .collect(java.util.stream.Collectors.joining(",")) + "]"; + } + + private String prettyJson(String domain) { + return """ + [ + { + "password": "password-%1$s", + "username": "%1$s", + "url": "jdbc:postgresql://db/%1$s", + "domain": "%1$s", + "name": "%1$s" + } + ] + """.formatted(domain); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private ArgumentCaptor> tenantListCaptor() { + return (ArgumentCaptor) ArgumentCaptor.forClass(List.class); } } diff --git a/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java index 62b8db0..d31bcfe 100644 --- a/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java @@ -1,23 +1,32 @@ package com.magistr.app.controller; import com.magistr.app.config.tenant.TenantConfigWatcher; +import com.magistr.app.config.tenant.TenantConfig; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.config.tenant.TenantSecretUpdateReceipt; import com.magistr.app.service.TenantLifecycleException; +import com.magistr.app.service.TenantLifecycleMutationResult; import com.magistr.app.service.TenantLifecycleService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.web.servlet.MockMvc; +import java.util.List; import java.util.Map; import java.util.function.Supplier; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -68,7 +77,128 @@ class DatabaseControllerTest { .andExpect(jsonPath("$.message") .value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC")))); - verify(tenantConfigWatcher, never()).refreshHash(); + verify(tenantConfigWatcher, never()).prepareForApiMutation(); + verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any()); verify(lifecycleService).executeSerialized(any(Supplier.class)); } + + @Test + void successfulAddPreparesProjectionAndRegistersPersistenceReceipt() throws Exception { + executeSerializedActionsImmediately(); + TenantConfig saved = new TenantConfig( + "Тестовый университет", + "test", + "jdbc:postgresql://db/test", + "user", + "password" + ); + TenantSecretUpdateReceipt receipt = receipt(saved); + when(lifecycleService.addOrUpdateTenantWithReceipt(any(TenantConfig.class))) + .thenReturn(new TenantLifecycleMutationResult(saved, receipt)); + + mockMvc.perform(post("/api/database/tenants") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Тестовый университет", + "domain": "test", + "url": "jdbc:postgresql://db/test", + "username": "user", + "password": "password" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + InOrder order = inOrder(lifecycleService, tenantConfigWatcher); + order.verify(lifecycleService).executeSerialized(any(Supplier.class)); + order.verify(tenantConfigWatcher).prepareForApiMutation(); + order.verify(lifecycleService).addOrUpdateTenantWithReceipt(any(TenantConfig.class)); + order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt); + } + + @Test + void successfulRemovalPreparesProjectionAndRegistersPersistenceReceipt() throws Exception { + executeSerializedActionsImmediately(); + TenantConfig removed = new TenantConfig( + "Тестовый университет", + "test", + "jdbc:postgresql://db/test", + "user", + "password" + ); + TenantSecretUpdateReceipt receipt = receipt(); + when(lifecycleService.removeTenantWithReceipt("test")) + .thenReturn(new TenantLifecycleMutationResult(removed, receipt)); + + mockMvc.perform(delete("/api/database/tenants/test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + InOrder order = inOrder(lifecycleService, tenantConfigWatcher); + order.verify(lifecycleService).executeSerialized(any(Supplier.class)); + order.verify(tenantConfigWatcher).prepareForApiMutation(); + order.verify(lifecycleService).removeTenantWithReceipt("test"); + order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt); + } + + @Test + void projectionReadFailurePreventsAddAndReturnsServiceUnavailable() throws Exception { + executeSerializedActionsImmediately(); + doThrow(new TenantLifecycleException( + "Не удалось прочитать конфигурацию тенантов перед изменением", + new IllegalStateException("тестовая ошибка чтения") + )).when(tenantConfigWatcher).prepareForApiMutation(); + + mockMvc.perform(post("/api/database/tenants") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Тестовый университет", + "domain": "test", + "url": "jdbc:postgresql://db/test", + "username": "user", + "password": "password" + } + """)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message") + .value("Не удалось прочитать конфигурацию тенантов перед изменением")); + + verify(lifecycleService, never()).addOrUpdateTenantWithReceipt(any()); + verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any()); + } + + @Test + void projectionReadFailurePreventsRemovalAndReturnsServiceUnavailable() throws Exception { + executeSerializedActionsImmediately(); + doThrow(new TenantLifecycleException( + "Не удалось прочитать конфигурацию тенантов перед изменением", + new IllegalStateException("тестовая ошибка чтения") + )).when(tenantConfigWatcher).prepareForApiMutation(); + + mockMvc.perform(delete("/api/database/tenants/test")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.success").value(false)); + + verify(lifecycleService, never()).removeTenantWithReceipt(any()); + verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any()); + } + + private void executeSerializedActionsImmediately() { + doAnswer(invocation -> invocation.>getArgument(0).get()) + .when(lifecycleService) + .executeSerialized(org.mockito.ArgumentMatchers.>any()); + } + + private TenantSecretUpdateReceipt receipt(TenantConfig... tenants) { + return new TenantSecretUpdateReceipt( + true, + true, + "resource-version", + List.of(), + List.of(tenants) + ); + } } diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java index 97f2463..5885699 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java @@ -178,6 +178,52 @@ class TenantLifecyclePostgreSqlIntegrationTest { assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE); } + @Test + void persistedConfigReplacementKeepsOldPoolOnFailureAndSwapsWithoutSecretWrite() throws Exception { + TenantConfig wrongCredentials = config( + "Неверные credentials из проекции", + jdbcUrl(OLD_DATABASE), + POSTGRES.getPassword() + "-wrong" + ); + + assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(wrongCredentials))) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось подключиться к базе данных тенанта"); + assertOldState(); + + TenantConfig badChecksum = config( + "Повреждённый Flyway из проекции", + jdbcUrl(BAD_CHECKSUM_DATABASE), + POSTGRES.getPassword() + ); + assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(badChecksum))) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось выполнить миграции базы данных тенанта"); + assertOldState(); + + TenantConfig validCandidate = config( + "Новый tenant из проекции", + jdbcUrl(VALID_DATABASE), + POSTGRES.getPassword() + ); + try (Connection inFlightOldConnection = oldPool.getConnection()) { + lifecycleService.synchronizeFromPersistedConfig(List.of(validCandidate)); + + assertThat(oldPool.isClosed()).isFalse(); + try (Statement statement = inFlightOldConnection.createStatement(); + ResultSet result = statement.executeQuery("SELECT current_database()")) { + assertThat(result.next()).isTrue(); + assertThat(result.getString(1)).isEqualTo(OLD_DATABASE); + } + } + + awaitPoolClosed(oldPool); + assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE); + verify(tenantConfigStore, never()).upsertTenant(any()); + verify(tenantConfigStore, never()).removeTenant(any()); + verify(tenantConfigStore, never()).compensate(any()); + } + private void awaitPoolClosed(HikariDataSource pool) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); while (!pool.isClosed() && System.nanoTime() < deadline) { @@ -218,6 +264,7 @@ class TenantLifecyclePostgreSqlIntegrationTest { private void createDatabase(String databaseName) throws Exception { try (Connection connection = POSTGRES.createConnection(""); Statement statement = connection.createStatement()) { + statement.execute("DROP DATABASE IF EXISTS " + databaseName + " WITH (FORCE)"); statement.execute("CREATE DATABASE " + databaseName); } } diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java index d07f05e..4484883 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java @@ -11,6 +11,9 @@ import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mock; @@ -20,17 +23,21 @@ import org.mockito.quality.Strictness; import java.sql.Connection; import java.sql.SQLException; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -128,6 +135,25 @@ class TenantLifecycleServiceTest { verify(candidate, never()).close(); } + @Test + void returnsConfirmedReceiptWithSuccessfulUpsert() { + TenantConfig requested = newAlpha(); + TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt( + true, + false, + "resource-version-noop", + List.of(requested), + List.of(requested) + ); + when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt); + + TenantLifecycleMutationResult result = service.addOrUpdateTenantWithReceipt(requested); + + assertThat(result.receipt()).isSameAs(receipt); + assertConfig(result.tenant(), + "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); + } + @Test void addsNewTenantWithoutRetiringExistingPool() { Map createSnapshot = new LinkedHashMap<>(); @@ -296,6 +322,26 @@ class TenantLifecycleServiceTest { order.verify(retiredPoolService).retire(oldDataSource); } + @Test + void returnsLocalOnlyReceiptWithSuccessfulRemoval() { + TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt( + false, + false, + null, + List.of(oldAlpha), + List.of(beta) + ); + when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt); + when(routingDataSource.removeTenantAtomically("alpha")) + .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource)); + + TenantLifecycleMutationResult result = service.removeTenantWithReceipt("alpha"); + + assertThat(result.receipt()).isSameAs(receipt); + assertConfig(result.tenant(), + "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password"); + } + @Test void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() { TenantSecretUpdateReceipt receipt = removeReceipt("alpha"); @@ -314,22 +360,156 @@ class TenantLifecycleServiceTest { @Test void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() { TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); - when(routingDataSource.hasTenant("gamma")).thenReturn(false); when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null); service.synchronizeFromPersistedConfig(List.of(gamma)); InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource); - order.verify(readinessRegistry).clearConfigurationFailure(); order.verify(readinessRegistry).replaceDesired(any()); order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class)); order.verify(migrationService).migrate(candidate); order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class)); order.verify(readinessRegistry).markConnectivity("gamma", true); + order.verify(readinessRegistry).clearConfigurationFailure(); verify(tenantConfigStore, never()).upsertTenant(any()); } + @ParameterizedTest + @MethodSource("changedPersistedAlphaConfigs") + void replacesExistingPersistedTenantWhenAnyNormalizedFieldChanges(TenantConfig changedConfig) { + when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha)); + + service.synchronizeFromPersistedConfig(List.of(changedConfig)); + + ArgumentCaptor preparedConfig = ArgumentCaptor.forClass(TenantConfig.class); + verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture()); + verify(migrationService).migrate(candidate); + verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); + verify(retiredPoolService).retire(oldDataSource); + verify(tenantConfigStore, never()).upsertTenant(any()); + verify(tenantConfigStore, never()).removeTenant(any()); + assertConfig( + preparedConfig.getValue(), + changedConfig.getName().trim(), + "alpha", + changedConfig.getUrl().trim(), + changedConfig.getUsername(), + changedConfig.getPassword() + ); + } + + @Test + void normalizedEquivalentPersistedTenantDoesNotRecreatePool() { + when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha)); + TenantConfig equivalent = config( + " Старый Alpha ", + " ALPHA ", + " jdbc:old-alpha ", + "old-user", + "old-password" + ); + + service.synchronizeFromPersistedConfig(List.of(equivalent)); + + verify(routingDataSource, never()).prepareTenantDataSource(any()); + verify(routingDataSource, never()).swapTenant(any(), any()); + verify(routingDataSource, never()).removeTenantAtomically(any()); + verify(migrationService, never()).migrate(any()); + verify(retiredPoolService, never()).retire(any()); + verify(readinessRegistry).clearConfigurationFailure(); + } + + @Test + void failedPersistedReplacementKeepsOldPoolAndClosesCandidate() { + when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha)); + org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway")) + .when(migrationService).migrate(candidate); + + assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(newAlpha()))) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось выполнить миграции базы данных тенанта"); + + verify(routingDataSource, never()).swapTenant(any(), any()); + verify(retiredPoolService, never()).retire(oldDataSource); + verify(candidate).close(); + verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class)); + verify(readinessRegistry, never()).clearConfigurationFailure(); + verify(tenantConfigStore, never()).upsertTenant(any()); + } + + @Test + void retryRestoresReadinessAfterPostSwapPublicationFailureWithoutSecondSwap() { + TenantConfig changed = newAlpha(); + Map activatedSnapshot = new LinkedHashMap<>(); + activatedSnapshot.put("alpha", changed); + activatedSnapshot.put("beta", beta); + when(routingDataSource.snapshotTenantConfigs()) + .thenReturn(oldSnapshot, activatedSnapshot); + TenantReadinessRegistry.TenantStatus interruptedStatus = new TenantReadinessRegistry.TenantStatus( + "alpha", + "тестовый-fingerprint", + TenantReadinessRegistry.MigrationState.IN_PROGRESS, + TenantReadinessRegistry.ConnectivityState.UNKNOWN, + null, + false, + Instant.EPOCH + ); + when(readinessRegistry.snapshot()).thenReturn(Map.of("alpha", interruptedStatus)); + when(routingDataSource.testConnection("alpha")).thenReturn(true); + doThrow(new IllegalStateException("тестовый отказ публикации readiness")) + .doNothing() + .when(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class)); + + assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(changed, beta))) + .isInstanceOf(IllegalStateException.class); + + service.synchronizeFromPersistedConfig(List.of(changed, beta)); + + verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); + verify(migrationService).migrate(candidate); + verify(retiredPoolService).retire(oldDataSource); + verify(readinessRegistry, never()).markMigrationFailed(any(TenantConfig.class)); + verify(routingDataSource).testConnection("alpha"); + verify(readinessRegistry).markConnectivity("alpha", true); + verify(readinessRegistry).clearConfigurationFailure(); + } + + @Test + void appliesMergedPersistedSnapshotWithoutWritingSecret() { + TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password"); + TenantConfig newBeta = config("Новый Beta", "beta", "jdbc:new-beta", "new-beta-user", "new-beta-password"); + when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of( + "alpha", oldAlpha, + "gamma", gamma + )); + when(routingDataSource.removeTenantAtomically("gamma")) + .thenReturn(new TenantRoutingDataSource.TenantState("gamma", gamma, oldDataSource)); + + service.synchronizeFromPersistedConfig(List.of(newAlpha(), newBeta)); + + verify(routingDataSource, times(2)).prepareTenantDataSource(any(TenantConfig.class)); + verify(routingDataSource, times(2)).swapTenant(any(TenantConfig.class), any()); + verify(routingDataSource).removeTenantAtomically("gamma"); + verify(tenantConfigStore, never()).upsertTenant(any()); + verify(tenantConfigStore, never()).removeTenant(any()); + verify(tenantConfigStore, never()).compensate(any()); + } + + @Test + void rejectsDuplicateNormalizedDomainsBeforeRuntimeOrReadinessMutation() { + TenantConfig first = config("Alpha 1", "alpha", "jdbc:alpha-1", "user", "password"); + TenantConfig second = config("Alpha 2", " ALPHA ", "jdbc:alpha-2", "user", "password"); + + assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(first, second))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Конфигурация содержит повторяющиеся домены тенантов"); + + verify(routingDataSource, never()).snapshotTenantConfigs(); + verify(routingDataSource, never()).prepareTenantDataSource(any()); + verify(readinessRegistry, never()).replaceDesired(any()); + } + @Test void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() { TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); @@ -376,6 +556,23 @@ class TenantLifecycleServiceTest { return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); } + private static Stream changedPersistedAlphaConfigs() { + return Stream.of( + Arguments.of(new TenantConfig( + "Новое имя", "alpha", "jdbc:old-alpha", "old-user", "old-password" + )), + Arguments.of(new TenantConfig( + "Старый Alpha", "alpha", "jdbc:new-alpha", "old-user", "old-password" + )), + Arguments.of(new TenantConfig( + "Старый Alpha", "alpha", "jdbc:old-alpha", "new-user", "old-password" + )), + Arguments.of(new TenantConfig( + "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "new-password" + )) + ); + } + private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) { Map committed = new LinkedHashMap<>(oldSnapshot); committed.put(tenant.getDomain(), tenant); diff --git a/docs/API.md b/docs/API.md index c49d50e..a9d5b2a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1190,16 +1190,20 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 него используется нормализованный `domain`. **Логика:** -1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору. -2. Открывает соединение и явно проверяет его готовность. -3. Выполняет Flyway-валидацию и миграции tenant-БД. -4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и +1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию + tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`. +2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору. +3. Открывает соединение и явно проверяет его готовность. +4. Выполняет Flyway-валидацию и миграции tenant-БД. +5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`. -5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным +6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным retry/backoff; неизменившаяся конфигурация не записывается повторно. -6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`. -7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов +7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`. +8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов либо по истечении защитного таймаута. +9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой + `TenantSecretUpdateReceipt` для согласования mounted-проекции. Backend соединяется с Kubernetes API только через проверенный service-account CA и hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция @@ -1236,6 +1240,15 @@ HTTP-статусы: `400` для некорректного payload и `503` д компенсация также допускается только для подтверждённой версии и не затирает более новое изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`. +Для `POST` и `DELETE` baseline готовится до API-мутации под общим lifecycle monitor. +После успеха backend использует семантические `previousTenants` и `committedTenants` из +`TenantSecretUpdateReceipt`. Fence создаётся только для реального изменения общего Secret +(`persisted=true`, `changed=true`): известные старые и промежуточные снимки временно +откладываются, ожидаемый committed-снимок применяется полностью. Persisted no-op и локальная +операция fence не создают, а неизвестный merged snapshot другого pod синхронизируется сразу. +SHA-256 файла подтверждается только после полного успешного sync; ошибки повторяются с +экспоненциальной задержкой от 30 до 300 секунд. + ### `POST /api/database/test` Тест подключения к произвольной БД (без регистрации тенанта). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d493a2..400e350 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,7 +81,7 @@ sequenceDiagram | `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | | `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое | -| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | +| `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff | | `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации | | `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA | | `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе | @@ -128,10 +128,11 @@ sequenceDiagram ### Жизненный цикл тенанта -1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию, - создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет - Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только - upsert запрошенного домена. +1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает, + нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline. + Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт + непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а + `TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена. 2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего `tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное @@ -140,6 +141,9 @@ sequenceDiagram не отправляет лишний `PUT`. 3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`. + Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией + и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также + семантические снимки `previousTenants` и `committedTenants`. 4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей @@ -151,7 +155,10 @@ sequenceDiagram а прежний Hikari pool закрывается после завершения активных подключений либо по истечении настраиваемого grace timeout. 6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный - `tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor. + `tenants.json` и сравнивает все поля нормализованного `TenantConfig`: `name`, `domain`, + `url`, `username` и `password`. Для нового или изменённого тенанта lifecycle без повторной + записи Secret выполняет prepare → проверку соединения → Flyway → атомарный swap; отсутствующие + домены удаляются под тем же локальным monitor. 7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же @@ -171,13 +178,25 @@ monitor по-прежнему не считается межподовой бл цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS завершает персистенцию безопасным отказом. -`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по -SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за -32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие, -но ещё не заменяет подключение существующего домена при изменении его URL или credentials. -Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое -значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться -готовым по старому подключению. +`TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой +ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим +снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым +только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш, +снимает readiness и запускает экспоненциальные повторы через 30–300 секунд; другая неудачная +файловая ревизия получает собственную попытку без ожидания backoff предыдущей. + +После API-операции snapshot fence устанавливается только если `TenantSecretUpdateReceipt` +подтверждает реальное общее изменение: `persisted=true` и `changed=true`. Снимок до записи +и ранее ожидавшиеся committed-снимки временно считаются deferred, а новый committed-снимок — +ожидаемым. Поэтому при быстрых мутациях H0 → H1 → H2 запаздывающие H0 и H1 не откатывают +runtime, а H2 полностью синхронизируется и снимает fence. Неизвестный объединённый снимок, +которого нет среди deferred-состояний, также немедленно проходит полный sync и не теряет +изменение другого pod. Локальная персистенция (`persisted=false`) и подтверждённый no-op +(`changed=false`) fence не создают. + +Если ошибка readiness произошла уже после успешного swap, следующий watcher retry видит +совпадающую активную конфигурацию, проверяет действующее соединение и восстанавливает +readiness без повторной миграции и второго swap. ### Liveness и readiness diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a793505..bc43226 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -16,6 +16,9 @@ ``` frontend/ +├── package.json # Dependency-free проверки frontend через node:test +├── tests/ +│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов ├── index.html # 🔐 Страница авторизации (общая) ├── script.js # Логика авторизации ├── style.css # Стили страницы авторизации @@ -34,6 +37,7 @@ frontend/ │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── api.js # HTTP-обёртка (fetch + Authorization) +│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── utils.js # Утилиты │ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ └── views/ # Модули представлений @@ -125,6 +129,7 @@ frontend/ | Tab | Описание | API | |-----|----------|-----| +| `dashboard` | Сводные метрики и проверка конфликтов текущей недели с явным статусом полноты данных | `/api/departments`, `/api/classrooms`, `/api/groups`, `/api/users/teachers`, `/api/schedule/search`, `/api/admin/time-slots` | | `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` | | `users` | CRUD пользователей | `/api/users` | | `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | @@ -139,6 +144,7 @@ frontend/ ### Особенности админских вкладок +- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. @@ -208,6 +214,19 @@ export const api = { --- +## Frontend-тесты + +Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:00–03:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса. + +Команды выполняются из каталога `frontend/`: + +```bash +npm test # unit-тесты дат, загрузки кафедр и UI-состояний +npm run check # синтаксис модулей дашборда + unit-тесты +``` + +--- + ## Аутентификация (Frontend) ### Страница входа (`/index.html`) diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 842df9d..73e7948 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -126,16 +126,24 @@ env: снимают readiness; резервная H2-БД не считается готовой tenant-БД. При добавлении тенанта через API: -1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway -2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку +1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую + mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию +2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway +3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку только upsert запрошенного домена -3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`; +4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`; при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации -4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо +5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо подтверждённой повторным чтением персистенции -5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout -6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд - применяет добавление или удаление домена +6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после + drain/grace timeout +7. lifecycle возвращает `TenantLifecycleMutationResult` с `TenantSecretUpdateReceipt`; + semantic fence регистрируется только для фактически записанного изменения + (`persisted=true`, `changed=true`) +8. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд + синхронизирует полный нормализованный `TenantConfig`, включая изменения `name`, `domain`, + `url`, `username` и `password`; новый или изменённый pool проходит проверку соединения, Flyway + и атомарный swap без повторной записи Secret Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами @@ -147,10 +155,17 @@ backend восстанавливает прежний снимок только Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое состояние принимается без квитанции, допускающей небезопасный автоматический откат. -Watcher пока не заменяет подключение существующего домена при изменении URL или credentials. -Такое изменение сбрасывает readiness, но полное применение на другом pod относится к -проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех -полей `TenantConfig`. +Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения +снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация +повторяется с экспоненциальной задержкой от 30 до 300 секунд. + +Защита от задержки kubelet основана не на raw hash, а на полном нормализованном снимке. +Квитанция фактической persisted-мутации задаёт `previousTenants` и `committedTenants`: +previous и промежуточные committed-снимки временно deferred, последний committed ожидается. +Поэтому последовательность H0 → H1 → H2 не откатывается при доставке H0/H1, а H2 применяется. +Неизвестный merged snapshot, не совпадающий с deferred-состояниями, применяется сразу. +Persisted no-op и локальная операция fence не создают. Если sync оборвался после swap на +публикации readiness, retry проверяет уже активное соединение и не выполняет второй swap. Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server. Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 9cb01b8..b1e51ba 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -246,6 +246,77 @@ border-color: var(--button-secondary-border); } +.dashboard-check-state { + --check-accent: var(--text-secondary); + --check-surface: rgba(148, 163, 184, 0.08); + --check-border: rgba(148, 163, 184, 0.22); + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.9rem; + align-items: start; + padding: 1rem 1.1rem; + color: var(--text-primary); + background: linear-gradient(115deg, var(--check-surface), transparent 72%); + border: 1px solid var(--check-border); + border-left: 4px solid var(--check-accent); + border-radius: var(--radius-sm); +} + +.dashboard-check-state--success { + --check-accent: var(--success); + --check-surface: rgba(16, 185, 129, 0.1); + --check-border: rgba(16, 185, 129, 0.24); +} + +.dashboard-check-state--warning { + --check-accent: var(--warning); + --check-surface: rgba(245, 158, 11, 0.1); + --check-border: rgba(245, 158, 11, 0.26); +} + +.dashboard-check-state--error { + --check-accent: var(--error); + --check-surface: rgba(239, 68, 68, 0.1); + --check-border: rgba(239, 68, 68, 0.26); +} + +.dashboard-check-state__icon { + display: grid; + place-items: center; + width: 40px; + height: 40px; + color: var(--check-accent); + background: var(--check-surface); + border: 1px solid var(--check-border); + border-radius: var(--radius-sm); +} + +.dashboard-check-state__body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.2rem; +} + +.dashboard-check-state__body strong { + color: var(--check-accent); + font-size: 0.95rem; + line-height: 1.35; +} + +.dashboard-check-state__body > span { + color: var(--text-primary); + font-size: 0.86rem; + line-height: 1.5; +} + +.dashboard-check-state__body small { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.76rem; + line-height: 1.4; +} + @media (max-width: 1200px) { .dashboard-metrics-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); diff --git a/frontend/admin/js/dashboard-conflicts.js b/frontend/admin/js/dashboard-conflicts.js new file mode 100644 index 0000000..1c82680 --- /dev/null +++ b/frontend/admin/js/dashboard-conflicts.js @@ -0,0 +1,214 @@ +export const CONFLICT_CHECK_STATE = Object.freeze({ + COMPLETE: 'COMPLETE', + PARTIAL: 'PARTIAL', + NOT_RUN: 'NOT_RUN' +}); + +export const CONFLICT_CHECK_REASON = Object.freeze({ + NO_DEPARTMENTS: 'NO_DEPARTMENTS', + ALL_REQUESTS_FAILED: 'ALL_REQUESTS_FAILED' +}); + +/** Формирует date-only строку из локальных компонентов без UTC-конвертации. */ +export function formatLocalDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new TypeError('Для форматирования требуется корректная дата'); + } + return [ + String(date.getFullYear()).padStart(4, '0'), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); +} + +/** Возвращает локальные границы недели с понедельника по воскресенье включительно. */ +export function currentWeekDateRange(referenceDate = new Date()) { + if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) { + throw new TypeError('Для расчёта недели требуется корректная дата'); + } + + const localDate = new Date( + referenceDate.getFullYear(), + referenceDate.getMonth(), + referenceDate.getDate() + ); + const dayOfWeek = localDate.getDay(); + const daysFromMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const monday = addLocalDays(localDate, daysFromMonday); + const sunday = addLocalDays(monday, 6); + + return { + startDate: formatLocalDate(monday), + endDate: formatLocalDate(sunday) + }; +} + +/** + * Загружает расписания независимо и сохраняет факт каждого отказа. + * Технические причины намеренно не возвращаются в UI-модель. + */ +export async function loadDepartmentSchedules(departments, loadSchedule) { + if (!Array.isArray(departments)) { + throw new TypeError('Список кафедр должен быть массивом'); + } + if (typeof loadSchedule !== 'function') { + throw new TypeError('Функция загрузки расписания обязательна'); + } + + if (departments.length === 0) { + return { + state: CONFLICT_CHECK_STATE.NOT_RUN, + reason: CONFLICT_CHECK_REASON.NO_DEPARTMENTS, + lessons: [], + totalDepartmentCount: 0, + checkedDepartmentCount: 0, + failedDepartments: [] + }; + } + + const results = await Promise.allSettled(departments.map(async department => { + const lessons = await loadSchedule(department); + if (!Array.isArray(lessons)) { + throw new TypeError('API расписания вернул некорректный формат данных'); + } + return lessons; + })); + + const lessons = []; + const failedDepartments = []; + let checkedDepartmentCount = 0; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + checkedDepartmentCount += 1; + lessons.push(...result.value); + return; + } + failedDepartments.push(publicDepartment(departments[index], index)); + }); + + const state = checkedDepartmentCount === 0 + ? CONFLICT_CHECK_STATE.NOT_RUN + : failedDepartments.length > 0 + ? CONFLICT_CHECK_STATE.PARTIAL + : CONFLICT_CHECK_STATE.COMPLETE; + + return { + state, + reason: state === CONFLICT_CHECK_STATE.NOT_RUN + ? CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED + : null, + lessons, + totalDepartmentCount: departments.length, + checkedDepartmentCount, + failedDepartments + }; +} + +/** Возвращает безопасную UI-модель итогового состояния проверки. */ +export function conflictCheckPresentation(scheduleLoad, conflictCount, range) { + assertPresentationInput(scheduleLoad, conflictCount, range); + + const count = conflictCount; + const startDate = range.startDate; + const endDate = range.endDate; + const rangeLabel = `${startDate} — ${endDate}`; + + if (scheduleLoad?.state === CONFLICT_CHECK_STATE.NOT_RUN) { + const noDepartments = scheduleLoad.reason === CONFLICT_CHECK_REASON.NO_DEPARTMENTS; + return { + tone: 'error', + title: 'Проверка не выполнена', + description: noDepartments + ? 'Нет кафедр, расписание которых можно проверить.' + : 'Не удалось получить расписание ни одной кафедры. Подтвердить отсутствие конфликтов нельзя.', + meta: `Период: ${rangeLabel}` + }; + } + + if (scheduleLoad?.state === CONFLICT_CHECK_STATE.PARTIAL) { + return { + tone: 'warning', + title: 'Проверка выполнена частично', + description: count > 0 + ? 'Показаны найденные конфликты, но подтвердить полноту результата нельзя.' + : 'В доступной части расписания конфликтов не найдено, но подтвердить их отсутствие во всём расписании нельзя.', + meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из ` + + `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}` + }; + } + + if (scheduleLoad?.state === CONFLICT_CHECK_STATE.COMPLETE && count === 0) { + return { + tone: 'success', + title: 'Конфликты расписания не обнаружены', + description: 'Проверены накладки преподавателей и аудиторий, а также вместимость помещений.', + meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из ` + + `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}` + }; + } + + return null; +} + +function addLocalDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +function assertPresentationInput(scheduleLoad, conflictCount, range) { + if (!Number.isInteger(conflictCount) || conflictCount < 0) { + throw new TypeError('Количество конфликтов должно быть целым неотрицательным числом'); + } + + if (!isValidDateOnly(range?.startDate) || !isValidDateOnly(range?.endDate) + || range.startDate > range.endDate) { + throw new TypeError('Для результата проверки требуется корректный локальный диапазон дат'); + } + + const total = scheduleLoad?.totalDepartmentCount; + const checked = scheduleLoad?.checkedDepartmentCount; + if (!Number.isInteger(total) || total < 0 + || !Number.isInteger(checked) || checked < 0 || checked > total) { + throw new TypeError('Количество проверенных кафедр не соответствует результату проверки'); + } + + if (scheduleLoad.state === CONFLICT_CHECK_STATE.COMPLETE) { + if (total === 0 || checked !== total) { + throw new TypeError('Полная проверка должна охватывать все кафедры'); + } + return; + } + + if (scheduleLoad.state === CONFLICT_CHECK_STATE.PARTIAL) { + if (checked === 0 || checked === total) { + throw new TypeError('Частичная проверка должна охватывать только часть кафедр'); + } + return; + } + + if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN && checked === 0) { + return; + } + + throw new TypeError('Неизвестное состояние проверки конфликтов'); +} + +function isValidDateOnly(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ''); + if (!match) return false; + + const [, year, month, day] = match.map(Number); + const date = new Date(year, month - 1, day); + return date.getFullYear() === year + && date.getMonth() === month - 1 + && date.getDate() === day; +} + +function publicDepartment(department, index) { + return { + id: department?.id ?? null, + name: department?.departmentName || department?.name || `Кафедра ${index + 1}` + }; +} diff --git a/frontend/admin/js/views/dashboard.js b/frontend/admin/js/views/dashboard.js index 6b9d8c0..d594266 100644 --- a/frontend/admin/js/views/dashboard.js +++ b/frontend/admin/js/views/dashboard.js @@ -1,5 +1,12 @@ import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +import { escapeHtml } from '../utils.js'; +import { + CONFLICT_CHECK_STATE, + conflictCheckPresentation, + currentWeekDateRange, + formatLocalDate, + loadDepartmentSchedules +} from '../dashboard-conflicts.js'; export async function initDashboard() { // DOM Элементы @@ -20,16 +27,27 @@ export async function initDashboard() { let teachers = []; let todayLessons = []; let timeSlots = []; + let dashboardDataReady = false; btnRecheck.addEventListener('click', async () => { btnRecheck.disabled = true; - btnRecheck.textContent = '...'; - await checkConflicts(); - btnRecheck.textContent = 'Перепроверить'; - btnRecheck.disabled = false; + btnRecheck.setAttribute('aria-busy', 'true'); + btnRecheck.textContent = 'Проверка…'; + try { + if (dashboardDataReady) { + await checkConflicts(); + } else { + await loadData(); + } + } finally { + btnRecheck.textContent = 'Перепроверить'; + btnRecheck.disabled = false; + btnRecheck.removeAttribute('aria-busy'); + } }); async function loadData() { + dashboardDataReady = false; try { // Загружаем базовые справочники [departments, classrooms, groups, teachers] = await Promise.all([ @@ -45,7 +63,7 @@ export async function initDashboard() { metricStudents.textContent = totalStudents; metricTeachers.textContent = teachers.length; - const todayStr = new Date().toISOString().split('T')[0]; + const todayStr = formatLocalDate(new Date()); // Загружаем временные слоты и сегодняшнее расписание const lessonsPromises = departments.map(d => @@ -75,10 +93,18 @@ export async function initDashboard() { metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`; updateRealtimeMonitoring(); + dashboardDataReady = true; await checkConflicts(); } catch (e) { - console.error("Dashboard data load error", e); - conflictsContainer.innerHTML = `
Ошибка загрузки панели: ${escapeHtml(e.message)}
`; + dashboardDataReady = false; + console.error('Не удалось загрузить данные панели', { + errorType: e?.name || 'Error' + }); + conflictsContainer.innerHTML = renderCheckStateCard({ + tone: 'error', + title: 'Проверка не выполнена', + description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.' + }); } } @@ -147,25 +173,25 @@ export async function initDashboard() { // --- Анализ конфликтов (Red Zone) --- async function checkConflicts() { + conflictsContainer.setAttribute('aria-busy', 'true'); conflictsContainer.innerHTML = '
Анализ расписания и выявление конфликтов...
'; - // Вычисляем даты начала и конца текущей недели - const curr = new Date(); - const day = curr.getDay(); - const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник - const monday = new Date(curr.setDate(diff)); - const sunday = new Date(curr.setDate(diff + 6)); - - const mondayStr = monday.toISOString().split('T')[0]; - const sundayStr = sunday.toISOString().split('T')[0]; - try { - // Загружаем расписание на всю неделю по кафедрам - const weekLessonsPromises = departments.map(d => - api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => []) + const { startDate, endDate } = currentWeekDateRange(new Date()); + const scheduleLoad = await loadDepartmentSchedules( + departments, + department => api.get( + `/api/schedule/search?departmentId=${department.id}` + + `&startDate=${startDate}&endDate=${endDate}` + ) ); - const weekLessonsArrays = await Promise.all(weekLessonsPromises); - const weekLessons = weekLessonsArrays.flat(); + + if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) { + renderConflicts([], scheduleLoad, { startDate, endDate }); + return; + } + + const weekLessons = scheduleLoad.lessons; const conflicts = []; @@ -264,31 +290,40 @@ export async function initDashboard() { } }); - renderConflicts(conflicts); + renderConflicts(conflicts, scheduleLoad, { startDate, endDate }); } catch (e) { - console.error("Conflicts check error", e); - conflictsContainer.innerHTML = `
Ошибка при анализе конфликтов: ${escapeHtml(e.message)}
`; + console.error('Не удалось выполнить проверку конфликтов', { + errorType: e?.name || 'Error' + }); + conflictsContainer.innerHTML = renderCheckStateCard({ + tone: 'error', + title: 'Проверка не выполнена', + description: 'Во время анализа произошла ошибка. Повторите проверку позже.' + }); + } finally { + conflictsContainer.removeAttribute('aria-busy'); } } - function renderConflicts(conflicts) { - if (!conflicts.length) { - conflictsContainer.innerHTML = ` -
- - - - -
- Конфликты расписания не обнаружены! - Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют. -
-
- `; + function renderConflicts(conflicts, scheduleLoad, range) { + const stateCards = []; + const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range); + + if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) { + conflictsContainer.innerHTML = renderCheckStateCard(presentation); return; } - conflictsContainer.innerHTML = conflicts.map(c => { + if (presentation) { + stateCards.push(renderCheckStateCard(presentation)); + } + + if (!conflicts.length) { + conflictsContainer.innerHTML = stateCards.join(''); + return; + } + + const conflictCards = conflicts.map(c => { const isError = c.severity === 'error'; const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b'; const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)'; @@ -306,6 +341,27 @@ export async function initDashboard() { `; }).join(''); + conflictsContainer.innerHTML = stateCards.join('') + conflictCards; + } + + function renderCheckStateCard({ tone, title, description, meta = '' }) { + const icon = tone === 'success' + ? '' + : tone === 'warning' + ? '' + : ''; + return ` +
+ + + ${escapeHtml(title)} + ${escapeHtml(description)} + ${meta ? `${escapeHtml(meta)}` : ''} + +
+ `; } // Загружаем данные при старте diff --git a/frontend/admin/views/dashboard.html b/frontend/admin/views/dashboard.html index b3e7dd6..415ed90 100644 --- a/frontend/admin/views/dashboard.html +++ b/frontend/admin/views/dashboard.html @@ -135,10 +135,10 @@
Анализ накладок и коллизий расписания на текущую неделю
- + -
+
Анализ расписания и выявление конфликтов...
diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9db5f87 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,12 @@ +{ + "name": "magistr-frontend-checks", + "private": true, + "type": "module", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test tests/*.test.mjs", + "check": "node --check admin/js/dashboard-conflicts.js && node --check admin/js/views/dashboard.js && node --test tests/*.test.mjs" + } +} diff --git a/frontend/tests/dashboard-conflicts.test.mjs b/frontend/tests/dashboard-conflicts.test.mjs new file mode 100644 index 0000000..a18ff3f --- /dev/null +++ b/frontend/tests/dashboard-conflicts.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CONFLICT_CHECK_REASON, + CONFLICT_CHECK_STATE, + conflictCheckPresentation, + currentWeekDateRange, + formatLocalDate, + loadDepartmentSchedules +} from '../admin/js/dashboard-conflicts.js'; + +process.env.TZ = 'Europe/Moscow'; + +test('неделя для 2026-07-02 охватывает 2026-06-29…2026-07-05', () => { + assert.deepEqual( + currentWeekDateRange(new Date('2026-07-01T21:15:00Z')), + { startDate: '2026-06-29', endDate: '2026-07-05' } + ); +}); + +test('неделя корректно пересекает границу года', () => { + assert.deepEqual( + currentWeekDateRange(new Date(2026, 0, 1, 8, 30, 0)), + { startDate: '2025-12-29', endDate: '2026-01-04' } + ); +}); + +test('date-only формируется из локальных компонентов', () => { + assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02'); +}); + +test('полный успех разрешает достоверный вывод по всем кафедрам', async () => { + const result = await loadDepartmentSchedules( + [{ id: 1 }, { id: 2 }], + async department => [{ departmentId: department.id }] + ); + + assert.equal(result.state, CONFLICT_CHECK_STATE.COMPLETE); + assert.equal(result.checkedDepartmentCount, 2); + assert.equal(result.totalDepartmentCount, 2); + assert.deepEqual(result.lessons, [{ departmentId: 1 }, { departmentId: 2 }]); + assert.deepEqual(result.failedDepartments, []); +}); + +test('единичный отказ даёт частичный результат, а не пустой успешный ответ', async () => { + const departments = [ + { id: 1, departmentName: 'Физика' }, + { id: 2, departmentName: 'Математика' } + ]; + const result = await loadDepartmentSchedules(departments, async department => { + if (department.id === 2) { + throw new Error('тестовая техническая причина'); + } + return [{ departmentId: department.id }]; + }); + + assert.equal(result.state, CONFLICT_CHECK_STATE.PARTIAL); + assert.equal(result.checkedDepartmentCount, 1); + assert.deepEqual(result.lessons, [{ departmentId: 1 }]); + assert.deepEqual(result.failedDepartments, [{ id: 2, name: 'Математика' }]); + assert.equal('reason' in result.failedDepartments[0], false); +}); + +test('отказ всех запросов означает, что проверка не выполнена', async () => { + const result = await loadDepartmentSchedules( + [{ id: 1 }, { id: 2 }], + async () => { throw new Error('тестовый отказ'); } + ); + + assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN); + assert.equal(result.reason, CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED); + assert.equal(result.checkedDepartmentCount, 0); + assert.deepEqual(result.lessons, []); +}); + +test('отсутствие кафедр не изображается как зелёная успешная проверка', async () => { + const result = await loadDepartmentSchedules([], async () => []); + + assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN); + assert.equal(result.reason, CONFLICT_CHECK_REASON.NO_DEPARTMENTS); +}); + +test('зелёная карточка разрешена только для полной проверки без конфликтов', () => { + const complete = { + state: CONFLICT_CHECK_STATE.COMPLETE, + checkedDepartmentCount: 2, + totalDepartmentCount: 2 + }; + const range = { startDate: '2026-06-29', endDate: '2026-07-05' }; + + assert.equal(conflictCheckPresentation(complete, 0, range).tone, 'success'); + assert.equal(conflictCheckPresentation(complete, 1, range), null); +}); + +test('некорректный контракт результата не может породить зелёный статус', () => { + const range = { startDate: '2026-06-29', endDate: '2026-07-05' }; + + assert.throws( + () => conflictCheckPresentation({ + state: CONFLICT_CHECK_STATE.COMPLETE, + checkedDepartmentCount: 1, + totalDepartmentCount: 2 + }, 0, range), + /Полная проверка должна охватывать все кафедры/ + ); + assert.throws( + () => conflictCheckPresentation({ + state: CONFLICT_CHECK_STATE.COMPLETE, + checkedDepartmentCount: 2, + totalDepartmentCount: 2 + }, Number.NaN, range), + /Количество конфликтов должно быть целым неотрицательным числом/ + ); +}); + +test('частичная проверка без конфликтов остаётся предупреждением', () => { + const presentation = conflictCheckPresentation({ + state: CONFLICT_CHECK_STATE.PARTIAL, + checkedDepartmentCount: 1, + totalDepartmentCount: 2 + }, 0, { startDate: '2026-06-29', endDate: '2026-07-05' }); + + assert.equal(presentation.tone, 'warning'); + assert.equal(presentation.title, 'Проверка выполнена частично'); + assert.match(presentation.description, /во всём расписании нельзя/); +}); + +test('частичная проверка с конфликтами предупреждает о неполноте результата', () => { + const presentation = conflictCheckPresentation({ + state: CONFLICT_CHECK_STATE.PARTIAL, + checkedDepartmentCount: 1, + totalDepartmentCount: 3 + }, 2, { startDate: '2026-06-29', endDate: '2026-07-05' }); + + assert.equal(presentation.tone, 'warning'); + assert.match(presentation.description, /Показаны найденные конфликты/); +}); + +test('полный отказ формирует явное состояние «Проверка не выполнена»', () => { + const presentation = conflictCheckPresentation({ + state: CONFLICT_CHECK_STATE.NOT_RUN, + reason: CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED, + checkedDepartmentCount: 0, + totalDepartmentCount: 2 + }, 0, { startDate: '2026-06-29', endDate: '2026-07-05' }); + + assert.equal(presentation.tone, 'error'); + assert.equal(presentation.title, 'Проверка не выполнена'); + assert.match(presentation.description, /Подтвердить отсутствие конфликтов нельзя/); +});