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