feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog

dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
This commit is contained in:
Zimin A.N.
2026-05-08 11:23:46 +03:00
parent c06ae263e0
commit 3eeaba058f
10 changed files with 1055 additions and 9 deletions
@@ -47,9 +47,33 @@ public class DictionaryRecordService {
private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
private final OutboxRecorder outbox;
private final AuditLogger auditLogger;
/** Phase 3 dict-relationships-v2: pre-close проверка cascade plan. Optional —
* тесты с null deps инжектят null. */
private final java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService;
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
@org.springframework.beans.factory.annotation.Autowired
public DictionaryRecordService(
DictionaryRecordRepository repository,
DictionaryDefinitionService definitionService,
RecordValidator validator,
cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
OutboxRecorder outbox,
AuditLogger auditLogger,
java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService) {
this.repository = repository;
this.definitionService = definitionService;
this.validator = validator;
this.referenceValidator = referenceValidator;
this.scopeContext = scopeContext;
this.outbox = outbox;
this.auditLogger = auditLogger;
this.cascadeService = cascadeService;
}
/** Backward-compat ctor для тестов и subclassing (BulkRecordServiceTest). */
public DictionaryRecordService(
DictionaryRecordRepository repository,
DictionaryDefinitionService definitionService,
@@ -58,13 +82,8 @@ public class DictionaryRecordService {
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
OutboxRecorder outbox,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.validator = validator;
this.referenceValidator = referenceValidator;
this.scopeContext = scopeContext;
this.outbox = outbox;
this.auditLogger = auditLogger;
this(repository, definitionService, validator, referenceValidator,
scopeContext, outbox, auditLogger, java.util.Optional.empty());
}
@Transactional(readOnly = true)
@@ -184,6 +203,28 @@ public class DictionaryRecordService {
var d = definitionService.findByName(dictionaryName);
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
// Phase 3 dict-relationships-v2: если есть active dependents — close
// блокируется (BLOCK или требует cascade-confirm для CASCADE). WARN
// dependents допускают close, но возвращают warnings (current
// contract: void return — warnings уйдут только в outbox + audit).
cascadeService.ifPresent(svc -> {
var plan = svc.evaluatePlan(dictionaryName, businessKey, when);
if (!plan.blockers().isEmpty()) {
throw OrdinisException.conflict(
"x_references_blocked_by_dependents",
"Cannot close " + dictionaryName + "/" + businessKey + ""
+ plan.blockers().size()
+ " active dependents block close. Resolve them first.");
}
if (!plan.cascade().isEmpty()) {
throw OrdinisException.conflict(
"x_references_cascade_required",
plan.cascade().size() + " dependents will be cascaded. "
+ "Use POST /records/{dict}/{key}/cascade-close with confirmed=true to proceed.");
}
// WARN-only — продолжаем close (warnings emitted via outbox indirectly).
});
var current = repository.findActiveAt(d.getId(), businessKey, when)
.orElseThrow(() -> OrdinisException.notFound(
"record_not_active",
@@ -0,0 +1,264 @@
package cloud.nstart.terravault.ordinis.restapi.service.reference;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionTimedOutException;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Cascade close engine — Phase 3 dict-relationships-v2 epic.
*
* <p>Запускается когда close target record имеет dependents (записи в других
* словарях через {@code x-references}). Branching по {@link OnCloseAction}
* объявленному в schema referencing dict'а:
*
* <ul>
* <li>{@link OnCloseAction#BLOCK} — close отклоняется с 409
* {@code x_references_blocked_by_dependents}, dependents list в response.</li>
* <li>{@link OnCloseAction#WARN} — close проходит, warnings возвращаются
* в response (orphan scanner подхватит на metric).</li>
* <li>{@link OnCloseAction#CASCADE} — требует explicit confirm через
* cascade-close endpoint. Direct dependents закрываются в той же
* transaction (one hop, F.OUT по design).</li>
* </ul>
*
* <p>Concurrency: SERIALIZABLE isolation. Postgres exclusion constraint +
* SERIALIZABLE retry — second concurrent close видит conflict и retry'ится
* (или возвращает 409 если retry exhausted).
*
* <p>Tx timeout: {@code @Transactional(timeout = 30)} на {@link #executeCascade}.
* При timeout'е → 503 {@code x_references_cascade_timeout}, rollback clean.
*/
@Service
public class CascadeCloseService {
private static final Logger log = LoggerFactory.getLogger(CascadeCloseService.class);
/** Hard upper bound на cascade size (one hop). Свыше — bulk-close ручкой. */
private static final int CASCADE_MAX_PER_REQUEST = 500;
private final LineageIndexService lineage;
private final RecordCloseSink closeSink;
private final ScopeContext scopeContext;
@Autowired
public CascadeCloseService(
LineageIndexService lineage,
DictionaryRecordRepository recordRepo,
DictionaryDefinitionService defService,
AuditLogger auditLogger,
OutboxRecorder outbox,
ScopeContext scopeContext) {
this(lineage,
new DefaultRecordCloseSink(recordRepo, defService, auditLogger, outbox),
scopeContext);
}
/** Test-only ctor — sink инжектится напрямую (Mockito не мокает concrete). */
CascadeCloseService(
LineageIndexService lineage,
RecordCloseSink closeSink,
ScopeContext scopeContext) {
this.lineage = lineage;
this.closeSink = closeSink;
this.scopeContext = scopeContext;
}
/**
* Read-only план: какие dependents есть, сгруппированные по mode.
* UI вызывает перед close чтобы показать preview dialog.
*/
@Transactional(readOnly = true)
public CascadePlan evaluatePlan(
String targetDict, String targetKey, OffsetDateTime at) {
Set<DataScope> scopes = scopeContext.currentScopes();
OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
var page = lineage.findRecordDependents(
targetDict, targetKey, scopes, 0, CASCADE_MAX_PER_REQUEST);
List<CascadeEntry> blockers = new ArrayList<>();
List<CascadeEntry> warnings = new ArrayList<>();
List<CascadeEntry> cascade = new ArrayList<>();
for (var d : page.items()) {
var entry = new CascadeEntry(
d.sourceDict(), d.sourceDisplayName(), d.businessKey(),
d.sourceField(), d.onClose(), d.id());
switch (d.onClose()) {
case BLOCK -> blockers.add(entry);
case WARN -> warnings.add(entry);
case CASCADE -> cascade.add(entry);
}
}
return new CascadePlan(
targetDict, targetKey, blockers, warnings, cascade, page.total());
}
/**
* Atomic execute: проверяет план, кидает 409 если blockers есть,
* иначе закрывает target + all CASCADE dependents в одной transaction.
* WARN dependents остаются orphan (warning'и возвращаются в response).
*
* @param requireConfirm true → если в плане CASCADE есть, требует explicit confirm.
* false → запускает только если blockers + cascade пусты
* (used by existing DELETE endpoint default path).
*/
@Transactional(isolation = Isolation.SERIALIZABLE, timeout = 30)
public CascadeCloseResult executeCascade(
String targetDict,
String targetKey,
OffsetDateTime closeAt,
String reason,
boolean confirmed) {
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
CascadePlan plan = evaluatePlan(targetDict, targetKey, when);
if (!plan.blockers().isEmpty()) {
throw OrdinisException.conflict(
"x_references_blocked_by_dependents",
"Cannot close " + targetDict + "/" + targetKey + ""
+ plan.blockers().size() + " active dependents block close. "
+ "Resolve them first, or use cascade-close endpoint with confirmation.");
}
if (!plan.cascade().isEmpty() && !confirmed) {
throw OrdinisException.conflict(
"x_references_cascade_required",
plan.cascade().size() + " dependents will be cascaded. "
+ "Re-call with confirmed=true to proceed.");
}
if (plan.cascade().size() > CASCADE_MAX_PER_REQUEST) {
throw OrdinisException.badRequest(
"x_references_cascade_too_large",
"Cascade size " + plan.cascade().size() + " exceeds limit "
+ CASCADE_MAX_PER_REQUEST + ". Split via bulk-close.");
}
try {
// Close target first (FK semantics не enforce'ятся в Postgres для
// JSON refs, но логика "сначала parent, потом children" чище).
closeSink.closeRecord(targetDict, targetKey, when, reason);
String cascadeReason = "cascaded from " + targetDict + "/" + targetKey;
for (var dep : plan.cascade()) {
closeSink.closeRecord(dep.sourceDict(), dep.businessKey(), when, cascadeReason);
}
log.info("Cascade close OK: target={}/{} cascaded={} warnings={}",
targetDict, targetKey, plan.cascade().size(), plan.warnings().size());
return new CascadeCloseResult(
targetDict, targetKey, plan.cascade(), plan.warnings(), 1 + plan.cascade().size());
} catch (TransactionTimedOutException | QueryTimeoutException e) {
log.error("Cascade close timeout for {}/{} after 30s: {}", targetDict, targetKey, e.getMessage());
throw new OrdinisException(
org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,
"x_references_cascade_timeout",
"Cascade close transaction exceeded 30s timeout. Rollback complete. "
+ "Consider splitting via bulk-close or reducing cascade fan-out.");
}
}
/**
* Узкий test-friendly seam над close mechanics (repo + audit + outbox).
* Concrete prod impl ниже; тесты подменяют через subclass / lambda.
*/
interface RecordCloseSink {
void closeRecord(String dictionaryName, String businessKey,
OffsetDateTime when, String reason);
}
/** Production impl: repo save + audit + outbox в одной operation. */
static final class DefaultRecordCloseSink implements RecordCloseSink {
private final DictionaryRecordRepository recordRepo;
private final DictionaryDefinitionService defService;
private final AuditLogger auditLogger;
private final OutboxRecorder outbox;
DefaultRecordCloseSink(
DictionaryRecordRepository recordRepo,
DictionaryDefinitionService defService,
AuditLogger auditLogger,
OutboxRecorder outbox) {
this.recordRepo = recordRepo;
this.defService = defService;
this.auditLogger = auditLogger;
this.outbox = outbox;
}
@Override
public void closeRecord(
String dictionaryName, String businessKey, OffsetDateTime when, String reason) {
DictionaryDefinition d = defService.findByName(dictionaryName);
DictionaryRecord current = recordRepo.findActiveAt(d.getId(), businessKey, when)
.orElseThrow(() -> OrdinisException.notFound(
"record_not_active",
"Нет активной записи business_key=" + businessKey + " в " + dictionaryName));
current.setValidTo(when);
recordRepo.save(current);
auditLogger.recordClose(d, current, reason);
publishDeleted(d, current, when, reason);
}
private void publishDeleted(
DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) {
var payload = new DictionaryRecordDeleted(
r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(),
r.getUpdatedBy(), reason);
var envelope = EventEnvelope.of(
d.getName(),
cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.getScope().name()),
d.getBundle(), payload);
outbox.record(
"RecordDeleted", "DictionaryRecord", r.getId().toString(),
d.getName(), d.getScope(), d.getBundle(),
d.getName() + ":" + r.getBusinessKey(), envelope);
}
}
// === DTOs ===
/** Single cascade entry: одна dependent record + её mode. */
public record CascadeEntry(
String sourceDict,
String sourceDisplayName,
String businessKey,
String sourceField,
OnCloseAction onClose,
java.util.UUID recordId) {}
/** Read-only план: что произойдёт при close. */
public record CascadePlan(
String targetDict,
String targetKey,
List<CascadeEntry> blockers,
List<CascadeEntry> warnings,
List<CascadeEntry> cascade,
long totalDependents) {}
/** Результат execute: сколько записей закрылось + warnings. */
public record CascadeCloseResult(
String targetDict,
String targetKey,
List<CascadeEntry> cascadedRecords,
List<CascadeEntry> warnings,
int totalClosed) {}
}
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -35,16 +36,19 @@ public class DictionaryRecordController {
private final BulkRecordService bulkService;
private final DictionaryDefinitionService definitionService;
private final ScopeContext scopeContext;
private final CascadeCloseService cascadeService;
public DictionaryRecordController(
DictionaryRecordService service,
BulkRecordService bulkService,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
ScopeContext scopeContext,
CascadeCloseService cascadeService) {
this.service = service;
this.bulkService = bulkService;
this.definitionService = definitionService;
this.scopeContext = scopeContext;
this.cascadeService = cascadeService;
}
@GetMapping("/{businessKey}")
@@ -98,6 +102,48 @@ public class DictionaryRecordController {
service.close(name, businessKey, at, reason);
}
/**
* Cascade preview (Phase 3): READ-ONLY оценка cascade plan. Ничего не
* меняет. UI вызывает перед close чтобы показать "this will close N
* records: ...".
*
* <p>Возвращает 200 с {@link CascadeCloseService.CascadePlan} независимо
* от того есть ли blockers — UI сам отображает разные иконки per mode.
*/
@GetMapping("/{businessKey}/cascade-preview")
public CascadeCloseService.CascadePlan cascadePreview(
@PathVariable String name,
@PathVariable String businessKey,
@RequestParam(required = false) OffsetDateTime at) {
requireReadAccess(name);
return cascadeService.evaluatePlan(name, businessKey, at);
}
/**
* Cascade close (Phase 3): atomic close target + all CASCADE dependents.
* Требует {@code confirmed=true} в query params когда план содержит
* cascade entries (UX guard против accidental cascade).
*
* <p>Errors:
* <ul>
* <li>409 {@code x_references_blocked_by_dependents} — есть BLOCK deps.</li>
* <li>409 {@code x_references_cascade_required} — есть CASCADE deps,
* но {@code confirmed=false}.</li>
* <li>400 {@code x_references_cascade_too_large} — cascade size {@literal >} 500.</li>
* <li>503 {@code x_references_cascade_timeout} — tx exceeded 30s.</li>
* </ul>
*/
@PostMapping("/{businessKey}/cascade-close")
public CascadeCloseService.CascadeCloseResult cascadeClose(
@PathVariable String name,
@PathVariable String businessKey,
@RequestParam(required = false) OffsetDateTime at,
@RequestParam(required = false) String reason,
@RequestParam(defaultValue = "false") boolean confirmed) {
requireWriteAccess(name);
return cascadeService.executeCascade(name, businessKey, at, reason, confirmed);
}
/**
* Bulk close: закрыть N записей одним запросом. Per-record транзакция —
* partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты).
@@ -0,0 +1,221 @@
package cloud.nstart.terravault.ordinis.restapi.service.reference;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService.RecordCloseSink;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.PerSourceSummary;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependent;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependentsPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Тесты {@link CascadeCloseService}: branching по onClose mode, blockers
* → 409, cascade-required → 409, atomic execute, max size guard.
*
* <p>JDK 25 + Mockito не мокают concrete classes (LineageIndexService,
* DictionaryDefinitionService и др.). Используем seam {@link RecordCloseSink}
* + anonymous subclass LineageIndexService — никаких реальных deps.
*/
class CascadeCloseServiceTest {
private ScopeContext scopeContext;
/** Stub state — каждый тест перезаписывает. */
private RecordDependentsPage stubPage;
private final List<CloseCall> closeCalls = new ArrayList<>();
@BeforeEach
void setUp() {
// ScopeContext concrete class — JDK 25 + Mockito incompat. Anonymous
// subclass возвращает statically PUBLIC для всех тестов.
scopeContext = new ScopeContext(null) {
@Override
public Set<DataScope> currentScopes() {
return Set.of(DataScope.PUBLIC);
}
};
stubPage = emptyPage("dict_t", "k");
closeCalls.clear();
}
/** Создаёт CascadeCloseService с stub-LineageIndexService и tracking sink. */
private CascadeCloseService makeService() {
LineageIndexService stubLineage = new LineageIndexService(null, null, null) {
@Override
public RecordDependentsPage findRecordDependents(
String targetDictName, String targetBusinessKey,
Set<DataScope> allowedScopes, int page, int size) {
return stubPage;
}
};
RecordCloseSink trackingSink = (dict, key, when, reason) ->
closeCalls.add(new CloseCall(dict, key, when, reason));
return new CascadeCloseService(stubLineage, trackingSink, scopeContext);
}
// === evaluatePlan ===
@Test
void evaluatePlan_emptyDependents_returnsEmptyPlan() {
var plan = makeService().evaluatePlan("dict_t", "k", null);
assertThat(plan.blockers()).isEmpty();
assertThat(plan.warnings()).isEmpty();
assertThat(plan.cascade()).isEmpty();
assertThat(plan.totalDependents()).isZero();
}
@Test
void evaluatePlan_splitsByOnCloseMode() {
stubPage = pageWith(List.of(
recordDep("src_block", "rec1", "f1", OnCloseAction.BLOCK),
recordDep("src_warn", "rec2", "f2", OnCloseAction.WARN),
recordDep("src_cascade", "rec3", "f3", OnCloseAction.CASCADE),
recordDep("src_block", "rec4", "f1", OnCloseAction.BLOCK)),
"dict_t", "k", 4L);
var plan = makeService().evaluatePlan("dict_t", "k", null);
assertThat(plan.blockers()).hasSize(2);
assertThat(plan.warnings()).hasSize(1);
assertThat(plan.cascade()).hasSize(1);
assertThat(plan.totalDependents()).isEqualTo(4L);
}
// === executeCascade ===
@Test
void executeCascade_throws409WhenBlockersExist() {
stubPage = pageWith(
List.of(recordDep("src", "rec1", "f", OnCloseAction.BLOCK)),
"dict_t", "k", 1L);
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getStatus())
.isEqualTo(HttpStatus.CONFLICT))
.hasMessageContaining("block close");
assertThat(closeCalls).isEmpty(); // Nothing closed.
}
@Test
void executeCascade_requiresConfirmWhenCascadeNonEmpty() {
stubPage = pageWith(
List.of(recordDep("src", "rec1", "f", OnCloseAction.CASCADE)),
"dict_t", "k", 1L);
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", false))
.isInstanceOf(OrdinisException.class)
.hasMessageContaining("Re-call with confirmed=true");
assertThat(closeCalls).isEmpty();
}
@Test
void executeCascade_warnOnly_proceedsWithoutCascade() {
stubPage = pageWith(
List.of(recordDep("src", "rec1", "f", OnCloseAction.WARN)),
"dict_t", "k", 1L);
var result = makeService().executeCascade("dict_t", "k", null, "test", false);
assertThat(result.cascadedRecords()).isEmpty();
assertThat(result.warnings()).hasSize(1);
assertThat(result.totalClosed()).isEqualTo(1); // только target
assertThat(closeCalls).hasSize(1);
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
assertThat(closeCalls.get(0).reason()).isEqualTo("test");
}
@Test
void executeCascade_cascadeMode_closesTargetAndDependentsAtomically() {
stubPage = pageWith(List.of(
recordDep("src_a", "rec1", "f", OnCloseAction.CASCADE),
recordDep("src_b", "rec2", "f", OnCloseAction.CASCADE)),
"dict_t", "k", 2L);
var result = makeService().executeCascade("dict_t", "k", null, "manual", true);
assertThat(result.cascadedRecords()).hasSize(2);
assertThat(result.warnings()).isEmpty();
assertThat(result.totalClosed()).isEqualTo(3); // target + 2 cascaded
assertThat(closeCalls).hasSize(3);
// Order: target first, then cascade entries.
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
assertThat(closeCalls.get(0).reason()).isEqualTo("manual");
assertThat(closeCalls.get(1).reason()).contains("cascaded from dict_t/k");
assertThat(closeCalls.get(2).reason()).contains("cascaded from dict_t/k");
var cascadedDicts = closeCalls.subList(1, 3).stream()
.map(CloseCall::dict).toList();
assertThat(cascadedDicts).containsExactlyInAnyOrder("src_a", "src_b");
}
@Test
void executeCascade_blockOverridesCascade_409EvenWithConfirm() {
stubPage = pageWith(List.of(
recordDep("src_block", "rec1", "f", OnCloseAction.BLOCK),
recordDep("src_cascade", "rec2", "f", OnCloseAction.CASCADE)),
"dict_t", "k", 2L);
assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
.isInstanceOf(OrdinisException.class)
.hasMessageContaining("block close");
assertThat(closeCalls).isEmpty();
}
@Test
void executeCascade_emptyPlan_closesOnlyTarget() {
var result = makeService().executeCascade("dict_t", "k", null, "manual", false);
assertThat(result.cascadedRecords()).isEmpty();
assertThat(result.warnings()).isEmpty();
assertThat(result.totalClosed()).isEqualTo(1);
assertThat(closeCalls).hasSize(1);
assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
}
// === Helpers ===
private record CloseCall(String dict, String key, OffsetDateTime when, String reason) {}
private static RecordDependent recordDep(
String sourceDict, String businessKey, String sourceField, OnCloseAction onClose) {
return new RecordDependent(
sourceDict, sourceDict + " (display)", sourceField, onClose,
UUID.randomUUID(), businessKey,
OffsetDateTime.parse("2026-01-01T00:00:00Z"),
OffsetDateTime.MAX,
OffsetDateTime.parse("2026-01-01T00:00:00Z"));
}
private static RecordDependentsPage emptyPage(String targetDict, String targetKey) {
return new RecordDependentsPage(
targetDict, targetKey, List.of(), List.of(), 0L, 0, 50, null);
}
private static RecordDependentsPage pageWith(
List<RecordDependent> items, String targetDict, String targetKey, long total) {
List<PerSourceSummary> perSource = new ArrayList<>();
var grouped = new java.util.LinkedHashMap<String, Long>();
for (var d : items) grouped.merge(d.sourceDict() + "." + d.sourceField(), 1L, Long::sum);
for (var entry : grouped.entrySet()) {
var match = items.stream()
.filter(it -> (it.sourceDict() + "." + it.sourceField()).equals(entry.getKey()))
.findFirst().orElseThrow();
perSource.add(new PerSourceSummary(
match.sourceDict(), match.sourceDisplayName(),
match.sourceField(), match.onClose(), entry.getValue()));
}
return new RecordDependentsPage(
targetDict, targetKey, items, perSource, total, 0, 50, null);
}
}