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");
+ }
+}