Skip to main content

forge_core/testing/context/
job.rs

1//! Test context for job functions.
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use sqlx::PgPool;
7use uuid::Uuid;
8
9use super::super::mock_http::{MockHttp, MockRequest, MockResponse};
10use super::build_test_auth;
11use crate::Result;
12use crate::env::{EnvAccess, EnvProvider, MockEnvProvider};
13use crate::function::AuthContext;
14
15/// Progress update recorded during testing.
16#[derive(Debug, Clone)]
17pub struct TestProgressUpdate {
18    /// Progress percentage (0-100).
19    pub percent: u8,
20    /// Progress message.
21    pub message: String,
22}
23
24/// Test context for job functions.
25///
26/// Provides an isolated testing environment for jobs with progress tracking,
27/// retry simulation, and HTTP mocking.
28///
29/// # Example
30///
31/// ```ignore
32/// let ctx = TestJobContext::builder("export_users")
33///     .with_job_id(Uuid::new_v4())
34///     .build();
35///
36/// // Simulate progress
37/// ctx.progress(50, "Halfway there")?;
38///
39/// // Verify progress was recorded
40/// assert_eq!(ctx.progress_updates().len(), 1);
41/// ```
42pub struct TestJobContext {
43    /// Job ID.
44    pub job_id: Uuid,
45    /// Job type name.
46    pub job_type: String,
47    /// Current attempt number (1-based).
48    pub attempt: u32,
49    /// Maximum attempts allowed.
50    pub max_attempts: u32,
51    /// Authentication context.
52    pub auth: AuthContext,
53    /// Optional database pool.
54    pool: Option<PgPool>,
55    /// Mock HTTP client.
56    http: Arc<MockHttp>,
57    /// Progress updates recorded during execution.
58    progress_updates: Arc<RwLock<Vec<TestProgressUpdate>>>,
59    /// Mock environment provider.
60    env_provider: Arc<MockEnvProvider>,
61}
62
63impl TestJobContext {
64    /// Create a new builder.
65    pub fn builder(job_type: impl Into<String>) -> TestJobContextBuilder {
66        TestJobContextBuilder::new(job_type)
67    }
68
69    /// Get the database pool (if available).
70    pub fn db(&self) -> Option<&PgPool> {
71        self.pool.as_ref()
72    }
73
74    /// Get the mock HTTP client.
75    pub fn http(&self) -> &MockHttp {
76        &self.http
77    }
78
79    /// Report job progress.
80    pub fn progress(&self, percent: u8, message: impl Into<String>) -> Result<()> {
81        let update = TestProgressUpdate {
82            percent: percent.min(100),
83            message: message.into(),
84        };
85        self.progress_updates.write().unwrap().push(update);
86        Ok(())
87    }
88
89    /// Get all progress updates.
90    pub fn progress_updates(&self) -> Vec<TestProgressUpdate> {
91        self.progress_updates.read().unwrap().clone()
92    }
93
94    /// Check if this is a retry attempt.
95    pub fn is_retry(&self) -> bool {
96        self.attempt > 1
97    }
98
99    /// Check if this is the last attempt.
100    pub fn is_last_attempt(&self) -> bool {
101        self.attempt >= self.max_attempts
102    }
103
104    /// Simulate heartbeat (no-op in tests, but records the intent).
105    pub async fn heartbeat(&self) -> Result<()> {
106        Ok(())
107    }
108
109    /// Get the mock env provider for verification.
110    pub fn env_mock(&self) -> &MockEnvProvider {
111        &self.env_provider
112    }
113}
114
115impl EnvAccess for TestJobContext {
116    fn env_provider(&self) -> &dyn EnvProvider {
117        self.env_provider.as_ref()
118    }
119}
120
121/// Builder for TestJobContext.
122pub struct TestJobContextBuilder {
123    job_id: Option<Uuid>,
124    job_type: String,
125    attempt: u32,
126    max_attempts: u32,
127    user_id: Option<Uuid>,
128    roles: Vec<String>,
129    claims: HashMap<String, serde_json::Value>,
130    pool: Option<PgPool>,
131    http: MockHttp,
132    env_vars: HashMap<String, String>,
133}
134
135impl TestJobContextBuilder {
136    /// Create a new builder with job type.
137    pub fn new(job_type: impl Into<String>) -> Self {
138        Self {
139            job_id: None,
140            job_type: job_type.into(),
141            attempt: 1,
142            max_attempts: 1,
143            user_id: None,
144            roles: Vec::new(),
145            claims: HashMap::new(),
146            pool: None,
147            http: MockHttp::new(),
148            env_vars: HashMap::new(),
149        }
150    }
151
152    /// Set a specific job ID.
153    pub fn with_job_id(mut self, id: Uuid) -> Self {
154        self.job_id = Some(id);
155        self
156    }
157
158    /// Set as a retry (attempt > 1).
159    pub fn as_retry(mut self, attempt: u32) -> Self {
160        self.attempt = attempt.max(1);
161        self
162    }
163
164    /// Set the maximum attempts.
165    pub fn with_max_attempts(mut self, max: u32) -> Self {
166        self.max_attempts = max.max(1);
167        self
168    }
169
170    /// Set as the last attempt.
171    pub fn as_last_attempt(mut self) -> Self {
172        self.attempt = 3;
173        self.max_attempts = 3;
174        self
175    }
176
177    /// Set the authenticated user with a UUID.
178    pub fn as_user(mut self, id: Uuid) -> Self {
179        self.user_id = Some(id);
180        self
181    }
182
183    /// For non-UUID auth providers (Firebase, Clerk, etc.).
184    pub fn as_subject(mut self, subject: impl Into<String>) -> Self {
185        self.claims
186            .insert("sub".to_string(), serde_json::json!(subject.into()));
187        self
188    }
189
190    /// Add a role.
191    pub fn with_role(mut self, role: impl Into<String>) -> Self {
192        self.roles.push(role.into());
193        self
194    }
195
196    /// Set the database pool.
197    pub fn with_pool(mut self, pool: PgPool) -> Self {
198        self.pool = Some(pool);
199        self
200    }
201
202    /// Add an HTTP mock with a custom handler.
203    pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
204    where
205        F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
206    {
207        self.http.add_mock_sync(pattern, handler);
208        self
209    }
210
211    /// Add an HTTP mock that returns a JSON response.
212    pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
213        let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
214        self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
215    }
216
217    /// Set a single environment variable.
218    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
219        self.env_vars.insert(key.into(), value.into());
220        self
221    }
222
223    /// Set multiple environment variables.
224    pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
225        self.env_vars.extend(vars);
226        self
227    }
228
229    /// Build the test context.
230    pub fn build(self) -> TestJobContext {
231        TestJobContext {
232            job_id: self.job_id.unwrap_or_else(Uuid::new_v4),
233            job_type: self.job_type,
234            attempt: self.attempt,
235            max_attempts: self.max_attempts,
236            auth: build_test_auth(self.user_id, self.roles, self.claims),
237            pool: self.pool,
238            http: Arc::new(self.http),
239            progress_updates: Arc::new(RwLock::new(Vec::new())),
240            env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_job_context_creation() {
251        let ctx = TestJobContext::builder("export_users").build();
252
253        assert_eq!(ctx.job_type, "export_users");
254        assert_eq!(ctx.attempt, 1);
255        assert!(!ctx.is_retry());
256        assert!(ctx.is_last_attempt()); // 1 of 1
257    }
258
259    #[test]
260    fn test_retry_detection() {
261        let ctx = TestJobContext::builder("test")
262            .as_retry(3)
263            .with_max_attempts(5)
264            .build();
265
266        assert!(ctx.is_retry());
267        assert!(!ctx.is_last_attempt());
268    }
269
270    #[test]
271    fn test_last_attempt() {
272        let ctx = TestJobContext::builder("test").as_last_attempt().build();
273
274        assert!(ctx.is_retry());
275        assert!(ctx.is_last_attempt());
276    }
277
278    #[test]
279    fn test_progress_tracking() {
280        let ctx = TestJobContext::builder("test").build();
281
282        ctx.progress(25, "Step 1 complete").unwrap();
283        ctx.progress(50, "Step 2 complete").unwrap();
284        ctx.progress(100, "Done").unwrap();
285
286        let updates = ctx.progress_updates();
287        assert_eq!(updates.len(), 3);
288        assert_eq!(updates[0].percent, 25);
289        assert_eq!(updates[2].percent, 100);
290    }
291}