From 75967edc9a0e7e60dcb55f1ef3230a9283064748 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 15:26:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20Phase=205=20=E2=80=94=20dispat?= =?UTF-8?q?cher=20filter=20+=20delivery=20lifecycle=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../webhook/WebhookDeliveryLifecycleTest.java | 114 ++++++++++++++++ .../webhook/WebhookDispatcherMatchTest.java | 128 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDeliveryLifecycleTest.java create mode 100644 ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcherMatchTest.java diff --git a/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDeliveryLifecycleTest.java b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDeliveryLifecycleTest.java new file mode 100644 index 0000000..8fbef87 --- /dev/null +++ b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDeliveryLifecycleTest.java @@ -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); + } +} diff --git a/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcherMatchTest.java b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcherMatchTest.java new file mode 100644 index 0000000..6a22e94 --- /dev/null +++ b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcherMatchTest.java @@ -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 scopeFilter, + List dictFilter, + List 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(); + } +}