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