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