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}
* 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 {
@@ -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.
*
* <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.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);
@@ -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))