feat(webhook): time-series histogram stats endpoint + frontend chart

This commit is contained in:
Александр Зимин
2026-05-12 14:30:22 +00:00
parent 91f4310a77
commit 5479142093
11 changed files with 1049 additions and 2 deletions
@@ -22,6 +22,9 @@ import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@@ -42,7 +45,9 @@ import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -297,6 +302,99 @@ class WebhookE2ETest {
}
}
/**
* Mock JWT с INTERNAL role для admin endpoints. spring-security-test
* authentication() post-processor правильно проходит через filter chain
* (filter не overwrite'ит auth когда передан таким образом).
*/
private static org.springframework.test.web.servlet.request.RequestPostProcessor internalJwt() {
Jwt jwt = Jwt.withTokenValue("test-token")
.header("alg", "none")
.claim("sub", "test-user")
.claim("realm_access", java.util.Map.of("roles", java.util.List.of("ordinis-internal")))
.build();
return SecurityMockMvcRequestPostProcessors.authentication(
new JwtAuthenticationToken(jwt, java.util.Collections.emptyList()));
}
@Test
void statsEndpoint_returnsContinuousTimeline_withCorrectCounts() throws Exception {
WebhookSubscription sub = subRepo.save(new WebhookSubscription(
"stats-test", "http://127.0.0.1:1/null", "stats-secret", "test-user"));
UUID subId = sub.getId();
Long eventId1 = insertMinimalOutboxEvent("EVT-stats-1");
Long eventId2 = insertMinimalOutboxEvent("EVT-stats-2");
Long eventId3 = insertMinimalOutboxEvent("EVT-stats-3");
WebhookDelivery del1 = deliveryRepo.save(new WebhookDelivery(subId, eventId1));
del1.markDelivered(200);
deliveryRepo.save(del1);
WebhookDelivery del2 = deliveryRepo.save(new WebhookDelivery(subId, eventId2));
del2.markFailed("connection refused", null);
deliveryRepo.save(del2);
deliveryRepo.save(new WebhookDelivery(subId, eventId3));
mvc.perform(get("/api/v1/admin/webhooks/subscriptions/{id}/stats", subId)
.with(internalJwt())
.param("bucketBy", "hour"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.bucketBy").value("hour"))
.andExpect(jsonPath("$.buckets").isArray())
.andExpect(jsonPath("$.buckets[?(@.total > 0)].pending").value(org.hamcrest.Matchers.hasItem(1)))
.andExpect(jsonPath("$.buckets[?(@.total > 0)].retrying").value(org.hamcrest.Matchers.hasItem(1)))
.andExpect(jsonPath("$.buckets[?(@.total > 0)].delivered").value(org.hamcrest.Matchers.hasItem(1)));
}
@Test
void statsEndpoint_rejectsInvalidBucketBy() throws Exception {
WebhookSubscription sub = subRepo.save(new WebhookSubscription(
"stats-bad", "http://127.0.0.1:1/null", "secret-bad", "test-user"));
mvc.perform(get("/api/v1/admin/webhooks/subscriptions/{id}/stats", sub.getId())
.with(internalJwt())
.param("bucketBy", "year"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("webhook_stats_bucket_invalid"));
}
@Test
void statsEndpoint_rejectsWindowTooLarge() throws Exception {
WebhookSubscription sub = subRepo.save(new WebhookSubscription(
"stats-big", "http://127.0.0.1:1/null", "secret-big", "test-user"));
String fromIso = java.time.OffsetDateTime.now().minusDays(60).toString();
String toIso = java.time.OffsetDateTime.now().toString();
mvc.perform(get("/api/v1/admin/webhooks/subscriptions/{id}/stats", sub.getId())
.with(internalJwt())
.param("bucketBy", "hour")
.param("from", fromIso)
.param("to", toIso))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("webhook_stats_window_too_large"));
}
@Autowired org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
/**
* Минимальный outbox_event insert для удовлетворения FK constraint
* webhook_deliveries.outbox_event_id. NOT NULL columns set к dummy values.
*/
private Long insertMinimalOutboxEvent(String key) {
return jdbcTemplate.queryForObject(
"""
INSERT INTO outbox_events
(event_type, aggregate_type, aggregate_id, data_scope,
payload, kafka_topic, kafka_key, created_at, attempt_count)
VALUES
('TestEvent', 'Test', ?, 'PUBLIC',
'{}'::jsonb, 'test-topic', ?, now(), 0)
RETURNING id
""",
Long.class,
key, key);
}
// ---------- no-op scheduler implementation ----------
/**