test: re-enable WebhookE2ETest — убрать Kafka из теста

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 стартует — поведение этого теста не изменено.
This commit is contained in:
Zimin A.N.
2026-05-06 19:33:25 +03:00
parent c5d41da834
commit d4e2e731df
2 changed files with 68 additions and 24 deletions
@@ -11,7 +11,13 @@ import org.testcontainers.utility.DockerImageName;
* между тестовыми классами через {@link org.testcontainers.junit.jupiter.Container} * между тестовыми классами через {@link org.testcontainers.junit.jupiter.Container}
* shared mode. * shared mode.
* *
* <p>Использовать через extends либо composition в @SpringBootTest классах. * <p>Postgres стартует eagerly в class init — нужен всем e2e тестам.
* Kafka стартует lazy — только если {@link #registerProperties} вызвана. Тесты
* которые не используют Kafka (например {@code WebhookE2ETest}) экономят
* 5-10 секунд cold-start на CI и убирают целый класс flakiness (kafka broker
* не успевает отвечать на ранние produce/poll).
*
* <p>Использовать через composition в @SpringBootTest классах.
*/ */
public final class E2ESupport { public final class E2ESupport {
@@ -28,30 +34,62 @@ public final class E2ESupport {
// Confluent Kafka — confluentinc/cp-kafka работает out-of-the-box. // Confluent Kafka — confluentinc/cp-kafka работает out-of-the-box.
// Confluent 7.6.0 включает в Kafka 3.6 — kraft mode. // Confluent 7.6.0 включает в Kafka 3.6 — kraft mode.
// Lazy: стартует при первом вызове registerProperties() (через kafka()).
@SuppressWarnings("resource") @SuppressWarnings("resource")
static final KafkaContainer KAFKA = new KafkaContainer( private static final KafkaContainer KAFKA = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withReuse(true); .withReuse(true);
private static volatile boolean kafkaStarted = false;
static { static {
POSTGRES.start(); POSTGRES.start();
KAFKA.start();
} }
private E2ESupport() {} private E2ESupport() {}
/** /**
* Прокидывает DB + Kafka URLs в Spring Boot context. Вызывать из * Lazy-старт Kafka при первом обращении. Synchronized — testcontainers
* {@code @DynamicPropertySource}. * 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) { 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.
*
* <p>Тест ДОЛЖЕН либо отключить {@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.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername); registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword); registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
// Liquibase использует тот же datasource — отдельные креды не нужны. // Liquibase использует тот же datasource — отдельные креды не нужны.
registry.add("spring.liquibase.url", POSTGRES::getJdbcUrl); registry.add("spring.liquibase.url", POSTGRES::getJdbcUrl);
registry.add("spring.liquibase.user", POSTGRES::getUsername); registry.add("spring.liquibase.user", POSTGRES::getUsername);
@@ -53,27 +53,28 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest( @SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = { properties = {
"ordinis.outbox.enabled=true", // Outbox poller off — он единственный требует KafkaTemplate. Webhook
"ordinis.outbox.poll-interval-ms=200", // dispatcher читает outbox_events напрямую, Kafka не нужна.
"ordinis.outbox.batch-size=20", "ordinis.outbox.enabled=false",
"ordinis.webhook.enabled=true", "ordinis.webhook.enabled=true",
"ordinis.webhook.match-interval-ms=200", "ordinis.webhook.match-interval-ms=200",
"ordinis.webhook.send-interval-ms=200", "ordinis.webhook.send-interval-ms=200",
"ordinis.webhook.allowed-hosts=localhost,127.0.0.1", "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 @AutoConfigureMockMvc
@ActiveProfiles("test") @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 { class WebhookE2ETest {
@DynamicPropertySource @DynamicPropertySource
static void props(DynamicPropertyRegistry registry) { static void props(DynamicPropertyRegistry registry) {
E2ESupport.registerProperties(registry); // Только Postgres — без Kafka container. Экономит ~5-10s cold start
// и убирает класс flakiness.
E2ESupport.registerPostgresOnlyProperties(registry);
} }
@Autowired MockMvc mvc; @Autowired MockMvc mvc;
@@ -150,8 +151,11 @@ class WebhookE2ETest {
.content(om.writeValueAsBytes(recBody))) .content(om.writeValueAsBytes(recBody)))
.andExpect(status().is2xxSuccessful()); .andExpect(status().is2xxSuccessful());
// 4. Wait for delivery: receiver получил POST request // 4. Wait for delivery: receiver получил POST request.
await().atMost(20, TimeUnit.SECONDS) // 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)) .pollInterval(Duration.ofMillis(300))
.until(() -> received.stream().anyMatch(r -> r.body().contains(needle))); .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)); String expectedSig = HmacSigner.sign(secret, req.body().getBytes(StandardCharsets.UTF_8));
assertThat(req.signatureHeader()).isEqualTo(expectedSig); assertThat(req.signatureHeader()).isEqualTo(expectedSig);
// 6. Verify DB delivery row marked delivered // 6. Verify DB delivery row marked delivered. Receiver уже получил body
await().atMost(10, TimeUnit.SECONDS) // (см. await выше) — delivery row либо уже есть, либо будет в ближайшем
// sendTick цикле.
await().atMost(20, TimeUnit.SECONDS)
.pollInterval(Duration.ofMillis(300)) .pollInterval(Duration.ofMillis(300))
.until(() -> deliveryRepo.count() > deliveriesBefore); .until(() -> deliveryRepo.count() > deliveriesBefore);
@@ -178,7 +184,7 @@ class WebhookE2ETest {
subId, org.springframework.data.domain.PageRequest.of(0, 10)).getContent(); subId, org.springframework.data.domain.PageRequest.of(0, 10)).getContent();
assertThat(all).isNotEmpty(); assertThat(all).isNotEmpty();
WebhookDelivery latest = all.get(0); WebhookDelivery latest = all.get(0);
await().atMost(10, TimeUnit.SECONDS) await().atMost(20, TimeUnit.SECONDS)
.pollInterval(Duration.ofMillis(300)) .pollInterval(Duration.ofMillis(300))
.until(() -> { .until(() -> {
WebhookDelivery d = deliveryRepo.findById(latest.getId()).orElseThrow(); WebhookDelivery d = deliveryRepo.findById(latest.getId()).orElseThrow();
@@ -236,8 +242,8 @@ class WebhookE2ETest {
.content(om.writeValueAsBytes(recBody))).andExpect(status().is2xxSuccessful()); .content(om.writeValueAsBytes(recBody))).andExpect(status().is2xxSuccessful());
// Ждём что delivery с status=retrying появится (HTTP 500 → markFailed). // Ждём что delivery с status=retrying появится (HTTP 500 → markFailed).
// Timeout 30s покрывает CI cold start + несколько schedule циклов. // Timeout 60s покрывает CI cold start + несколько schedule циклов.
await().atMost(30, TimeUnit.SECONDS) await().atMost(60, TimeUnit.SECONDS)
.pollInterval(Duration.ofMillis(300)) .pollInterval(Duration.ofMillis(300))
.until(() -> deliveryRepo.findBySubscriptionIdOrderByCreatedAtDesc( .until(() -> deliveryRepo.findBySubscriptionIdOrderByCreatedAtDesc(
sub.getId(), org.springframework.data.domain.PageRequest.of(0, 10)) sub.getId(), org.springframework.data.domain.PageRequest.of(0, 10))