Skip to main content

forge_core/testing/context/
webhook.rs

1//! Test context for webhook functions.
2
3#![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
16/// Test context for webhook functions.
17pub struct TestWebhookContext {
18    pub webhook_name: String,
19    pub request_id: String,
20    pub idempotency_key: Option<String>,
21    /// Keys are always lowercased.
22    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")); // case-insensitive
181    }
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        // request_id must be a parseable UUID when not overridden.
210        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        // Stored map should only contain lowercased keys.
233        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        // Caller's handle sees the dispatch even though the call went through ctx.
269        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        // EnvAccess wires the MockEnvProvider correctly.
281        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        // The mock records accesses, so we can verify reads landed on the same provider.
285        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}