Skip to main content

forge_core/testing/context/
webhook.rs

1//! Test context for webhook functions.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use sqlx::PgPool;
7use uuid::Uuid;
8
9use super::super::mock_dispatch::MockJobDispatch;
10use super::super::mock_http::{MockHttp, MockRequest, MockResponse};
11use crate::Result;
12use crate::env::{EnvAccess, EnvProvider, MockEnvProvider};
13
14/// Test context for webhook functions.
15///
16/// Provides an isolated testing environment for webhooks with configurable
17/// headers, idempotency key, and mock job dispatch.
18///
19/// # Example
20///
21/// ```ignore
22/// let ctx = TestWebhookContext::builder("github_webhook")
23///     .with_header("X-GitHub-Event", "push")
24///     .with_idempotency_key("delivery-123")
25///     .build();
26///
27/// // Dispatch a job
28/// ctx.dispatch_job("process_event", payload).await?;
29///
30/// // Verify job was dispatched
31/// ctx.job_dispatch().assert_dispatched("process_event");
32/// ```
33pub struct TestWebhookContext {
34    /// Webhook name.
35    pub webhook_name: String,
36    /// Request ID.
37    pub request_id: String,
38    /// Idempotency key (if extracted from request).
39    pub idempotency_key: Option<String>,
40    /// Request headers (lowercase keys).
41    headers: HashMap<String, String>,
42    /// Optional database pool.
43    pool: Option<PgPool>,
44    /// Mock HTTP client.
45    http: Arc<MockHttp>,
46    /// Mock job dispatch for verification.
47    job_dispatch: Arc<MockJobDispatch>,
48    /// Mock environment provider.
49    env_provider: Arc<MockEnvProvider>,
50}
51
52impl TestWebhookContext {
53    /// Create a new builder.
54    pub fn builder(webhook_name: impl Into<String>) -> TestWebhookContextBuilder {
55        TestWebhookContextBuilder::new(webhook_name)
56    }
57
58    /// Get the database pool (if available).
59    pub fn db(&self) -> Option<&PgPool> {
60        self.pool.as_ref()
61    }
62
63    /// Get the mock HTTP client.
64    pub fn http(&self) -> &MockHttp {
65        &self.http
66    }
67
68    /// Get a request header value (case-insensitive).
69    pub fn header(&self, name: &str) -> Option<&str> {
70        self.headers.get(&name.to_lowercase()).map(|s| s.as_str())
71    }
72
73    /// Get all headers.
74    pub fn headers(&self) -> &HashMap<String, String> {
75        &self.headers
76    }
77
78    /// Get the mock job dispatch for verification.
79    pub fn job_dispatch(&self) -> &MockJobDispatch {
80        &self.job_dispatch
81    }
82
83    /// Dispatch a job (records for later verification).
84    pub async fn dispatch_job<T: serde::Serialize>(&self, job_type: &str, args: T) -> Result<Uuid> {
85        self.job_dispatch.dispatch(job_type, args).await
86    }
87
88    /// Get the mock env provider for verification.
89    pub fn env_mock(&self) -> &MockEnvProvider {
90        &self.env_provider
91    }
92}
93
94impl EnvAccess for TestWebhookContext {
95    fn env_provider(&self) -> &dyn EnvProvider {
96        self.env_provider.as_ref()
97    }
98}
99
100/// Builder for TestWebhookContext.
101pub struct TestWebhookContextBuilder {
102    webhook_name: String,
103    request_id: Option<String>,
104    idempotency_key: Option<String>,
105    headers: HashMap<String, String>,
106    pool: Option<PgPool>,
107    http: MockHttp,
108    job_dispatch: Arc<MockJobDispatch>,
109    env_vars: HashMap<String, String>,
110}
111
112impl TestWebhookContextBuilder {
113    /// Create a new builder with webhook name.
114    pub fn new(webhook_name: impl Into<String>) -> Self {
115        Self {
116            webhook_name: webhook_name.into(),
117            request_id: None,
118            idempotency_key: None,
119            headers: HashMap::new(),
120            pool: None,
121            http: MockHttp::new(),
122            job_dispatch: Arc::new(MockJobDispatch::new()),
123            env_vars: HashMap::new(),
124        }
125    }
126
127    /// Set a specific request ID.
128    pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
129        self.request_id = Some(id.into());
130        self
131    }
132
133    /// Set the idempotency key.
134    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
135        self.idempotency_key = Some(key.into());
136        self
137    }
138
139    /// Add a request header.
140    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
141        self.headers
142            .insert(name.into().to_lowercase(), value.into());
143        self
144    }
145
146    /// Add multiple headers.
147    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
148        for (k, v) in headers {
149            self.headers.insert(k.to_lowercase(), v);
150        }
151        self
152    }
153
154    /// Set the database pool.
155    pub fn with_pool(mut self, pool: PgPool) -> Self {
156        self.pool = Some(pool);
157        self
158    }
159
160    /// Add an HTTP mock with a custom handler.
161    pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
162    where
163        F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
164    {
165        self.http.add_mock_sync(pattern, handler);
166        self
167    }
168
169    /// Add an HTTP mock that returns a JSON response.
170    pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
171        let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
172        self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
173    }
174
175    /// Use a specific mock job dispatch.
176    pub fn with_job_dispatch(mut self, dispatch: Arc<MockJobDispatch>) -> Self {
177        self.job_dispatch = dispatch;
178        self
179    }
180
181    /// Set a single environment variable.
182    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
183        self.env_vars.insert(key.into(), value.into());
184        self
185    }
186
187    /// Set multiple environment variables.
188    pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
189        self.env_vars.extend(vars);
190        self
191    }
192
193    /// Build the test context.
194    pub fn build(self) -> TestWebhookContext {
195        TestWebhookContext {
196            webhook_name: self.webhook_name,
197            request_id: self
198                .request_id
199                .unwrap_or_else(|| Uuid::new_v4().to_string()),
200            idempotency_key: self.idempotency_key,
201            headers: self.headers,
202            pool: self.pool,
203            http: Arc::new(self.http),
204            job_dispatch: self.job_dispatch,
205            env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_webhook_context_creation() {
216        let ctx = TestWebhookContext::builder("github_webhook")
217            .with_header("X-GitHub-Event", "push")
218            .with_idempotency_key("delivery-123")
219            .build();
220
221        assert_eq!(ctx.webhook_name, "github_webhook");
222        assert_eq!(ctx.idempotency_key, Some("delivery-123".to_string()));
223        assert_eq!(ctx.header("X-GitHub-Event"), Some("push"));
224        assert_eq!(ctx.header("x-github-event"), Some("push")); // case-insensitive
225    }
226
227    #[tokio::test]
228    async fn test_dispatch_job() {
229        let ctx = TestWebhookContext::builder("test").build();
230
231        let job_id = ctx
232            .dispatch_job("process_event", serde_json::json!({"action": "push"}))
233            .await
234            .unwrap();
235
236        assert!(!job_id.is_nil());
237        ctx.job_dispatch().assert_dispatched("process_event");
238    }
239
240    #[test]
241    fn test_headers_case_insensitive() {
242        let ctx = TestWebhookContext::builder("test")
243            .with_header("Content-Type", "application/json")
244            .build();
245
246        assert_eq!(ctx.header("content-type"), Some("application/json"));
247        assert_eq!(ctx.header("CONTENT-TYPE"), Some("application/json"));
248    }
249}