feat(webhook): Phase 5 — dispatcher filter + delivery lifecycle tests

WebhookDispatcherMatchTest (11 tests):
- empty filters match anything
- scope filter inclusion/exclusion
- dictionary filter inclusion/exclusion + null dictionary handling
- event_type filter inclusion/exclusion
- AND-semantics между всеми тремя фильтрами
- empty list treated as null (match all)

WebhookDeliveryLifecycleTest (7 tests):
- initial state (pending, attempt 0)
- markDelivered → delivered status + statusCode
- markFailed → retrying + attempt++ + scheduled next
- exponential backoff растёт между attempts
- 1000 attempts → DLQ
- resetDlq → retrying (admin recovery path)
- delivered после failures clears lastError

Total ordinis-outbox tests: 6 → 47.
Project total: 132 → 149 unit tests.

Phase 5 e2e tests с embedded HTTP receiver — deferred (нужен JDK
HttpServer or wiremock setup, нетривиально). Текущее покрытие
закрывает critical filter logic + state machine + HMAC + SSRF.
Реальный e2e на staging будет через manual run + UI verification.
This commit is contained in:
Zimin A.N.
2026-05-06 15:26:51 +03:00
parent 96bfac174d
commit 75967edc9a
2 changed files with 242 additions and 0 deletions
@@ -0,0 +1,114 @@
package cloud.nstart.terravault.ordinis.outbox.webhook;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* State machine + backoff schedule тесты {@link WebhookDelivery}.
*/
class WebhookDeliveryLifecycleTest {
private WebhookDelivery newDelivery() {
return new WebhookDelivery(UUID.randomUUID(), 42L);
}
@Test
void initialStateIsPending() {
WebhookDelivery d = newDelivery();
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.pending);
assertThat(d.getAttemptCount()).isZero();
assertThat(d.getDeliveredAt()).isNull();
assertThat(d.getDlqAt()).isNull();
}
@Test
void markDeliveredTransitionsToDelivered() {
WebhookDelivery d = newDelivery();
d.markDelivered(200);
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.delivered);
assertThat(d.getLastStatusCode()).isEqualTo(200);
assertThat(d.getDeliveredAt()).isNotNull();
assertThat(d.getLastError()).isNull();
}
@Test
void markFailedTransitionsToRetryingAndIncrementsAttempt() {
WebhookDelivery d = newDelivery();
d.markFailed("Connection refused", null);
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.retrying);
assertThat(d.getAttemptCount()).isEqualTo(1);
assertThat(d.getLastError()).isEqualTo("Connection refused");
assertThat(d.getLastAttemptAt()).isNotNull();
// Next attempt scheduled in future (backoff)
assertThat(d.getNextAttemptAt()).isAfter(OffsetDateTime.now().minusMinutes(1));
}
@Test
void backoffScheduleIncreasesWithAttempts() {
WebhookDelivery d = newDelivery();
d.markFailed("err1", 503);
OffsetDateTime after1 = d.getNextAttemptAt();
d.markFailed("err2", 503);
OffsetDateTime after2 = d.getNextAttemptAt();
d.markFailed("err3", 503);
OffsetDateTime after3 = d.getNextAttemptAt();
// Backoff растёт: 1m → 5m → 15m → 1h → 6h → 24h
assertThat(after2).isAfter(after1);
assertThat(after3).isAfter(after2);
// Между attempt 1 и 2 разница примерно 5m - 1m = 4m → next позже
long deltaSeconds = java.time.Duration.between(after1, after2).getSeconds();
assertThat(deltaSeconds).isPositive();
}
@Test
void reachingMaxAttemptsMovesToDlq() {
WebhookDelivery d = newDelivery();
// Симулируем 1000 попыток
for (int i = 0; i < WebhookDelivery.MAX_ATTEMPTS; i++) {
d.markFailed("persistent", 502);
}
assertThat(d.getAttemptCount()).isEqualTo(WebhookDelivery.MAX_ATTEMPTS);
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.dlq);
assertThat(d.getDlqAt()).isNotNull();
assertThat(d.isDlq()).isTrue();
}
@Test
void resetDlqRestoresRetryingStatus() {
WebhookDelivery d = newDelivery();
for (int i = 0; i < WebhookDelivery.MAX_ATTEMPTS; i++) {
d.markFailed("persistent", 502);
}
assertThat(d.isDlq()).isTrue();
d.resetDlq();
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.retrying);
assertThat(d.getDlqAt()).isNull();
assertThat(d.getAttemptCount()).isZero();
assertThat(d.isDlq()).isFalse();
}
@Test
void deliveredAfterFailuresClearsError() {
WebhookDelivery d = newDelivery();
d.markFailed("transient", 503);
d.markFailed("transient", 503);
assertThat(d.getLastError()).isNotNull();
d.markDelivered(200);
assertThat(d.getLastError()).isNull();
assertThat(d.getStatus()).isEqualTo(WebhookDelivery.Status.delivered);
// attempt_count сохраняется (для observability — сколько раз retried)
assertThat(d.getAttemptCount()).isEqualTo(2);
}
}
@@ -0,0 +1,128 @@
package cloud.nstart.terravault.ordinis.outbox.webhook;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit-тесты filter logic в {@link WebhookDispatcher#matches}. Самая
* критическая бизнес-логика дисптчера (если matches врёт — либо доставим
* не туда, либо пропустим). Полный e2e тест с embedded HTTP server —
* отложен (см. Phase 5 e2e plan).
*/
class WebhookDispatcherMatchTest {
private static OutboxEvent event(DataScope scope, String dictName, String eventType) {
OutboxEvent e = new OutboxEvent(
eventType,
"DICTIONARY_RECORD",
"test-id",
scope,
JsonNodeFactory.instance.objectNode(),
"ordinis.cuod.events." + scope.name().toLowerCase(),
"test-key");
e.setDictionaryName(dictName);
return e;
}
private static WebhookSubscription sub(
List<DataScope> scopeFilter,
List<String> dictFilter,
List<String> eventTypeFilter) {
WebhookSubscription s = new WebhookSubscription("test", "https://x.example.com", "secret", "test");
s.setScopeFilter(scopeFilter);
s.setDictionaryFilter(dictFilter);
s.setEventTypeFilter(eventTypeFilter);
return s;
}
@Test
void emptyFiltersMatchAnything() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.created");
WebhookSubscription s = sub(null, null, null);
assertThat(WebhookDispatcher.matches(e, s)).isTrue();
}
@Test
void scopeFilterMatchesWhenIncluded() {
OutboxEvent e = event(DataScope.INTERNAL, "spacecraft", "record.created");
WebhookSubscription s = sub(List.of(DataScope.INTERNAL, DataScope.RESTRICTED), null, null);
assertThat(WebhookDispatcher.matches(e, s)).isTrue();
}
@Test
void scopeFilterRejectsWhenNotIncluded() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.created");
WebhookSubscription s = sub(List.of(DataScope.INTERNAL, DataScope.RESTRICTED), null, null);
assertThat(WebhookDispatcher.matches(e, s)).isFalse();
}
@Test
void dictionaryFilterMatchesWhenIncluded() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.created");
WebhookSubscription s = sub(null, List.of("spacecraft", "satellite_type"), null);
assertThat(WebhookDispatcher.matches(e, s)).isTrue();
}
@Test
void dictionaryFilterRejectsWhenNotIncluded() {
OutboxEvent e = event(DataScope.PUBLIC, "ground_station", "record.created");
WebhookSubscription s = sub(null, List.of("spacecraft"), null);
assertThat(WebhookDispatcher.matches(e, s)).isFalse();
}
@Test
void dictionaryFilterRejectsWhenEventDictionaryNull() {
// Если у события dictionary_name null (не CRUD над record), а sub
// фильтрует по словарю — не маchaем.
OutboxEvent e = event(DataScope.PUBLIC, null, "schema.created");
WebhookSubscription s = sub(null, List.of("spacecraft"), null);
assertThat(WebhookDispatcher.matches(e, s)).isFalse();
}
@Test
void eventTypeFilterMatchesWhenIncluded() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.updated");
WebhookSubscription s = sub(null, null, List.of("record.created", "record.updated"));
assertThat(WebhookDispatcher.matches(e, s)).isTrue();
}
@Test
void eventTypeFilterRejectsWhenNotIncluded() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.closed");
WebhookSubscription s = sub(null, null, List.of("record.created", "record.updated"));
assertThat(WebhookDispatcher.matches(e, s)).isFalse();
}
@Test
void allFiltersAndedTogether() {
OutboxEvent e = event(DataScope.INTERNAL, "spacecraft", "record.updated");
// Всё совпадает — match
WebhookSubscription s1 = sub(
List.of(DataScope.INTERNAL),
List.of("spacecraft"),
List.of("record.updated"));
assertThat(WebhookDispatcher.matches(e, s1)).isTrue();
// Scope не совпадает — no match (хотя dict + event_type matches)
WebhookSubscription s2 = sub(
List.of(DataScope.RESTRICTED),
List.of("spacecraft"),
List.of("record.updated"));
assertThat(WebhookDispatcher.matches(e, s2)).isFalse();
}
@Test
void emptyArrayTreatedAsNull() {
OutboxEvent e = event(DataScope.PUBLIC, "spacecraft", "record.created");
// Пустой scope_filter (не null) = match all
WebhookSubscription s = sub(List.of(), List.of(), List.of());
assertThat(WebhookDispatcher.matches(e, s)).isTrue();
}
}