From d4e2e731df42821bb37e4caf9bf6d04f37cc2ce5 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 19:33:25 +0300 Subject: [PATCH] =?UTF-8?q?test:=20re-enable=20WebhookE2ETest=20=E2=80=94?= =?UTF-8?q?=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20Kafka=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebhookDispatcher читает outbox_events напрямую и не зависит от Kafka. OutboxPoller (единственный consumer KafkaTemplate в runtime) выключаем через ordinis.outbox.enabled=false, и spring-kafka autoconfig — через spring.autoconfigure.exclude. Kafka container теперь не нужен для этого теста. E2ESupport: Kafka container теперь lazy. Тесты вызывающие registerProperties() триггерят start(); тесты на новом registerPostgresOnlyProperties() — нет. Локально: WebhookE2ETest 2/2 PASSED за 9.8s (раньше timeout на CI 20-30s). Boot cold start 7s vs ~15s с Kafka. Уходит главный источник flakiness — Kafka broker timing на shared CI runner. OutboxKafkaE2ETest по-прежнему использует registerProperties() → Kafka container стартует — поведение этого теста не изменено. --- .../ordinis/app/e2e/E2ESupport.java | 52 ++++++++++++++++--- .../ordinis/app/e2e/WebhookE2ETest.java | 40 ++++++++------ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java index 8029f05..1ae1dac 100644 --- a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java @@ -11,7 +11,13 @@ import org.testcontainers.utility.DockerImageName; * между тестовыми классами через {@link org.testcontainers.junit.jupiter.Container} * shared mode. * - *

Использовать через extends либо composition в @SpringBootTest классах. + *

Postgres стартует eagerly в class init — нужен всем e2e тестам. + * Kafka стартует lazy — только если {@link #registerProperties} вызвана. Тесты + * которые не используют Kafka (например {@code WebhookE2ETest}) экономят + * 5-10 секунд cold-start на CI и убирают целый класс flakiness (kafka broker + * не успевает отвечать на ранние produce/poll). + * + *

Использовать через composition в @SpringBootTest классах. */ public final class E2ESupport { @@ -28,30 +34,62 @@ public final class E2ESupport { // Confluent Kafka — confluentinc/cp-kafka работает out-of-the-box. // Confluent 7.6.0 включает в Kafka 3.6 — kraft mode. + // Lazy: стартует при первом вызове registerProperties() (через kafka()). @SuppressWarnings("resource") - static final KafkaContainer KAFKA = new KafkaContainer( + private static final KafkaContainer KAFKA = new KafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) .withReuse(true); + private static volatile boolean kafkaStarted = false; + static { POSTGRES.start(); - KAFKA.start(); } private E2ESupport() {} /** - * Прокидывает DB + Kafka URLs в Spring Boot context. Вызывать из - * {@code @DynamicPropertySource}. + * Lazy-старт Kafka при первом обращении. Synchronized — testcontainers + * shared между классами, гонок не должно быть. + */ + private static synchronized KafkaContainer kafka() { + if (!kafkaStarted) { + KAFKA.start(); + kafkaStarted = true; + } + return KAFKA; + } + + /** + * Прокидывает DB + Kafka URLs в Spring Boot context. Использовать в тестах + * где есть Kafka producers/consumers. */ public static void registerProperties(DynamicPropertyRegistry registry) { + registerPostgresProperties(registry); + registry.add("spring.kafka.bootstrap-servers", () -> kafka().getBootstrapServers()); + } + + /** + * Прокидывает только DB URLs. Использовать в тестах которые не туда не + * сюда не нуждаются в Kafka — экономит 5-10s cold start на CI и убирает + * Kafka-специфичную flakiness. + * + *

Тест ДОЛЖЕН либо отключить {@code spring-kafka} autoconfig + * через {@code spring.autoconfigure.exclude}, либо отключить outbox + * poller (он единственный использует {@code KafkaTemplate} в runtime + * code) через {@code ordinis.outbox.enabled=false}. Иначе Spring при + * старте попытается подключиться к {@code localhost:9092} и зависнет. + */ + public static void registerPostgresOnlyProperties(DynamicPropertyRegistry registry) { + registerPostgresProperties(registry); + } + + private static void registerPostgresProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); registry.add("spring.datasource.username", POSTGRES::getUsername); registry.add("spring.datasource.password", POSTGRES::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - // Liquibase использует тот же datasource — отдельные креды не нужны. registry.add("spring.liquibase.url", POSTGRES::getJdbcUrl); registry.add("spring.liquibase.user", POSTGRES::getUsername); diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java index 521e66e..781e468 100644 --- a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java @@ -53,27 +53,28 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { - "ordinis.outbox.enabled=true", - "ordinis.outbox.poll-interval-ms=200", - "ordinis.outbox.batch-size=20", + // Outbox poller off — он единственный требует KafkaTemplate. Webhook + // dispatcher читает outbox_events напрямую, Kafka не нужна. + "ordinis.outbox.enabled=false", "ordinis.webhook.enabled=true", "ordinis.webhook.match-interval-ms=200", "ordinis.webhook.send-interval-ms=200", "ordinis.webhook.allowed-hosts=localhost,127.0.0.1", + // Spring auto-config Kafka не нужно — нет KafkaTemplate потребителей + // без OutboxPoller. Иначе при старте Boot попытается подключиться + // к localhost:9092 и завесит контекст. + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration", }) @AutoConfigureMockMvc @ActiveProfiles("test") -@org.junit.jupiter.api.Disabled( - "Flaky на CI testcontainers — timeout 20-30s не достаточен для cold start " + - "Spring Boot + Postgres + Kafka + WebhookDispatcher schedule cycles. " + - "Логика покрыта 26 unit tests (HmacSigner + SsrfGuard + DispatcherMatch + " + - "DeliveryLifecycle). Re-enable когда сделаем dedicated CI runner с warm " + - "Docker images + увеличим timeout до 60s.") class WebhookE2ETest { @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { - E2ESupport.registerProperties(registry); + // Только Postgres — без Kafka container. Экономит ~5-10s cold start + // и убирает класс flakiness. + E2ESupport.registerPostgresOnlyProperties(registry); } @Autowired MockMvc mvc; @@ -150,8 +151,11 @@ class WebhookE2ETest { .content(om.writeValueAsBytes(recBody))) .andExpect(status().is2xxSuccessful()); - // 4. Wait for delivery: receiver получил POST request - await().atMost(20, TimeUnit.SECONDS) + // 4. Wait for delivery: receiver получил POST request. + // 60s timeout запас на CI: ждём 1-2 schedule cycle (matchTick 200ms + + // sendTick 200ms) + сетевой round-trip. Без Kafka container время + // cold start теста ~3-5s, но всё ещё хочется запас на shared CI runner. + await().atMost(60, TimeUnit.SECONDS) .pollInterval(Duration.ofMillis(300)) .until(() -> received.stream().anyMatch(r -> r.body().contains(needle))); @@ -169,8 +173,10 @@ class WebhookE2ETest { String expectedSig = HmacSigner.sign(secret, req.body().getBytes(StandardCharsets.UTF_8)); assertThat(req.signatureHeader()).isEqualTo(expectedSig); - // 6. Verify DB delivery row marked delivered - await().atMost(10, TimeUnit.SECONDS) + // 6. Verify DB delivery row marked delivered. Receiver уже получил body + // (см. await выше) — delivery row либо уже есть, либо будет в ближайшем + // sendTick цикле. + await().atMost(20, TimeUnit.SECONDS) .pollInterval(Duration.ofMillis(300)) .until(() -> deliveryRepo.count() > deliveriesBefore); @@ -178,7 +184,7 @@ class WebhookE2ETest { subId, org.springframework.data.domain.PageRequest.of(0, 10)).getContent(); assertThat(all).isNotEmpty(); WebhookDelivery latest = all.get(0); - await().atMost(10, TimeUnit.SECONDS) + await().atMost(20, TimeUnit.SECONDS) .pollInterval(Duration.ofMillis(300)) .until(() -> { WebhookDelivery d = deliveryRepo.findById(latest.getId()).orElseThrow(); @@ -236,8 +242,8 @@ class WebhookE2ETest { .content(om.writeValueAsBytes(recBody))).andExpect(status().is2xxSuccessful()); // Ждём что delivery с status=retrying появится (HTTP 500 → markFailed). - // Timeout 30s покрывает CI cold start + несколько schedule циклов. - await().atMost(30, TimeUnit.SECONDS) + // Timeout 60s покрывает CI cold start + несколько schedule циклов. + await().atMost(60, TimeUnit.SECONDS) .pollInterval(Duration.ofMillis(300)) .until(() -> deliveryRepo.findBySubscriptionIdOrderByCreatedAtDesc( sub.getId(), org.springframework.data.domain.PageRequest.of(0, 10))