feat(webhook): time-series histogram stats endpoint + frontend chart
This commit is contained in:
@@ -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 ----------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user