Задача Егора 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. |
| 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` отсутствует. |
| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. |
| 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. |
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. |
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 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. |
| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. |
| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
@@ -324,8 +324,8 @@
маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
components, домены, JDBC URL или credentials.
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
`docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего
домена явно оставлено проблеме № 13.
`docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials
существующего домена было оставлено проблеме № 13 и устранено следующим этапом.
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
@@ -343,18 +343,92 @@
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. |
### № 13 — полная и повторяемая синхронизация tenant-конфигурации
- `TenantLifecycleService.synchronizeFromPersistedConfig()` валидирует весь входной список,
отклоняет нормализованные дубли до изменения runtime/readiness и сравнивает `name`, `domain`,
`url`, `username`, `password`. Новый или изменённый tenant проходит candidate prepare,
проверку соединения, Flyway и атомарный swap; persisted-путь не вызывает `TenantConfigStore`.
- Hash mounted `tenants.json` становится применённым только после успешного разбора и полной
lifecycle-синхронизации. Ошибка сохраняет прежний hash и повторяется с экспоненциальной
задержкой 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, а не только
набор доменов;
2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без
повторной записи Secret;
3. считать hash применённым только после полной успешной синхронизации и не подтверждать
произвольное содержимое mount через `refreshHash()` после API-мутации;
4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего
файла после ошибки и межподового merged snapshot;
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.
1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и
inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации;
2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly
refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload;
3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без
`unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки;
4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP
перед AutoUpdateDocs и изменением статуса № 12.

View File

@@ -3,6 +3,7 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -10,24 +11,35 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.LongSupplier;
/**
* Периодически перечитывает tenants.json (mounted Secret).
* Если Secret был обновлён через K8s API, этот компонент
* подхватит изменения и синхронизирует in-memory datasource'ы.
*
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
* Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
* Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
*/
@Component
public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private static final String MISSING_FAILURE_KEY = "missing";
private static final String READ_FAILURE_KEY = "read";
static final long RETRY_BASE_DELAY_MILLIS = 30_000L;
static final long RETRY_MAX_DELAY_MILLIS = 300_000L;
private final TenantLifecycleService tenantLifecycleService;
private final TenantReadinessRegistry readinessRegistry;
@@ -39,9 +51,23 @@ public class TenantConfigWatcher {
@Value("${app.tenants.config-required:false}")
private boolean tenantsConfigRequired;
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
/** Хеш означает только полностью применённое содержимое файла. */
private String lastConfigHash = "";
private ConfigSnapshot lastAppliedSnapshot;
private String failedRevisionKey = "";
private int consecutiveFailures;
private long retryNotBeforeMillis;
/**
* Fence хранит семантические снимки, а не байтовые представления JSON.
* Поэтому форматирование файла не может ошибочно снять или установить fence.
*/
private ConfigSnapshot expectedCommittedSnapshot;
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
private boolean configurationUnavailable;
private LongSupplier currentTimeMillis = System::currentTimeMillis;
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
TenantReadinessRegistry readinessRegistry) {
@@ -49,73 +75,135 @@ public class TenantConfigWatcher {
this.readinessRegistry = readinessRegistry;
}
/**
* Каждые 30 секунд проверяет, изменился ли tenants.json.
*/
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void watchForChanges() {
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
}
private void watchForChangesSerialized() {
long now = currentTimeMillis.getAsLong();
Path path = Path.of(tenantsConfigPath);
if (!Files.exists(path)) {
if (tenantsConfigRequired) {
handleRequiredMissingFile(now);
}
return;
}
if (isRetryDeferred(READ_FAILURE_KEY, now)) {
logDeferredRetry(now);
return;
}
String failureKey = READ_FAILURE_KEY;
try {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
if (tenantsConfigRequired) {
readinessRegistry.markConfigurationFailure();
if (!configurationUnavailable) {
log.error("Обязательный файл конфигурации тенантов недоступен");
}
configurationUnavailable = true;
}
String content = Files.readString(path, StandardCharsets.UTF_8);
String hash = configHash(content);
failureKey = hash;
Projection projection = parseProjection(content, hash);
// Даже неизменившееся содержимое сначала разбирается: fence никогда не
// принимает решение по сырым байтам до проверки структуры JSON.
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
return;
}
if (isRetryDeferred(hash, now)) {
logDeferredRetry(now);
return;
}
if (!hash.equals(failedRevisionKey)) {
resetRetryState();
}
if (shouldDefer(projection.snapshot())) {
return;
}
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
String hash = configHash(content);
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
return; // Ничего не изменилось
}
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
lastConfigHash = hash;
configurationUnavailable = false;
} catch (Exception e) {
readinessRegistry.markConfigurationFailure();
configurationUnavailable = true;
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
log.debug("Технические детали синхронизации конфига тенантов", e);
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
syncTenants(projection.tenants());
markApplied(projection);
} catch (Exception failure) {
recordFailure(failureKey, failure);
}
}
/**
* Обновляет хеш конфига после ручного обновления Secret с этого же пода.
* Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
* с неизвестного baseline.
*/
public void refreshHash() {
tenantLifecycleService.executeSerialized(this::refreshHashSerialized);
public void prepareForApiMutation() {
tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
}
private void refreshHashSerialized() {
try {
File file = new File(tenantsConfigPath);
if (file.exists()) {
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
lastConfigHash = configHash(content);
} else if (tenantsConfigRequired) {
readinessRegistry.markConfigurationFailure();
configurationUnavailable = true;
private void prepareForApiMutationSerialized() {
Path path = Path.of(tenantsConfigPath);
if (!Files.exists(path)) {
if (!tenantsConfigRequired) {
return;
}
} catch (Exception e) {
readinessRegistry.markConfigurationFailure();
configurationUnavailable = true;
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали обновления хеша тенантов", e);
TenantLifecycleException failure = new TenantLifecycleException(
"Обязательный файл конфигурации тенантов недоступен"
);
recordFailure(MISSING_FAILURE_KEY, failure);
throw failure;
}
String failureKey = READ_FAILURE_KEY;
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
String hash = configHash(content);
failureKey = hash;
Projection projection = parseProjection(content, hash);
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
return;
}
if (shouldDefer(projection.snapshot())) {
return;
}
syncTenants(projection.tenants());
markApplied(projection);
} catch (Exception failure) {
recordFailure(failureKey, failure);
if (failure instanceof TenantLifecycleException lifecycleFailure) {
throw lifecycleFailure;
}
throw new TenantLifecycleException(
"Не удалось подготовить актуальную конфигурацию тенантов перед изменением",
failure
);
}
}
/**
* Устанавливает snapshot fence только после подтверждённой записи в общее
* хранилище. Локальные и no-op операции не создают ложных ожиданий от mount.
*/
public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) {
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt));
}
private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) {
if (!receipt.persisted() || !receipt.changed()) {
return;
}
ConfigSnapshot previous = snapshotOf(receipt.previousTenants());
ConfigSnapshot committed = snapshotOf(receipt.committedTenants());
if (lastAppliedSnapshot != null) {
deferredSnapshots.add(lastAppliedSnapshot);
}
if (expectedCommittedSnapshot != null) {
deferredSnapshots.add(expectedCommittedSnapshot);
}
deferredSnapshots.add(previous);
deferredSnapshots.remove(committed);
expectedCommittedSnapshot = committed;
}
static String configHash(String content) {
@@ -127,11 +215,158 @@ public class TenantConfigWatcher {
}
}
/**
* Синхронизирует in-memory тенантов с конфигом из файла.
*/
private void syncTenants(List<TenantConfig> newTenants) {
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants);
private Projection parseProjection(String content, String hash) throws IOException {
List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
if (tenants == null) {
throw new IllegalArgumentException(
"Корневое значение конфигурации тенантов должно быть массивом"
);
}
if (tenantsConfigRequired && tenants.isEmpty()) {
throw new IllegalArgumentException(
"Обязательная конфигурация тенантов не может быть пустой"
);
}
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
}
private ConfigSnapshot snapshotOf(List<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.model.Role;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleMutationResult;
import com.magistr.app.service.TenantLifecycleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -90,8 +91,11 @@ public class DatabaseController {
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
try {
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config);
tenantConfigWatcher.refreshHash();
tenantConfigWatcher.prepareForApiMutation();
TenantLifecycleMutationResult mutation =
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig saved = mutation.tenant();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
@@ -111,8 +115,11 @@ public class DatabaseController {
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
try {
return tenantLifecycleService.executeSerialized(() -> {
TenantConfig removed = tenantLifecycleService.removeTenant(domain);
tenantConfigWatcher.refreshHash();
tenantConfigWatcher.prepareForApiMutation();
TenantLifecycleMutationResult mutation =
tenantLifecycleService.removeTenantWithReceipt(domain);
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
TenantConfig removed = mutation.tenant();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
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.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@Service
public class TenantLifecycleService {
@@ -72,6 +71,13 @@ public class TenantLifecycleService {
* Добавляет или обновляет tenant без разрыва действующего подключения.
*/
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
return addOrUpdateTenantWithReceipt(requestedConfig).tenant();
}
/**
* Добавляет или обновляет tenant и возвращает подтверждённый результат персистенции.
*/
public synchronized TenantLifecycleMutationResult addOrUpdateTenantWithReceipt(TenantConfig requestedConfig) {
TenantConfig config = validateAndNormalize(requestedConfig);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
@@ -96,7 +102,7 @@ public class TenantLifecycleService {
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
retireDataSource(previous == null ? null : previous.dataSource());
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
return copyConfig(config);
return new TenantLifecycleMutationResult(config, receipt);
} finally {
if (!activated) {
closeCandidateDataSource(candidate);
@@ -108,6 +114,13 @@ public class TenantLifecycleService {
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
*/
public synchronized TenantConfig removeTenant(String requestedDomain) {
return removeTenantWithReceipt(requestedDomain).tenant();
}
/**
* Удаляет tenant и возвращает подтверждённый результат персистенции.
*/
public synchronized TenantLifecycleMutationResult removeTenantWithReceipt(String requestedDomain) {
String domain = validateAndNormalizeDomain(requestedDomain);
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
TenantConfig existing = oldConfigs.get(domain);
@@ -132,13 +145,14 @@ public class TenantLifecycleService {
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
retireDataSource(removed.dataSource());
log.info("Lifecycle тенанта '{}' удалён", domain);
return copyConfig(existing);
return new TenantLifecycleMutationResult(existing, receipt);
}
/**
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13.
* Для нового или изменённого домена подключение сначала полностью подготавливается,
* проверяется и мигрируется, после чего атомарно заменяет прежний runtime-снимок.
*/
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
if (requestedConfigs == null) {
@@ -146,47 +160,55 @@ public class TenantLifecycleService {
}
List<TenantConfig> configs = requestedConfigs.stream()
.map(this::validateAndNormalize)
.sorted(Comparator.comparing(TenantConfig::getDomain))
.toList();
Set<String> desiredDomains = configs.stream()
.map(TenantConfig::getDomain)
.collect(Collectors.toSet());
readinessRegistry.clearConfigurationFailure();
readinessRegistry.replaceDesired(configs);
if (desiredDomains.size() != configs.size()) {
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
}
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>();
for (TenantConfig config : configs) {
if (!routingDataSource.hasTenant(config.getDomain())) {
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
if (desiredConfigs.put(config.getDomain(), config) != null) {
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
}
}
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) {
if (!desiredDomains.contains(existingDomain)) {
Map<String, TenantConfig> currentConfigs = routingDataSource.snapshotTenantConfigs();
readinessRegistry.replaceDesired(configs);
for (TenantConfig config : configs) {
TenantConfig currentConfig = currentConfigs.get(config.getDomain());
if (currentConfig == null) {
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
} else if (!sameConfig(currentConfig, config)) {
log.info("Заменяем подключение тенанта '{}' из persisted-конфигурации", config.getDomain());
activatePersistedConfig(config);
} else {
recoverPersistedReadinessIfNeeded(config);
}
}
for (String existingDomain : new ArrayList<>(currentConfigs.keySet())) {
if (!desiredConfigs.containsKey(existingDomain)) {
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
TenantRoutingDataSource.TenantState removed =
routingDataSource.removeTenantAtomically(existingDomain);
retireDataSource(removed == null ? null : removed.dataSource());
}
}
readinessRegistry.clearConfigurationFailure();
}
private void activatePersistedConfig(TenantConfig config) {
HikariDataSource candidate = null;
boolean activated = false;
TenantRoutingDataSource.TenantState previous;
readinessRegistry.markMigrationStarted(config);
try {
candidate = prepareCandidate(config);
verifyConnection(candidate);
migrateCandidate(candidate);
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
previous = routingDataSource.swapTenant(config, candidate);
activated = true;
readinessRegistry.markMigrationSucceeded(config);
readinessRegistry.markConnectivity(config.getDomain(), true);
retireDataSource(previous == null ? null : previous.dataSource());
} catch (RuntimeException activationFailure) {
readinessRegistry.markMigrationFailed(config);
throw activationFailure;
@@ -195,6 +217,29 @@ public class TenantLifecycleService {
closeCandidateDataSource(candidate);
}
}
// После swap новый pool уже является рабочим. Ошибка публикации readiness не должна
// ошибочно помечать успешно мигрированный pool как FAILED: следующий retry восстановит
// состояние по активному подключению без повторной замены DataSource.
retireDataSource(previous == null ? null : previous.dataSource());
readinessRegistry.markMigrationSucceeded(config);
readinessRegistry.markConnectivity(config.getDomain(), true);
}
private void recoverPersistedReadinessIfNeeded(TenantConfig config) {
TenantReadinessRegistry.TenantStatus status =
readinessRegistry.snapshot().get(config.getDomain());
if (status == null
|| status.migrationState() == TenantReadinessRegistry.MigrationState.SUCCEEDED) {
return;
}
if (!routingDataSource.testConnection(config.getDomain())) {
throw new TenantLifecycleException(
"Активное подключение тенанта не прошло проверку после предыдущей синхронизации"
);
}
readinessRegistry.markMigrationSucceeded(config);
readinessRegistry.markConnectivity(config.getDomain(), true);
}
private HikariDataSource prepareCandidate(TenantConfig config) {
@@ -398,9 +443,24 @@ public class TenantLifecycleService {
);
}
private boolean sameConfig(TenantConfig first, TenantConfig second) {
TenantConfig normalizedFirst = validateAndNormalize(first);
TenantConfig normalizedSecond = validateAndNormalize(second);
return Objects.equals(normalizedFirst.getName(), normalizedSecond.getName())
&& Objects.equals(normalizedFirst.getDomain(), normalizedSecond.getDomain())
&& Objects.equals(normalizedFirst.getUrl(), normalizedSecond.getUrl())
&& Objects.equals(normalizedFirst.getUsername(), normalizedSecond.getUsername())
&& Objects.equals(normalizedFirst.getPassword(), normalizedSecond.getPassword());
}
private void retireDataSource(DataSource dataSource) {
if (dataSource != null) {
retiredPoolService.retire(dataSource);
try {
retiredPoolService.retire(dataSource);
} catch (RuntimeException retirementFailure) {
logTechnicalFailure("Не удалось передать прежний tenant pool на отложенное закрытие",
retirementFailure);
}
}
}

View File

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

View File

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

View File

@@ -1,23 +1,32 @@
package com.magistr.app.controller;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleMutationResult;
import com.magistr.app.service.TenantLifecycleService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -68,7 +77,128 @@ class DatabaseControllerTest {
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC"))));
verify(tenantConfigWatcher, never()).refreshHash();
verify(tenantConfigWatcher, never()).prepareForApiMutation();
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
verify(lifecycleService).executeSerialized(any(Supplier.class));
}
@Test
void successfulAddPreparesProjectionAndRegistersPersistenceReceipt() throws Exception {
executeSerializedActionsImmediately();
TenantConfig saved = new TenantConfig(
"Тестовый университет",
"test",
"jdbc:postgresql://db/test",
"user",
"password"
);
TenantSecretUpdateReceipt receipt = receipt(saved);
when(lifecycleService.addOrUpdateTenantWithReceipt(any(TenantConfig.class)))
.thenReturn(new TenantLifecycleMutationResult(saved, receipt));
mockMvc.perform(post("/api/database/tenants")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Тестовый университет",
"domain": "test",
"url": "jdbc:postgresql://db/test",
"username": "user",
"password": "password"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
InOrder order = inOrder(lifecycleService, tenantConfigWatcher);
order.verify(lifecycleService).executeSerialized(any(Supplier.class));
order.verify(tenantConfigWatcher).prepareForApiMutation();
order.verify(lifecycleService).addOrUpdateTenantWithReceipt(any(TenantConfig.class));
order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt);
}
@Test
void successfulRemovalPreparesProjectionAndRegistersPersistenceReceipt() throws Exception {
executeSerializedActionsImmediately();
TenantConfig removed = new TenantConfig(
"Тестовый университет",
"test",
"jdbc:postgresql://db/test",
"user",
"password"
);
TenantSecretUpdateReceipt receipt = receipt();
when(lifecycleService.removeTenantWithReceipt("test"))
.thenReturn(new TenantLifecycleMutationResult(removed, receipt));
mockMvc.perform(delete("/api/database/tenants/test"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
InOrder order = inOrder(lifecycleService, tenantConfigWatcher);
order.verify(lifecycleService).executeSerialized(any(Supplier.class));
order.verify(tenantConfigWatcher).prepareForApiMutation();
order.verify(lifecycleService).removeTenantWithReceipt("test");
order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt);
}
@Test
void projectionReadFailurePreventsAddAndReturnsServiceUnavailable() throws Exception {
executeSerializedActionsImmediately();
doThrow(new TenantLifecycleException(
"Не удалось прочитать конфигурацию тенантов перед изменением",
new IllegalStateException("тестовая ошибка чтения")
)).when(tenantConfigWatcher).prepareForApiMutation();
mockMvc.perform(post("/api/database/tenants")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Тестовый университет",
"domain": "test",
"url": "jdbc:postgresql://db/test",
"username": "user",
"password": "password"
}
"""))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message")
.value("Не удалось прочитать конфигурацию тенантов перед изменением"));
verify(lifecycleService, never()).addOrUpdateTenantWithReceipt(any());
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
}
@Test
void projectionReadFailurePreventsRemovalAndReturnsServiceUnavailable() throws Exception {
executeSerializedActionsImmediately();
doThrow(new TenantLifecycleException(
"Не удалось прочитать конфигурацию тенантов перед изменением",
new IllegalStateException("тестовая ошибка чтения")
)).when(tenantConfigWatcher).prepareForApiMutation();
mockMvc.perform(delete("/api/database/tenants/test"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.success").value(false));
verify(lifecycleService, never()).removeTenantWithReceipt(any());
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
}
private void executeSerializedActionsImmediately() {
doAnswer(invocation -> invocation.<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);
}
@Test
void persistedConfigReplacementKeepsOldPoolOnFailureAndSwapsWithoutSecretWrite() throws Exception {
TenantConfig wrongCredentials = config(
"Неверные credentials из проекции",
jdbcUrl(OLD_DATABASE),
POSTGRES.getPassword() + "-wrong"
);
assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(wrongCredentials)))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось подключиться к базе данных тенанта");
assertOldState();
TenantConfig badChecksum = config(
"Повреждённый Flyway из проекции",
jdbcUrl(BAD_CHECKSUM_DATABASE),
POSTGRES.getPassword()
);
assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(badChecksum)))
.isInstanceOf(TenantLifecycleException.class)
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
assertOldState();
TenantConfig validCandidate = config(
"Новый tenant из проекции",
jdbcUrl(VALID_DATABASE),
POSTGRES.getPassword()
);
try (Connection inFlightOldConnection = oldPool.getConnection()) {
lifecycleService.synchronizeFromPersistedConfig(List.of(validCandidate));
assertThat(oldPool.isClosed()).isFalse();
try (Statement statement = inFlightOldConnection.createStatement();
ResultSet result = statement.executeQuery("SELECT current_database()")) {
assertThat(result.next()).isTrue();
assertThat(result.getString(1)).isEqualTo(OLD_DATABASE);
}
}
awaitPoolClosed(oldPool);
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
verify(tenantConfigStore, never()).upsertTenant(any());
verify(tenantConfigStore, never()).removeTenant(any());
verify(tenantConfigStore, never()).compensate(any());
}
private void awaitPoolClosed(HikariDataSource pool) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (!pool.isClosed() && System.nanoTime() < deadline) {
@@ -218,6 +264,7 @@ class TenantLifecyclePostgreSqlIntegrationTest {
private void createDatabase(String databaseName) throws Exception {
try (Connection connection = POSTGRES.createConnection("");
Statement statement = connection.createStatement()) {
statement.execute("DROP DATABASE IF EXISTS " + databaseName + " WITH (FORCE)");
statement.execute("CREATE DATABASE " + databaseName);
}
}

View File

@@ -11,6 +11,9 @@ import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
@@ -20,17 +23,21 @@ import org.mockito.quality.Strictness;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -128,6 +135,25 @@ class TenantLifecycleServiceTest {
verify(candidate, never()).close();
}
@Test
void returnsConfirmedReceiptWithSuccessfulUpsert() {
TenantConfig requested = newAlpha();
TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
true,
false,
"resource-version-noop",
List.of(requested),
List.of(requested)
);
when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
TenantLifecycleMutationResult result = service.addOrUpdateTenantWithReceipt(requested);
assertThat(result.receipt()).isSameAs(receipt);
assertConfig(result.tenant(),
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
}
@Test
void addsNewTenantWithoutRetiringExistingPool() {
Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
@@ -296,6 +322,26 @@ class TenantLifecycleServiceTest {
order.verify(retiredPoolService).retire(oldDataSource);
}
@Test
void returnsLocalOnlyReceiptWithSuccessfulRemoval() {
TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
false,
false,
null,
List.of(oldAlpha),
List.of(beta)
);
when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
when(routingDataSource.removeTenantAtomically("alpha"))
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
TenantLifecycleMutationResult result = service.removeTenantWithReceipt("alpha");
assertThat(result.receipt()).isSameAs(receipt);
assertConfig(result.tenant(),
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
}
@Test
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
@@ -314,22 +360,156 @@ class TenantLifecycleServiceTest {
@Test
void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
when(routingDataSource.hasTenant("gamma")).thenReturn(false);
when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
service.synchronizeFromPersistedConfig(List.of(gamma));
InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
order.verify(readinessRegistry).clearConfigurationFailure();
order.verify(readinessRegistry).replaceDesired(any());
order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
order.verify(migrationService).migrate(candidate);
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
order.verify(readinessRegistry).markConnectivity("gamma", true);
order.verify(readinessRegistry).clearConfigurationFailure();
verify(tenantConfigStore, never()).upsertTenant(any());
}
@ParameterizedTest
@MethodSource("changedPersistedAlphaConfigs")
void replacesExistingPersistedTenantWhenAnyNormalizedFieldChanges(TenantConfig changedConfig) {
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
service.synchronizeFromPersistedConfig(List.of(changedConfig));
ArgumentCaptor<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
void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
@@ -376,6 +556,23 @@ class TenantLifecycleServiceTest {
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
}
private static Stream<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) {
Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
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`.
**Логика:**
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
2. Открывает соединение и явно проверяет его готовность.
3. Выполняет Flyway-валидацию и миграции tenant-БД.
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию
tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`.
2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
3. Открывает соединение и явно проверяет его готовность.
4. Выполняет Flyway-валидацию и миграции tenant-БД.
5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
retry/backoff; неизменившаяся конфигурация не записывается повторно.
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
либо по истечении защитного таймаута.
9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой
`TenantSecretUpdateReceipt` для согласования mounted-проекции.
Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
@@ -1236,6 +1240,15 @@ HTTP-статусы: `400` для некорректного payload и `503` д
компенсация также допускается только для подтверждённой версии и не затирает более новое
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
Для `POST` и `DELETE` baseline готовится до API-мутации под общим lifecycle monitor.
После успеха backend использует семантические `previousTenants` и `committedTenants` из
`TenantSecretUpdateReceipt`. Fence создаётся только для реального изменения общего Secret
(`persisted=true`, `changed=true`): известные старые и промежуточные снимки временно
откладываются, ожидаемый committed-снимок применяется полностью. Persisted no-op и локальная
операция fence не создают, а неизвестный merged snapshot другого pod синхронизируется сразу.
SHA-256 файла подтверждается только после полного успешного sync; ошибки повторяются с
экспоненциальной задержкой от 30 до 300 секунд.
### `POST /api/database/test`
Тест подключения к произвольной БД (без регистрации тенанта).

View File

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

View File

@@ -16,6 +16,9 @@
```
frontend/
├── package.json # Dependency-free проверки frontend через node:test
├── tests/
│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
@@ -34,6 +37,7 @@ frontend/
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
@@ -125,6 +129,7 @@ frontend/
| Tab | Описание | API |
|-----|----------|-----|
| `dashboard` | Сводные метрики и проверка конфликтов текущей недели с явным статусом полноты данных | `/api/departments`, `/api/classrooms`, `/api/groups`, `/api/users/teachers`, `/api/schedule/search`, `/api/admin/time-slots` |
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
@@ -139,6 +144,7 @@ frontend/
### Особенности админских вкладок
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
@@ -208,6 +214,19 @@ export const api = {
---
## Frontend-тесты
Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:0003:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
Команды выполняются из каталога `frontend/`:
```bash
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
npm run check # синтаксис модулей дашборда + unit-тесты
```
---
## Аутентификация (Frontend)
### Страница входа (`/index.html`)

View File

@@ -126,16 +126,24 @@ env:
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API:
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
только upsert запрошенного домена
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
подтверждённой повторным чтением персистенции
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
применяет добавление или удаление домена
6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после
drain/grace timeout
7. lifecycle возвращает `TenantLifecycleMutationResult` с `TenantSecretUpdateReceipt`;
semantic fence регистрируется только для фактически записанного изменения
(`persisted=true`, `changed=true`)
8. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
синхронизирует полный нормализованный `TenantConfig`, включая изменения `name`, `domain`,
`url`, `username` и `password`; новый или изменённый pool проходит проверку соединения, Flyway
и атомарный swap без повторной записи Secret
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
@@ -147,10 +155,17 @@ backend восстанавливает прежний снимок только
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials.
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех
полей `TenantConfig`.
Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения
снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация
повторяется с экспоненциальной задержкой от 30 до 300 секунд.
Защита от задержки kubelet основана не на raw hash, а на полном нормализованном снимке.
Квитанция фактической persisted-мутации задаёт `previousTenants` и `committedTenants`:
previous и промежуточные committed-снимки временно deferred, последний committed ожидается.
Поэтому последовательность H0 → H1 → H2 не откатывается при доставке H0/H1, а H2 применяется.
Неизвестный merged snapshot, не совпадающий с deferred-состояниями, применяется сразу.
Persisted no-op и локальная операция fence не создают. Если sync оборвался после swap на
публикации readiness, retry проверяет уже активное соединение и не выполняет второй swap.
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только

View File

@@ -246,6 +246,77 @@
border-color: var(--button-secondary-border);
}
.dashboard-check-state {
--check-accent: var(--text-secondary);
--check-surface: rgba(148, 163, 184, 0.08);
--check-border: rgba(148, 163, 184, 0.22);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.9rem;
align-items: start;
padding: 1rem 1.1rem;
color: var(--text-primary);
background: linear-gradient(115deg, var(--check-surface), transparent 72%);
border: 1px solid var(--check-border);
border-left: 4px solid var(--check-accent);
border-radius: var(--radius-sm);
}
.dashboard-check-state--success {
--check-accent: var(--success);
--check-surface: rgba(16, 185, 129, 0.1);
--check-border: rgba(16, 185, 129, 0.24);
}
.dashboard-check-state--warning {
--check-accent: var(--warning);
--check-surface: rgba(245, 158, 11, 0.1);
--check-border: rgba(245, 158, 11, 0.26);
}
.dashboard-check-state--error {
--check-accent: var(--error);
--check-surface: rgba(239, 68, 68, 0.1);
--check-border: rgba(239, 68, 68, 0.26);
}
.dashboard-check-state__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
color: var(--check-accent);
background: var(--check-surface);
border: 1px solid var(--check-border);
border-radius: var(--radius-sm);
}
.dashboard-check-state__body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.2rem;
}
.dashboard-check-state__body strong {
color: var(--check-accent);
font-size: 0.95rem;
line-height: 1.35;
}
.dashboard-check-state__body > span {
color: var(--text-primary);
font-size: 0.86rem;
line-height: 1.5;
}
.dashboard-check-state__body small {
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.76rem;
line-height: 1.4;
}
@media (max-width: 1200px) {
.dashboard-metrics-grid {
grid-template-columns: repeat(2, minmax(180px, 1fr));

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 { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { escapeHtml } from '../utils.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../dashboard-conflicts.js';
export async function initDashboard() {
// DOM Элементы
@@ -20,16 +27,27 @@ export async function initDashboard() {
let teachers = [];
let todayLessons = [];
let timeSlots = [];
let dashboardDataReady = false;
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.textContent = '...';
await checkConflicts();
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
btnRecheck.setAttribute('aria-busy', 'true');
btnRecheck.textContent = 'Проверка…';
try {
if (dashboardDataReady) {
await checkConflicts();
} else {
await loadData();
}
} finally {
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
btnRecheck.removeAttribute('aria-busy');
}
});
async function loadData() {
dashboardDataReady = false;
try {
// Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([
@@ -45,7 +63,7 @@ export async function initDashboard() {
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = new Date().toISOString().split('T')[0];
const todayStr = formatLocalDate(new Date());
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
@@ -75,10 +93,18 @@ export async function initDashboard() {
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
dashboardDataReady = true;
await checkConflicts();
} catch (e) {
console.error("Dashboard data load error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
dashboardDataReady = false;
console.error('Не удалось загрузить данные панели', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
});
}
}
@@ -147,25 +173,25 @@ export async function initDashboard() {
// --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() {
conflictsContainer.setAttribute('aria-busy', 'true');
conflictsContainer.innerHTML = '<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 {
// Загружаем расписание на всю неделю по кафедрам
const weekLessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
const { startDate, endDate } = currentWeekDateRange(new Date());
const scheduleLoad = await loadDepartmentSchedules(
departments,
department => api.get(
`/api/schedule/search?departmentId=${department.id}`
+ `&startDate=${startDate}&endDate=${endDate}`
)
);
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
const weekLessons = weekLessonsArrays.flat();
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
renderConflicts([], scheduleLoad, { startDate, endDate });
return;
}
const weekLessons = scheduleLoad.lessons;
const conflicts = [];
@@ -264,31 +290,40 @@ export async function initDashboard() {
}
});
renderConflicts(conflicts);
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
} catch (e) {
console.error("Conflicts check error", e);
conflictsContainer.innerHTML = `<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: 'Во время анализа произошла ошибка. Повторите проверку позже.'
});
} finally {
conflictsContainer.removeAttribute('aria-busy');
}
}
function renderConflicts(conflicts) {
if (!conflicts.length) {
conflictsContainer.innerHTML = `
<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">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<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>
`;
function renderConflicts(conflicts, scheduleLoad, range) {
const stateCards = [];
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
return;
}
conflictsContainer.innerHTML = conflicts.map(c => {
if (presentation) {
stateCards.push(renderCheckStateCard(presentation));
}
if (!conflicts.length) {
conflictsContainer.innerHTML = stateCards.join('');
return;
}
const conflictCards = conflicts.map(c => {
const isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
@@ -306,6 +341,27 @@ export async function initDashboard() {
</div>
`;
}).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>
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</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 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>
</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, /Подтвердить отсутствие конфликтов нельзя/);
});