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:
@@ -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);
|
||||||
|
|||||||
+23
-17
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user