test(records): unit-test BulkRecordService — 10 кейсов

Покрытие: all-success, classification (record_not_active → skipped,
другие OrdinisException + unexpected → errors), дедуп дубликатов внутри
batch'а, blank/null business_key → skipped invalid_key, передача
at/reason в service, mixed results, empty batch, preservation порядка
(audit consistency).

Mockito не может мокать DictionaryRecordService на Java 17+ (Mockito
inline-mock limitation для классов из standard distribution). Workaround:
анонимный subclass с null deps в конструкторе — переопределённый close()
не обращается к ним. routedStub() helper для per-key поведения через
Map<businessKey, Exception>.

Прогон: 60/60 в rest-api модуле (было 50, +10).
This commit is contained in:
Zimin A.N.
2026-05-07 11:55:03 +03:00
parent 5941207955
commit 640a633722
@@ -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.
*
* <p>{@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<String, String> 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<String, RuntimeException> errorByKey,
List<String> calls) {
return stubService((dict, bk) -> {
calls.add(bk);
RuntimeException ex = errorByKey.get(bk);
if (ex != null) throw ex;
});
}
@Test
void allSucceed_whenServiceClosesEachRecord() {
List<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> capturedKeys = new ArrayList<>();
List<OffsetDateTime> capturedAt = new ArrayList<>();
List<String> 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<String> 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<String> 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<String> 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");
}
}