Задача Егора 2ч.

This commit is contained in:
2026-07-17 01:27:02 +03:00
parent 3f685d3e22
commit 3d798c13e3
20 changed files with 2003 additions and 275 deletions

View File

@@ -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. | | 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. | | 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-хеши V1V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. | | 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-хеши V1V4 совпадают. 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. | | 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 30300 секунд и 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. | | 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. | | 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. | | 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
@@ -324,8 +324,8 @@
маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
components, домены, JDBC URL или credentials. components, домены, JDBC URL или credentials.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и - AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
`docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего `docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials
домена явно оставлено проблеме № 13. существующего домена было оставлено проблеме № 13 и устранено следующим этапом.
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись - В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
и Kustomize не запускался. Для production обязательны directory mount без `subPath`, и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness / `TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
@@ -343,18 +343,92 @@
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. | | Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. | | `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 и повторяется с экспоненциальной
задержкой 30300 секунд; новая ревизия обходит 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:<port>`,
совпадающим с 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-ом**. Текущий этап: **№ 12access JWT и удалённый JavaScript в одном origin**.
Следующая операция: Следующая операция:
1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только 1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и
набор доменов; inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации;
2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без 2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly
повторной записи Secret; refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload;
3. считать hash применённым только после полной успешной синхронизации и не подтверждать 3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без
произвольное содержимое mount через `refreshHash()` после API-мутации; `unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки;
4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего 4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP
файла после ошибки и межподового merged snapshot; перед AutoUpdateDocs и изменением статуса № 12.
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.

View File

@@ -3,6 +3,7 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService; import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -10,24 +11,35 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.File; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List; 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). * Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
* Если Secret был обновлён через K8s API, этот компонент * Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
* подхватит изменения и синхронизирует in-memory datasource'ы.
*
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
*/ */
@Component @Component
public class TenantConfigWatcher { public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class); 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 TenantLifecycleService tenantLifecycleService;
private final TenantReadinessRegistry readinessRegistry; private final TenantReadinessRegistry readinessRegistry;
@@ -39,9 +51,23 @@ public class TenantConfigWatcher {
@Value("${app.tenants.config-required:false}") @Value("${app.tenants.config-required:false}")
private boolean tenantsConfigRequired; private boolean tenantsConfigRequired;
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря /** Хеш означает только полностью применённое содержимое файла. */
private String lastConfigHash = ""; private String lastConfigHash = "";
private ConfigSnapshot lastAppliedSnapshot;
private String failedRevisionKey = "";
private int consecutiveFailures;
private long retryNotBeforeMillis;
/**
* Fence хранит семантические снимки, а не байтовые представления JSON.
* Поэтому форматирование файла не может ошибочно снять или установить fence.
*/
private ConfigSnapshot expectedCommittedSnapshot;
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
private boolean configurationUnavailable; private boolean configurationUnavailable;
private LongSupplier currentTimeMillis = System::currentTimeMillis;
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService, public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
TenantReadinessRegistry readinessRegistry) { TenantReadinessRegistry readinessRegistry) {
@@ -49,73 +75,135 @@ public class TenantConfigWatcher {
this.readinessRegistry = readinessRegistry; this.readinessRegistry = readinessRegistry;
} }
/**
* Каждые 30 секунд проверяет, изменился ли tenants.json.
*/
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000) @Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() { public void watchForChanges() {
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized); tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
} }
private void watchForChangesSerialized() { private void watchForChangesSerialized() {
try { long now = currentTimeMillis.getAsLong();
File file = new File(tenantsConfigPath); Path path = Path.of(tenantsConfigPath);
if (!file.exists()) { if (!Files.exists(path)) {
if (tenantsConfigRequired) { if (tenantsConfigRequired) {
readinessRegistry.markConfigurationFailure(); handleRequiredMissingFile(now);
if (!configurationUnavailable) {
log.error("Обязательный файл конфигурации тенантов недоступен");
}
configurationUnavailable = true;
} }
return; return;
} }
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); if (isRetryDeferred(READ_FAILURE_KEY, now)) {
String hash = configHash(content); logDeferredRetry(now);
return;
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
return; // Ничего не изменилось
} }
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash); String failureKey = READ_FAILURE_KEY;
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {}); try {
syncTenants(newTenants); String content = Files.readString(path, StandardCharsets.UTF_8);
lastConfigHash = hash; String hash = configHash(content);
configurationUnavailable = false; failureKey = hash;
Projection projection = parseProjection(content, hash);
} catch (Exception e) { // Даже неизменившееся содержимое сначала разбирается: fence никогда не
readinessRegistry.markConfigurationFailure(); // принимает решение по сырым байтам до проверки структуры JSON.
configurationUnavailable = true; if (hash.equals(lastConfigHash) && !configurationUnavailable) {
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName()); clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
log.debug("Технические детали синхронизации конфига тенантов", e); return;
}
if (isRetryDeferred(hash, now)) {
logDeferredRetry(now);
return;
}
if (!hash.equals(failedRevisionKey)) {
resetRetryState();
}
if (shouldDefer(projection.snapshot())) {
return;
}
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
syncTenants(projection.tenants());
markApplied(projection);
} catch (Exception failure) {
recordFailure(failureKey, failure);
} }
} }
/** /**
* Обновляет хеш конфига после ручного обновления Secret с этого же пода. * Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
* с неизвестного baseline.
*/ */
public void refreshHash() { public void prepareForApiMutation() {
tenantLifecycleService.executeSerialized(this::refreshHashSerialized); tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
} }
private void refreshHashSerialized() { private void prepareForApiMutationSerialized() {
Path path = Path.of(tenantsConfigPath);
if (!Files.exists(path)) {
if (!tenantsConfigRequired) {
return;
}
TenantLifecycleException failure = new TenantLifecycleException(
"Обязательный файл конфигурации тенантов недоступен"
);
recordFailure(MISSING_FAILURE_KEY, failure);
throw failure;
}
String failureKey = READ_FAILURE_KEY;
try { try {
File file = new File(tenantsConfigPath); String content = Files.readString(path, StandardCharsets.UTF_8);
if (file.exists()) { String hash = configHash(content);
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); failureKey = hash;
lastConfigHash = configHash(content); Projection projection = parseProjection(content, hash);
} else if (tenantsConfigRequired) {
readinessRegistry.markConfigurationFailure(); if (hash.equals(lastConfigHash) && !configurationUnavailable) {
configurationUnavailable = true; clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
return;
} }
} catch (Exception e) { if (shouldDefer(projection.snapshot())) {
readinessRegistry.markConfigurationFailure(); return;
configurationUnavailable = true;
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали обновления хеша тенантов", e);
} }
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) { static String configHash(String content) {
@@ -127,11 +215,158 @@ public class TenantConfigWatcher {
} }
} }
/** private Projection parseProjection(String content, String hash) throws IOException {
* Синхронизирует in-memory тенантов с конфигом из файла. List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
*/ if (tenants == null) {
private void syncTenants(List<TenantConfig> newTenants) { throw new IllegalArgumentException(
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants); "Корневое значение конфигурации тенантов должно быть массивом"
);
}
if (tenantsConfigRequired && tenants.isEmpty()) {
throw new IllegalArgumentException(
"Обязательная конфигурация тенантов не может быть пустой"
);
}
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
} }
private ConfigSnapshot snapshotOf(List<TenantConfig> tenants) {
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
List<NormalizedTenantConfig> 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<TenantConfig> 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<TenantConfig> tenants, ConfigSnapshot snapshot) {
}
private record ConfigSnapshot(List<NormalizedTenantConfig> tenants) {
private ConfigSnapshot {
tenants = List.copyOf(tenants);
}
}
private record NormalizedTenantConfig(
String name,
String domain,
String url,
String username,
String password
) {
private static final Comparator<String> NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo);
private static final Comparator<NormalizedTenantConfig> 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);
}
} }

View File

@@ -7,6 +7,7 @@ import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource; import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.service.TenantLifecycleException; import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleMutationResult;
import com.magistr.app.service.TenantLifecycleService; import com.magistr.app.service.TenantLifecycleService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -90,8 +91,11 @@ public class DatabaseController {
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) { public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
try { try {
return tenantLifecycleService.executeSerialized(() -> { return tenantLifecycleService.executeSerialized(() -> {
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config); tenantConfigWatcher.prepareForApiMutation();
tenantConfigWatcher.refreshHash(); TenantLifecycleMutationResult mutation =
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig saved = mutation.tenant();
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("success", true); result.put("success", true);
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен"); result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
@@ -111,8 +115,11 @@ public class DatabaseController {
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) { public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
try { try {
return tenantLifecycleService.executeSerialized(() -> { return tenantLifecycleService.executeSerialized(() -> {
TenantConfig removed = tenantLifecycleService.removeTenant(domain); tenantConfigWatcher.prepareForApiMutation();
tenantConfigWatcher.refreshHash(); TenantLifecycleMutationResult mutation =
tenantLifecycleService.removeTenantWithReceipt(domain);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig removed = mutation.tenant();
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("success", true); result.put("success", true);
result.put("message", "Тенант '" + removed.getDomain() + "' удалён"); result.put("message", "Тенант '" + removed.getDomain() + "' удалён");

View File

@@ -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-мутации тенанта вместе с квитанцией персистенции.
*
* <p>Конфигурация копируется при создании и чтении, чтобы вызывающий код не мог изменить
* уже активированный runtime-снимок через изменяемый DTO.</p>
*/
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()
);
}
}

View File

@@ -21,10 +21,9 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Set; import java.util.Objects;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors;
@Service @Service
public class TenantLifecycleService { public class TenantLifecycleService {
@@ -72,6 +71,13 @@ public class TenantLifecycleService {
* Добавляет или обновляет tenant без разрыва действующего подключения. * Добавляет или обновляет tenant без разрыва действующего подключения.
*/ */
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) { public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
return addOrUpdateTenantWithReceipt(requestedConfig).tenant();
}
/**
* Добавляет или обновляет tenant и возвращает подтверждённый результат персистенции.
*/
public synchronized TenantLifecycleMutationResult addOrUpdateTenantWithReceipt(TenantConfig requestedConfig) {
TenantConfig config = validateAndNormalize(requestedConfig); TenantConfig config = validateAndNormalize(requestedConfig);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs(); Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs); Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
@@ -96,7 +102,7 @@ public class TenantLifecycleService {
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config); publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
retireDataSource(previous == null ? null : previous.dataSource()); retireDataSource(previous == null ? null : previous.dataSource());
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain()); log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
return copyConfig(config); return new TenantLifecycleMutationResult(config, receipt);
} finally { } finally {
if (!activated) { if (!activated) {
closeCandidateDataSource(candidate); closeCandidateDataSource(candidate);
@@ -108,6 +114,13 @@ public class TenantLifecycleService {
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant. * Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
*/ */
public synchronized TenantConfig removeTenant(String requestedDomain) { public synchronized TenantConfig removeTenant(String requestedDomain) {
return removeTenantWithReceipt(requestedDomain).tenant();
}
/**
* Удаляет tenant и возвращает подтверждённый результат персистенции.
*/
public synchronized TenantLifecycleMutationResult removeTenantWithReceipt(String requestedDomain) {
String domain = validateAndNormalizeDomain(requestedDomain); String domain = validateAndNormalizeDomain(requestedDomain);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs(); Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
TenantConfig existing = oldConfigs.get(domain); TenantConfig existing = oldConfigs.get(domain);
@@ -132,13 +145,14 @@ public class TenantLifecycleService {
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null); publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
retireDataSource(removed.dataSource()); retireDataSource(removed.dataSource());
log.info("Lifecycle тенанта '{}' удалён", domain); log.info("Lifecycle тенанта '{}' удалён", domain);
return copyConfig(existing); return new TenantLifecycleMutationResult(existing, receipt);
} }
/** /**
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет * Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
* Secret повторно и использует тот же monitor, что ручные API add/update/remove. * Secret повторно и использует тот же monitor, что ручные API add/update/remove.
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13. * Для нового или изменённого домена подключение сначала полностью подготавливается,
* проверяется и мигрируется, после чего атомарно заменяет прежний runtime-снимок.
*/ */
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) { public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
if (requestedConfigs == null) { if (requestedConfigs == null) {
@@ -146,47 +160,55 @@ public class TenantLifecycleService {
} }
List<TenantConfig> configs = requestedConfigs.stream() List<TenantConfig> configs = requestedConfigs.stream()
.map(this::validateAndNormalize) .map(this::validateAndNormalize)
.sorted(Comparator.comparing(TenantConfig::getDomain))
.toList(); .toList();
Set<String> desiredDomains = configs.stream()
.map(TenantConfig::getDomain)
.collect(Collectors.toSet());
readinessRegistry.clearConfigurationFailure(); Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>();
readinessRegistry.replaceDesired(configs); for (TenantConfig config : configs) {
if (desiredDomains.size() != configs.size()) { if (desiredConfigs.put(config.getDomain(), config) != null) {
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов"); throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
} }
}
Map<String, TenantConfig> currentConfigs = routingDataSource.snapshotTenantConfigs();
readinessRegistry.replaceDesired(configs);
for (TenantConfig config : configs) { for (TenantConfig config : configs) {
if (!routingDataSource.hasTenant(config.getDomain())) { TenantConfig currentConfig = currentConfigs.get(config.getDomain());
if (currentConfig == null) {
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain()); log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config); activatePersistedConfig(config);
} else if (!sameConfig(currentConfig, config)) {
log.info("Заменяем подключение тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
} else {
recoverPersistedReadinessIfNeeded(config);
} }
} }
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) { for (String existingDomain : new ArrayList<>(currentConfigs.keySet())) {
if (!desiredDomains.contains(existingDomain)) { if (!desiredConfigs.containsKey(existingDomain)) {
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain); log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
TenantRoutingDataSource.TenantState removed = TenantRoutingDataSource.TenantState removed =
routingDataSource.removeTenantAtomically(existingDomain); routingDataSource.removeTenantAtomically(existingDomain);
retireDataSource(removed == null ? null : removed.dataSource()); retireDataSource(removed == null ? null : removed.dataSource());
} }
} }
readinessRegistry.clearConfigurationFailure();
} }
private void activatePersistedConfig(TenantConfig config) { private void activatePersistedConfig(TenantConfig config) {
HikariDataSource candidate = null; HikariDataSource candidate = null;
boolean activated = false; boolean activated = false;
TenantRoutingDataSource.TenantState previous;
readinessRegistry.markMigrationStarted(config); readinessRegistry.markMigrationStarted(config);
try { try {
candidate = prepareCandidate(config); candidate = prepareCandidate(config);
verifyConnection(candidate); verifyConnection(candidate);
migrateCandidate(candidate); migrateCandidate(candidate);
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate); previous = routingDataSource.swapTenant(config, candidate);
activated = true; activated = true;
readinessRegistry.markMigrationSucceeded(config);
readinessRegistry.markConnectivity(config.getDomain(), true);
retireDataSource(previous == null ? null : previous.dataSource());
} catch (RuntimeException activationFailure) { } catch (RuntimeException activationFailure) {
readinessRegistry.markMigrationFailed(config); readinessRegistry.markMigrationFailed(config);
throw activationFailure; throw activationFailure;
@@ -195,6 +217,29 @@ public class TenantLifecycleService {
closeCandidateDataSource(candidate); 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) { 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) { private void retireDataSource(DataSource dataSource) {
if (dataSource != null) { if (dataSource != null) {
try {
retiredPoolService.retire(dataSource); retiredPoolService.retire(dataSource);
} catch (RuntimeException retirementFailure) {
logTechnicalFailure("Не удалось передать прежний tenant pool на отложенное закрытие",
retirementFailure);
}
} }
} }

View File

@@ -511,7 +511,10 @@ class KubernetesTenantSecretUpdaterTest {
files.tokenPath(), files.tokenPath(),
files.namespacePath(), files.namespacePath(),
files.caPath(), files.caPath(),
server.url("/").toString(), // MockWebServer на Windows может вернуть canonical loopback hostname
// kubernetes.docker.internal. Фиксируем имя из SAN тестового сертификата,
// сохраняя реальную проверку hostname в клиенте.
"https://localhost:" + server.getPort(),
"tenants-secret", "tenants-secret",
objectMapper, objectMapper,
maxAttempts, maxAttempts,

View File

@@ -1,33 +1,35 @@
package com.magistr.app.config.tenant; package com.magistr.app.config.tenant;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.RetiredTenantPoolService; import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.magistr.app.service.TenantLifecycleService; import com.magistr.app.service.TenantLifecycleService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir; 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 org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.CountDownLatch; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.Executors; import java.util.function.LongSupplier;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat; 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.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
@ExtendWith(OutputCaptureExtension.class)
class TenantConfigWatcherTest { class TenantConfigWatcherTest {
@TempDir @TempDir
@@ -48,29 +50,62 @@ class TenantConfigWatcherTest {
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b")); .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 @Test
void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception { void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json"); Path configPath = tempDirectory.resolve("tenants.json");
String content = """ String content = json("alpha");
[{
"name": "Тестовый tenant",
"domain": "alpha",
"url": "jdbc:postgresql://db/alpha",
"username": "alpha",
"password": "тестовый-пароль"
}]
""";
Files.writeString(configPath, content); Files.writeString(configPath, content);
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
doThrow(new IllegalStateException("тестовая ошибка синхронизации")) doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
.doNothing() .doNothing()
.when(lifecycleService).synchronizeFromPersistedConfig(anyList()); .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
TenantConfigWatcher watcher = watcher(lifecycleService, configPath); AtomicLong now = new AtomicLong(1_000L);
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);
watcher.watchForChanges(); watcher.watchForChanges();
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo(""); assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
watcher.watchForChanges(); watcher.watchForChanges();
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")) assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
.isEqualTo(TenantConfigWatcher.configHash(content)); .isEqualTo(TenantConfigWatcher.configHash(content));
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList()); verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
@@ -80,112 +115,441 @@ class TenantConfigWatcherTest {
} }
@Test @Test
void successfulHashPreventsRepeatedSynchronizationOfSameContent() throws Exception { void retriesSameRevisionOnlyAfterBackoffAndResetsStateAfterSuccess() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json"); Path configPath = tempDirectory.resolve("tenants.json");
String content = "[]"; String content = "[]";
Files.writeString(configPath, content); Files.writeString(configPath, content);
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class); TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
doNothing().when(lifecycleService).synchronizeFromPersistedConfig(anyList()); doThrow(new IllegalStateException("первая тестовая ошибка"))
TenantConfigWatcher watcher = watcher(lifecycleService, configPath); .doThrow(new IllegalStateException("вторая тестовая ошибка"))
.doNothing()
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
AtomicLong now = new AtomicLong(10_000L);
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);
watcher.watchForChanges(); watcher.watchForChanges();
watcher.watchForChanges(); watcher.watchForChanges();
verify(lifecycleService).synchronizeFromPersistedConfig(anyList()); 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")) assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
.isEqualTo(TenantConfigWatcher.configHash(content)); .isEqualTo(TenantConfigWatcher.configHash(content));
} }
@Test @Test
void watcherCannotReadBetweenSerializedApiMutationAndRefreshHash() throws Exception { void retryDelayGrowsExponentiallyAndIsCapped() {
Path configPath = tempDirectory.resolve("tenants.json"); assertThat(TenantConfigWatcher.retryDelayMillis(1)).isEqualTo(30_000L);
String content = "[]"; assertThat(TenantConfigWatcher.retryDelayMillis(2)).isEqualTo(60_000L);
Files.writeString(configPath, content); assertThat(TenantConfigWatcher.retryDelayMillis(3)).isEqualTo(120_000L);
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); assertThat(TenantConfigWatcher.retryDelayMillis(4)).isEqualTo(240_000L);
TenantLifecycleService lifecycleService = new TenantLifecycleService( assertThat(TenantConfigWatcher.retryDelayMillis(5)).isEqualTo(300_000L);
routingDataSource, assertThat(TenantConfigWatcher.retryDelayMillis(31)).isEqualTo(300_000L);
mock(TenantConfigStore.class),
mock(TenantDatabaseMigrationService.class),
mock(RetiredTenantPoolService.class),
mock(TenantReadinessRegistry.class)
);
TenantConfigWatcher watcher = new TenantConfigWatcher(
lifecycleService,
mock(TenantReadinessRegistry.class)
);
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
CountDownLatch allowApiRefresh = new CountDownLatch(1);
AtomicReference<Thread> 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();
Future<?> concurrentWatcher = executor.submit(() -> {
watcherThread.set(Thread.currentThread());
watcher.watchForChanges();
});
awaitBlocked(watcherThread);
assertThat(concurrentWatcher).isNotDone();
allowApiRefresh.countDown();
api.get(5, TimeUnit.SECONDS);
concurrentWatcher.get(5, TimeUnit.SECONDS);
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();
} }
@Test
void newRevisionBypassesBackoffImmediately() throws Exception {
Path configPath = tempDirectory.resolve("tenants.json");
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,
tempDirectory.resolve("tenants.json")
);
watcher.registerSuccessfulMutation(receipt(
false,
true,
tenants("alpha"),
tenants("alpha", "beta")
));
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
}
@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")
));
Files.writeString(configPath, json("alpha", "gamma"));
watcher.watchForChanges();
ArgumentCaptor<List<TenantConfig>> 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<List<TenantConfig>> 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<List<TenantConfig>> 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) { 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 -> { doAnswer(invocation -> {
invocation.<Runnable>getArgument(0).run(); invocation.<Runnable>getArgument(0).run();
return null; return null;
}).when(lifecycleService).executeSerialized(any(Runnable.class)); }).when(lifecycleService).executeSerialized(any(Runnable.class));
TenantConfigWatcher watcher = new TenantConfigWatcher( TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService, readinessRegistry);
lifecycleService,
mock(TenantReadinessRegistry.class)
);
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString()); ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
ReflectionTestUtils.setField(watcher, "currentTimeMillis", (LongSupplier) now::get);
return watcher; return watcher;
} }
private void await(CountDownLatch latch, String message) { private TenantSecretUpdateReceipt receipt(boolean persisted,
try { boolean changed,
if (!latch.await(5, TimeUnit.SECONDS)) { List<TenantConfig> previous,
throw new IllegalStateException(message); List<TenantConfig> committed) {
} return new TenantSecretUpdateReceipt(
} catch (InterruptedException exception) { persisted,
Thread.currentThread().interrupt(); changed,
throw new IllegalStateException(message, exception); persisted ? "resource-version" : null,
} previous,
committed
);
} }
private void awaitBlocked(AtomicReference<Thread> threadReference) { private List<TenantConfig> tenants(String... domains) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); return java.util.Arrays.stream(domains).map(this::tenant).toList();
while (System.nanoTime() < deadline) {
Thread thread = threadReference.get();
if (thread != null && thread.getState() == Thread.State.BLOCKED) {
return;
} }
try {
Thread.sleep(10); private TenantConfig tenant(String domain) {
} catch (InterruptedException exception) { return new TenantConfig(
Thread.currentThread().interrupt(); domain,
throw new IllegalStateException("Ожидание блокировки watcher прервано", exception); domain,
} "jdbc:postgresql://db/" + domain,
} domain,
throw new IllegalStateException("Watcher не заблокировался на общем lifecycle coordinator"); "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<List<TenantConfig>> tenantListCaptor() {
return (ArgumentCaptor) ArgumentCaptor.forClass(List.class);
} }
} }

View File

@@ -1,23 +1,32 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.tenant.TenantConfigWatcher; 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.TenantRoutingDataSource;
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
import com.magistr.app.service.TenantLifecycleException; import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleMutationResult;
import com.magistr.app.service.TenantLifecycleService; import com.magistr.app.service.TenantLifecycleService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.any; 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.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -68,7 +77,128 @@ class DatabaseControllerTest {
.andExpect(jsonPath("$.message") .andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC")))); .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)); 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.<Supplier<?>>getArgument(0).get())
.when(lifecycleService)
.executeSerialized(org.mockito.ArgumentMatchers.<Supplier<Object>>any());
}
private TenantSecretUpdateReceipt receipt(TenantConfig... tenants) {
return new TenantSecretUpdateReceipt(
true,
true,
"resource-version",
List.of(),
List.of(tenants)
);
}
} }

View File

@@ -178,6 +178,52 @@ class TenantLifecyclePostgreSqlIntegrationTest {
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE); 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) { private void awaitPoolClosed(HikariDataSource pool) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (!pool.isClosed() && System.nanoTime() < deadline) { while (!pool.isClosed() && System.nanoTime() < deadline) {
@@ -218,6 +264,7 @@ class TenantLifecyclePostgreSqlIntegrationTest {
private void createDatabase(String databaseName) throws Exception { private void createDatabase(String databaseName) throws Exception {
try (Connection connection = POSTGRES.createConnection(""); try (Connection connection = POSTGRES.createConnection("");
Statement statement = connection.createStatement()) { Statement statement = connection.createStatement()) {
statement.execute("DROP DATABASE IF EXISTS " + databaseName + " WITH (FORCE)");
statement.execute("CREATE DATABASE " + databaseName); statement.execute("CREATE DATABASE " + databaseName);
} }
} }

View File

@@ -11,6 +11,9 @@ import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.ArgumentCaptor;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.Mock; import org.mockito.Mock;
@@ -20,17 +23,21 @@ import org.mockito.quality.Strictness;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -128,6 +135,25 @@ class TenantLifecycleServiceTest {
verify(candidate, never()).close(); 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 @Test
void addsNewTenantWithoutRetiringExistingPool() { void addsNewTenantWithoutRetiringExistingPool() {
Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>(); Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
@@ -296,6 +322,26 @@ class TenantLifecycleServiceTest {
order.verify(retiredPoolService).retire(oldDataSource); 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 @Test
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() { void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
TenantSecretUpdateReceipt receipt = removeReceipt("alpha"); TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
@@ -314,22 +360,156 @@ class TenantLifecycleServiceTest {
@Test @Test
void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() { void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
when(routingDataSource.hasTenant("gamma")).thenReturn(false);
when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null); when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
service.synchronizeFromPersistedConfig(List.of(gamma)); service.synchronizeFromPersistedConfig(List.of(gamma));
InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource); InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
order.verify(readinessRegistry).clearConfigurationFailure();
order.verify(readinessRegistry).replaceDesired(any()); order.verify(readinessRegistry).replaceDesired(any());
order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class)); order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
order.verify(migrationService).migrate(candidate); order.verify(migrationService).migrate(candidate);
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class)); order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
order.verify(readinessRegistry).markConnectivity("gamma", true); order.verify(readinessRegistry).markConnectivity("gamma", true);
order.verify(readinessRegistry).clearConfigurationFailure();
verify(tenantConfigStore, never()).upsertTenant(any()); 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<TenantConfig> 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<String, TenantConfig> 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 @Test
void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() { void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); 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"); return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
} }
private static Stream<Arguments> 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) { private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot); Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
committed.put(tenant.getDomain(), tenant); committed.put(tenant.getDomain(), tenant);

View File

@@ -1190,16 +1190,20 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
него используется нормализованный `domain`. него используется нормализованный `domain`.
**Логика:** **Логика:**
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору. 1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию
2. Открывает соединение и явно проверяет его готовность. tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`.
3. Выполняет Flyway-валидацию и миграции tenant-БД. 2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и 3. Открывает соединение и явно проверяет его готовность.
4. Выполняет Flyway-валидацию и миграции tenant-БД.
5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`. выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным 6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
retry/backoff; неизменившаяся конфигурация не записывается повторно. retry/backoff; неизменившаяся конфигурация не записывается повторно.
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`. 7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов 8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
либо по истечении защитного таймаута. либо по истечении защитного таймаута.
9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой
`TenantSecretUpdateReceipt` для согласования mounted-проекции.
Backend соединяется с Kubernetes API только через проверенный service-account CA и Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
@@ -1236,6 +1240,15 @@ HTTP-статусы: `400` для некорректного payload и `503` д
компенсация также допускается только для подтверждённой версии и не затирает более новое компенсация также допускается только для подтверждённой версии и не затирает более новое
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `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` ### `POST /api/database/test`
Тест подключения к произвольной БД (без регистрации тенанта). Тест подключения к произвольной БД (без регистрации тенанта).

View File

@@ -81,7 +81,7 @@ sequenceDiagram
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` | | `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое | | `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | | `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff |
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации | | `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA | | `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе | | `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
@@ -128,10 +128,11 @@ sequenceDiagram
### Жизненный цикл тенанта ### Жизненный цикл тенанта
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию, 1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает,
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline.
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт
upsert запрошенного домена. непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а
`TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена.
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего 2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект `tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
@@ -140,6 +141,9 @@ sequenceDiagram
не отправляет лишний `PUT`. не отправляет лишний `PUT`.
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным 3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`. чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией
и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также
семантические снимки `previousTenants` и `committedTenants`.
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret 4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
@@ -151,7 +155,10 @@ sequenceDiagram
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
настраиваемого grace timeout. настраиваемого grace timeout.
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный 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 только 7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
@@ -171,13 +178,25 @@ monitor по-прежнему не считается межподовой бл
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом. завершает персистенцию безопасным отказом.
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по `TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие, снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым
но ещё не заменяет подключение существующего домена при изменении его URL или credentials. только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш,
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое снимает readiness и запускает экспоненциальные повторы через 30300 секунд; другая неудачная
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться файловая ревизия получает собственную попытку без ожидания 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 ### Liveness и readiness

View File

@@ -16,6 +16,9 @@
``` ```
frontend/ frontend/
├── package.json # Dependency-free проверки frontend через node:test
├── tests/
│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
├── index.html # 🔐 Страница авторизации (общая) ├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации ├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации ├── style.css # Стили страницы авторизации
@@ -34,6 +37,7 @@ frontend/
│ ├── js/ │ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод) │ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
@@ -125,6 +129,7 @@ frontend/
| Tab | Описание | API | | 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` | | `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` | | `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `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`. - Вкладка `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-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `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:0003:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
Команды выполняются из каталога `frontend/`:
```bash
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
npm run check # синтаксис модулей дашборда + unit-тесты
```
---
## Аутентификация (Frontend) ## Аутентификация (Frontend)
### Страница входа (`/index.html`) ### Страница входа (`/index.html`)

View File

@@ -126,16 +126,24 @@ env:
снимают readiness; резервная H2-БД не считается готовой tenant-БД. снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API: При добавлении тенанта через API:
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway 1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
только upsert запрошенного домена только upsert запрошенного домена
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`; 4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо 5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
подтверждённой повторным чтением персистенции подтверждённой повторным чтением персистенции
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout 6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд 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 только Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
@@ -147,10 +155,17 @@ backend восстанавливает прежний снимок только
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат. состояние принимается без квитанции, допускающей небезопасный автоматический откат.
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials. Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех повторяется с экспоненциальной задержкой от 30 до 300 секунд.
полей `TenantConfig`.
Защита от задержки 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. Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только

View File

@@ -246,6 +246,77 @@
border-color: var(--button-secondary-border); 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) { @media (max-width: 1200px) {
.dashboard-metrics-grid { .dashboard-metrics-grid {
grid-template-columns: repeat(2, minmax(180px, 1fr)); grid-template-columns: repeat(2, minmax(180px, 1fr));

View File

@@ -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}`
};
}

View File

@@ -1,5 +1,12 @@
import { api } from '../api.js'; 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() { export async function initDashboard() {
// DOM Элементы // DOM Элементы
@@ -20,16 +27,27 @@ export async function initDashboard() {
let teachers = []; let teachers = [];
let todayLessons = []; let todayLessons = [];
let timeSlots = []; let timeSlots = [];
let dashboardDataReady = false;
btnRecheck.addEventListener('click', async () => { btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true; btnRecheck.disabled = true;
btnRecheck.textContent = '...'; btnRecheck.setAttribute('aria-busy', 'true');
btnRecheck.textContent = 'Проверка…';
try {
if (dashboardDataReady) {
await checkConflicts(); await checkConflicts();
} else {
await loadData();
}
} finally {
btnRecheck.textContent = 'Перепроверить'; btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false; btnRecheck.disabled = false;
btnRecheck.removeAttribute('aria-busy');
}
}); });
async function loadData() { async function loadData() {
dashboardDataReady = false;
try { try {
// Загружаем базовые справочники // Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([ [departments, classrooms, groups, teachers] = await Promise.all([
@@ -45,7 +63,7 @@ export async function initDashboard() {
metricStudents.textContent = totalStudents; metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length; metricTeachers.textContent = teachers.length;
const todayStr = new Date().toISOString().split('T')[0]; const todayStr = formatLocalDate(new Date());
// Загружаем временные слоты и сегодняшнее расписание // Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d => const lessonsPromises = departments.map(d =>
@@ -75,10 +93,18 @@ export async function initDashboard() {
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`; metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring(); updateRealtimeMonitoring();
dashboardDataReady = true;
await checkConflicts(); await checkConflicts();
} catch (e) { } catch (e) {
console.error("Dashboard data load error", e); dashboardDataReady = false;
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`; console.error('Не удалось загрузить данные панели', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
});
} }
} }
@@ -147,25 +173,25 @@ export async function initDashboard() {
// --- Анализ конфликтов (Red Zone) --- // --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() { async function checkConflicts() {
conflictsContainer.setAttribute('aria-busy', 'true');
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>'; conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
// Вычисляем даты начала и конца текущей недели
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 { try {
// Загружаем расписание на всю неделю по кафедрам const { startDate, endDate } = currentWeekDateRange(new Date());
const weekLessonsPromises = departments.map(d => const scheduleLoad = await loadDepartmentSchedules(
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => []) 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 = []; const conflicts = [];
@@ -264,31 +290,40 @@ export async function initDashboard() {
} }
}); });
renderConflicts(conflicts); renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
} catch (e) { } catch (e) {
console.error("Conflicts check error", e); console.error('Не удалось выполнить проверку конфликтов', {
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`; errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
});
} finally {
conflictsContainer.removeAttribute('aria-busy');
} }
} }
function renderConflicts(conflicts) { function renderConflicts(conflicts, scheduleLoad, range) {
if (!conflicts.length) { const stateCards = [];
conflictsContainer.innerHTML = ` const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /> conflictsContainer.innerHTML = renderCheckStateCard(presentation);
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div>
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
</div>
</div>
`;
return; 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 isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b'; const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)'; const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
@@ -306,6 +341,27 @@ export async function initDashboard() {
</div> </div>
`; `;
}).join(''); }).join('');
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
}
function renderCheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? '<path d="M20 6 9 17l-5-5" />'
: tone === 'warning'
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
return `
<section class="dashboard-check-state dashboard-check-state--${tone}">
<span class="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
</span>
<span class="dashboard-check-state__body">
<strong>${escapeHtml(title)}</strong>
<span>${escapeHtml(description)}</span>
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
</span>
</section>
`;
} }
// Загружаем данные при старте // Загружаем данные при старте

View File

@@ -135,10 +135,10 @@
</h2> </h2>
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div> <div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
</div> </div>
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts">Перепроверить</button> <button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts" type="button">Перепроверить</button>
</div> </div>
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;"> <div id="conflicts-container" role="status" aria-live="polite" aria-atomic="true" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div> <div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
</div> </div>
</div> </div>

12
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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, /Подтвердить отсутствие конфликтов нельзя/);
});