diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java new file mode 100644 index 0000000..a7fea0b --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java @@ -0,0 +1,230 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Тесты {@link BulkRecordService}: per-record транзакция, дедуп, classification + * exception'ов в skipped vs errors. + * + *

{@link DictionaryRecordService} НЕ мокается через Mockito — на Java 17+ + * inline-mock не работает для классов из standard distribution. Вместо этого + * создаётся анонимный subclass который переопределяет {@code close()} — + * dependencies в конструкторе передаём null'ом (close override не обращается + * к ним). + */ +class BulkRecordServiceTest { + + private static final String DICT = "ground_station"; + + /** + * Создаёт {@link DictionaryRecordService} stub: конструктор принимает 7 + * null deps, единственный perевопреlojavkaedoperedrebooled close() метод + * — диспатчит вызов в {@code closeStub} (либо записывает в captures, + * либо бросает заготовленный exception). + */ + private static DictionaryRecordService stubService(BiConsumer closeStub) { + return new DictionaryRecordService(null, null, null, null, null, null, null) { + @Override + public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) { + closeStub.accept(dictionaryName, businessKey); + } + }; + } + + /** Stub с per-key поведением: lookup map[bk] → behavior. Default success. */ + private static DictionaryRecordService routedStub( + Map errorByKey, + List calls) { + return stubService((dict, bk) -> { + calls.add(bk); + RuntimeException ex = errorByKey.get(bk); + if (ex != null) throw ex; + }); + } + + @Test + void allSucceed_whenServiceClosesEachRecord() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub(Map.of(), calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("a", "b", "c"), null, "test reason")); + + assertThat(res.closed()).containsExactly("a", "b", "c"); + assertThat(res.skipped()).isEmpty(); + assertThat(res.errors()).isEmpty(); + assertThat(calls).containsExactly("a", "b", "c"); + } + + @Test + void notActive_classifiedAsSkipped() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub( + Map.of("b", OrdinisException.notFound("record_not_active", "уже закрыто")), + calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("a", "b", "c"), null, null)); + + assertThat(res.closed()).containsExactly("a", "c"); + assertThat(res.skipped()).hasSize(1); + assertThat(res.skipped().get(0).businessKey()).isEqualTo("b"); + assertThat(res.skipped().get(0).reason()).isEqualTo("already_closed"); + assertThat(res.errors()).isEmpty(); + } + + @Test + void otherOrdinisException_classifiedAsError() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub( + Map.of("a", OrdinisException.forbidden("scope_insufficient", "нет прав")), + calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("a"), null, null)); + + assertThat(res.closed()).isEmpty(); + assertThat(res.skipped()).isEmpty(); + assertThat(res.errors()).hasSize(1); + assertThat(res.errors().get(0).businessKey()).isEqualTo("a"); + assertThat(res.errors().get(0).reason()).contains("scope_insufficient").contains("нет прав"); + } + + @Test + void unexpectedException_classifiedAsError() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub( + Map.of("a", new RuntimeException("DB connection lost")), + calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("a"), null, null)); + + assertThat(res.closed()).isEmpty(); + assertThat(res.skipped()).isEmpty(); + assertThat(res.errors()).hasSize(1); + assertThat(res.errors().get(0).reason()) + .contains("RuntimeException") + .contains("DB connection lost"); + } + + @Test + void duplicates_silentlyDeduplicated() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub(Map.of(), calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("a", "a", "b", "a"), null, null)); + + assertThat(res.closed()).containsExactly("a", "b"); + assertThat(res.skipped()).isEmpty(); + assertThat(res.errors()).isEmpty(); + // service.close для "a" вызвался ровно 1 раз (дубликаты дедуплицированы) + assertThat(calls).containsExactly("a", "b"); + } + + @Test + void blankBusinessKey_classifiedAsSkipped_invalidKey() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub(Map.of(), calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(java.util.Arrays.asList("a", null, "", " ", "b"), null, null)); + + assertThat(res.closed()).containsExactly("a", "b"); + assertThat(res.skipped()).hasSize(3); + assertThat(res.skipped()).extracting(BulkCloseResponse.SkippedItem::reason) + .containsOnly("invalid_key"); + // service.close НЕ должен вызваться для невалидных ключей + assertThat(calls).containsExactly("a", "b"); + } + + @Test + void atTimestamp_passedThroughToServiceClose() { + List capturedKeys = new ArrayList<>(); + List capturedAt = new ArrayList<>(); + List capturedReason = new ArrayList<>(); + DictionaryRecordService rs = new DictionaryRecordService(null, null, null, null, null, null, null) { + @Override + public void close(String dict, String bk, OffsetDateTime at, String reason) { + capturedKeys.add(bk); + capturedAt.add(at); + capturedReason.add(reason); + } + }; + BulkRecordService bulk = new BulkRecordService(rs); + + OffsetDateTime when = OffsetDateTime.parse("2026-01-01T00:00:00Z"); + bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), when, "scheduled deactivation")); + + assertThat(capturedKeys).containsExactly("a"); + assertThat(capturedAt).containsExactly(when); + assertThat(capturedReason).containsExactly("scheduled deactivation"); + } + + @Test + void mixedResults_allCategoriesPopulated() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub( + Map.of( + "dup", OrdinisException.notFound("record_not_active", "x"), + "bad", new IllegalStateException("boom")), + calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("ok1", "dup", "bad", "ok2"), null, null)); + + assertThat(res.closed()).containsExactly("ok1", "ok2"); + assertThat(res.skipped()).extracting(BulkCloseResponse.SkippedItem::businessKey) + .containsExactly("dup"); + assertThat(res.errors()).extracting(BulkCloseResponse.ErrorItem::businessKey) + .containsExactly("bad"); + } + + @Test + void emptyBatch_returnsEmptyResultWithoutCallingService() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub(Map.of(), calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of(), null, null)); + + assertThat(res.closed()).isEmpty(); + assertThat(res.skipped()).isEmpty(); + assertThat(res.errors()).isEmpty(); + assertThat(calls).isEmpty(); + } + + @Test + void closeOrder_preservesInputOrder() { + List calls = new ArrayList<>(); + DictionaryRecordService rs = routedStub(Map.of(), calls); + BulkRecordService bulk = new BulkRecordService(rs); + + BulkCloseResponse res = bulk.bulkClose(DICT, + new BulkCloseRequest(List.of("z", "m", "a"), null, null)); + + // closed[] сохраняет порядок входа — не пересортировано + assertThat(res.closed()).containsExactly("z", "m", "a"); + // service.close вызывался в том же порядке (для audit log consistency) + assertThat(calls).containsExactly("z", "m", "a"); + } +}