forge_core/testing/context/
webhook.rs1#![allow(clippy::unwrap_used, clippy::indexing_slicing)]
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use sqlx::PgPool;
9use uuid::Uuid;
10
11use super::super::mock_dispatch::MockJobDispatch;
12use super::super::mock_http::{MockHttp, MockRequest, MockResponse};
13use crate::Result;
14use crate::env::{EnvAccess, EnvProvider, MockEnvProvider};
15
16pub struct TestWebhookContext {
18 pub webhook_name: String,
19 pub request_id: String,
20 pub idempotency_key: Option<String>,
21 headers: HashMap<String, String>,
23 pool: Option<PgPool>,
24 http: Arc<MockHttp>,
25 job_dispatch: Arc<MockJobDispatch>,
26 env_provider: Arc<MockEnvProvider>,
27}
28
29impl TestWebhookContext {
30 pub fn builder(webhook_name: impl Into<String>) -> TestWebhookContextBuilder {
31 TestWebhookContextBuilder::new(webhook_name)
32 }
33
34 pub fn db(&self) -> Option<&PgPool> {
35 self.pool.as_ref()
36 }
37
38 pub fn http(&self) -> &MockHttp {
39 &self.http
40 }
41
42 pub fn header(&self, name: &str) -> Option<&str> {
43 self.headers.get(&name.to_lowercase()).map(|s| s.as_str())
44 }
45
46 pub fn headers(&self) -> &HashMap<String, String> {
47 &self.headers
48 }
49
50 pub fn job_dispatch(&self) -> &MockJobDispatch {
51 &self.job_dispatch
52 }
53
54 pub async fn dispatch_job<T: serde::Serialize>(&self, job_type: &str, args: T) -> Result<Uuid> {
55 self.job_dispatch.dispatch(job_type, args).await
56 }
57
58 pub fn env_mock(&self) -> &MockEnvProvider {
59 &self.env_provider
60 }
61}
62
63impl EnvAccess for TestWebhookContext {
64 fn env_provider(&self) -> &dyn EnvProvider {
65 self.env_provider.as_ref()
66 }
67}
68
69pub struct TestWebhookContextBuilder {
70 webhook_name: String,
71 request_id: Option<String>,
72 idempotency_key: Option<String>,
73 headers: HashMap<String, String>,
74 pool: Option<PgPool>,
75 http: MockHttp,
76 job_dispatch: Arc<MockJobDispatch>,
77 env_vars: HashMap<String, String>,
78}
79
80impl TestWebhookContextBuilder {
81 pub fn new(webhook_name: impl Into<String>) -> Self {
82 Self {
83 webhook_name: webhook_name.into(),
84 request_id: None,
85 idempotency_key: None,
86 headers: HashMap::new(),
87 pool: None,
88 http: MockHttp::new(),
89 job_dispatch: Arc::new(MockJobDispatch::new()),
90 env_vars: HashMap::new(),
91 }
92 }
93
94 pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
95 self.request_id = Some(id.into());
96 self
97 }
98
99 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
100 self.idempotency_key = Some(key.into());
101 self
102 }
103
104 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
105 self.headers
106 .insert(name.into().to_lowercase(), value.into());
107 self
108 }
109
110 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
111 for (k, v) in headers {
112 self.headers.insert(k.to_lowercase(), v);
113 }
114 self
115 }
116
117 pub fn with_pool(mut self, pool: PgPool) -> Self {
118 self.pool = Some(pool);
119 self
120 }
121
122 pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
123 where
124 F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
125 {
126 self.http.add_mock_sync(pattern, handler);
127 self
128 }
129
130 pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
131 let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
132 self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
133 }
134
135 pub fn with_job_dispatch(mut self, dispatch: Arc<MockJobDispatch>) -> Self {
136 self.job_dispatch = dispatch;
137 self
138 }
139
140 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
141 self.env_vars.insert(key.into(), value.into());
142 self
143 }
144
145 pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
146 self.env_vars.extend(vars);
147 self
148 }
149
150 pub fn build(self) -> TestWebhookContext {
151 TestWebhookContext {
152 webhook_name: self.webhook_name,
153 request_id: self
154 .request_id
155 .unwrap_or_else(|| Uuid::new_v4().to_string()),
156 idempotency_key: self.idempotency_key,
157 headers: self.headers,
158 pool: self.pool,
159 http: Arc::new(self.http),
160 job_dispatch: self.job_dispatch,
161 env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn test_webhook_context_creation() {
172 let ctx = TestWebhookContext::builder("github_webhook")
173 .with_header("X-GitHub-Event", "push")
174 .with_idempotency_key("delivery-123")
175 .build();
176
177 assert_eq!(ctx.webhook_name, "github_webhook");
178 assert_eq!(ctx.idempotency_key, Some("delivery-123".to_string()));
179 assert_eq!(ctx.header("X-GitHub-Event"), Some("push"));
180 assert_eq!(ctx.header("x-github-event"), Some("push")); }
182
183 #[tokio::test]
184 async fn test_dispatch_job() {
185 let ctx = TestWebhookContext::builder("test").build();
186
187 let job_id = ctx
188 .dispatch_job("process_event", serde_json::json!({"action": "push"}))
189 .await
190 .unwrap();
191
192 assert!(!job_id.is_nil());
193 ctx.job_dispatch().assert_dispatched("process_event");
194 }
195
196 #[test]
197 fn test_headers_case_insensitive() {
198 let ctx = TestWebhookContext::builder("test")
199 .with_header("Content-Type", "application/json")
200 .build();
201
202 assert_eq!(ctx.header("content-type"), Some("application/json"));
203 assert_eq!(ctx.header("CONTENT-TYPE"), Some("application/json"));
204 }
205
206 #[test]
207 fn default_builder_auto_generates_request_id_and_no_idempotency_key() {
208 let ctx = TestWebhookContext::builder("anon").build();
209 assert!(Uuid::parse_str(&ctx.request_id).is_ok());
211 assert!(ctx.idempotency_key.is_none());
212 assert!(ctx.headers().is_empty());
213 assert!(ctx.db().is_none());
214 }
215
216 #[test]
217 fn with_request_id_overrides_generated_value() {
218 let ctx = TestWebhookContext::builder("a")
219 .with_request_id("req-42")
220 .build();
221 assert_eq!(ctx.request_id, "req-42");
222 }
223
224 #[test]
225 fn with_headers_bulk_lowercases_all_keys() {
226 let mut input = HashMap::new();
227 input.insert("X-One".to_string(), "1".to_string());
228 input.insert("X-TWO".to_string(), "2".to_string());
229 let ctx = TestWebhookContext::builder("a").with_headers(input).build();
230 assert_eq!(ctx.header("x-one"), Some("1"));
231 assert_eq!(ctx.header("x-two"), Some("2"));
232 for k in ctx.headers().keys() {
234 assert_eq!(k, &k.to_lowercase(), "header key not lowercased: {k}");
235 }
236 }
237
238 #[test]
239 fn header_returns_none_for_missing() {
240 let ctx = TestWebhookContext::builder("a").build();
241 assert!(ctx.header("absent").is_none());
242 }
243
244 #[tokio::test]
245 async fn dispatch_job_records_each_call_in_order() {
246 let ctx = TestWebhookContext::builder("h").build();
247 ctx.dispatch_job("a", serde_json::json!({"n": 1}))
248 .await
249 .unwrap();
250 ctx.dispatch_job("a", serde_json::json!({"n": 2}))
251 .await
252 .unwrap();
253 ctx.dispatch_job("b", serde_json::json!({})).await.unwrap();
254 ctx.job_dispatch().assert_dispatch_count("a", 2);
255 ctx.job_dispatch().assert_dispatch_count("b", 1);
256 ctx.job_dispatch().assert_not_dispatched("never");
257 }
258
259 #[tokio::test]
260 async fn with_job_dispatch_shares_state_across_clones() {
261 let shared = Arc::new(MockJobDispatch::new());
262 let ctx = TestWebhookContext::builder("h")
263 .with_job_dispatch(shared.clone())
264 .build();
265 ctx.dispatch_job("shared", serde_json::json!({}))
266 .await
267 .unwrap();
268 shared.assert_dispatched("shared");
270 }
271
272 #[test]
273 fn with_env_and_with_envs_compose() {
274 let mut bulk = HashMap::new();
275 bulk.insert("B1".to_string(), "vb1".to_string());
276 let ctx = TestWebhookContext::builder("h")
277 .with_env("A", "va")
278 .with_envs(bulk)
279 .build();
280 assert_eq!(ctx.env("A"), Some("va".to_string()));
282 assert_eq!(ctx.env("B1"), Some("vb1".to_string()));
283 assert!(ctx.env("UNSET").is_none());
284 assert!(ctx.env_mock().was_accessed("A"));
286 }
287
288 #[tokio::test]
289 async fn mock_http_json_returns_serialized_body_when_executed() {
290 let ctx = TestWebhookContext::builder("h")
291 .mock_http_json("https://example.test/echo", serde_json::json!({"ok": true}))
292 .build();
293
294 let req = MockRequest {
295 method: "GET".to_string(),
296 path: "/echo".to_string(),
297 url: "https://example.test/echo".to_string(),
298 headers: HashMap::new(),
299 body: serde_json::Value::Null,
300 };
301 let resp = ctx.http().execute(req).await;
302 assert_eq!(resp.status, 200);
303 assert_eq!(resp.body, serde_json::json!({"ok": true}));
304 ctx.http().assert_called("https://example.test/echo");
305 }
306}