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