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))