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:
+230
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user