forge_core/testing/context/
cron.rs

1//! Test context for cron functions.
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use chrono::{DateTime, Duration, Utc};
7use sqlx::PgPool;
8use uuid::Uuid;
9
10use super::super::mock_http::{MockHttp, MockRequest, MockResponse};
11use crate::env::{EnvAccess, EnvProvider, MockEnvProvider};
12use crate::function::AuthContext;
13
14/// Log entry recorded during testing.
15#[derive(Debug, Clone)]
16pub struct TestLogEntry {
17    /// Log level.
18    pub level: String,
19    /// Log message.
20    pub message: String,
21    /// Associated data.
22    pub data: serde_json::Value,
23}
24
25/// Test log for cron context.
26#[derive(Clone)]
27pub struct TestCronLog {
28    cron_name: String,
29    entries: Arc<RwLock<Vec<TestLogEntry>>>,
30}
31
32impl TestCronLog {
33    /// Create a new test cron log.
34    pub fn new(cron_name: impl Into<String>) -> Self {
35        Self {
36            cron_name: cron_name.into(),
37            entries: Arc::new(RwLock::new(Vec::new())),
38        }
39    }
40
41    /// Log an info message.
42    pub fn info(&self, message: &str) {
43        self.log("info", message, serde_json::Value::Null);
44    }
45
46    /// Log an info message with data.
47    pub fn info_with(&self, message: &str, data: serde_json::Value) {
48        self.log("info", message, data);
49    }
50
51    /// Log a warning message.
52    pub fn warn(&self, message: &str) {
53        self.log("warn", message, serde_json::Value::Null);
54    }
55
56    /// Log a warning message with data.
57    pub fn warn_with(&self, message: &str, data: serde_json::Value) {
58        self.log("warn", message, data);
59    }
60
61    /// Log an error message.
62    pub fn error(&self, message: &str) {
63        self.log("error", message, serde_json::Value::Null);
64    }
65
66    /// Log an error message with data.
67    pub fn error_with(&self, message: &str, data: serde_json::Value) {
68        self.log("error", message, data);
69    }
70
71    /// Log a debug message.
72    pub fn debug(&self, message: &str) {
73        self.log("debug", message, serde_json::Value::Null);
74    }
75
76    fn log(&self, level: &str, message: &str, data: serde_json::Value) {
77        let entry = TestLogEntry {
78            level: level.to_string(),
79            message: message.to_string(),
80            data,
81        };
82        self.entries.write().unwrap().push(entry);
83    }
84
85    /// Get all log entries.
86    pub fn entries(&self) -> Vec<TestLogEntry> {
87        self.entries.read().unwrap().clone()
88    }
89
90    /// Get the cron name.
91    pub fn cron_name(&self) -> &str {
92        &self.cron_name
93    }
94}
95
96/// Test context for cron functions.
97///
98/// Provides an isolated testing environment for crons with delay detection,
99/// catch-up simulation, and structured logging.
100///
101/// # Example
102///
103/// ```ignore
104/// let ctx = TestCronContext::builder("daily_cleanup")
105///     .scheduled_at(Utc::now() - Duration::minutes(5))
106///     .build();
107///
108/// assert!(ctx.is_late());
109///
110/// ctx.log.info("Starting cleanup");
111/// assert_eq!(ctx.log.entries().len(), 1);
112/// ```
113pub struct TestCronContext {
114    /// Cron run ID.
115    pub run_id: Uuid,
116    /// Cron name.
117    pub cron_name: String,
118    /// Scheduled time.
119    pub scheduled_time: DateTime<Utc>,
120    /// Execution time.
121    pub execution_time: DateTime<Utc>,
122    /// Timezone.
123    pub timezone: String,
124    /// Whether this is a catch-up run.
125    pub is_catch_up: bool,
126    /// Authentication context.
127    pub auth: AuthContext,
128    /// Structured logger.
129    pub log: TestCronLog,
130    /// Optional database pool.
131    pool: Option<PgPool>,
132    /// Mock HTTP client.
133    http: Arc<MockHttp>,
134    /// Mock environment provider.
135    env_provider: Arc<MockEnvProvider>,
136}
137
138impl TestCronContext {
139    /// Create a new builder.
140    pub fn builder(cron_name: impl Into<String>) -> TestCronContextBuilder {
141        TestCronContextBuilder::new(cron_name)
142    }
143
144    /// Get the database pool (if available).
145    pub fn db(&self) -> Option<&PgPool> {
146        self.pool.as_ref()
147    }
148
149    /// Get the mock HTTP client.
150    pub fn http(&self) -> &MockHttp {
151        &self.http
152    }
153
154    /// Get the delay between scheduled and actual execution time.
155    pub fn delay(&self) -> Duration {
156        self.execution_time - self.scheduled_time
157    }
158
159    /// Check if the cron is running late (more than 1 minute delay).
160    pub fn is_late(&self) -> bool {
161        self.delay() > Duration::minutes(1)
162    }
163
164    /// Get the mock env provider for verification.
165    pub fn env_mock(&self) -> &MockEnvProvider {
166        &self.env_provider
167    }
168}
169
170impl EnvAccess for TestCronContext {
171    fn env_provider(&self) -> &dyn EnvProvider {
172        self.env_provider.as_ref()
173    }
174}
175
176/// Builder for TestCronContext.
177pub struct TestCronContextBuilder {
178    run_id: Option<Uuid>,
179    cron_name: String,
180    scheduled_time: DateTime<Utc>,
181    execution_time: DateTime<Utc>,
182    timezone: String,
183    is_catch_up: bool,
184    user_id: Option<Uuid>,
185    roles: Vec<String>,
186    claims: HashMap<String, serde_json::Value>,
187    pool: Option<PgPool>,
188    http: MockHttp,
189    env_vars: HashMap<String, String>,
190}
191
192impl TestCronContextBuilder {
193    /// Create a new builder.
194    pub fn new(cron_name: impl Into<String>) -> Self {
195        let now = Utc::now();
196        Self {
197            run_id: None,
198            cron_name: cron_name.into(),
199            scheduled_time: now,
200            execution_time: now,
201            timezone: "UTC".to_string(),
202            is_catch_up: false,
203            user_id: None,
204            roles: Vec::new(),
205            claims: HashMap::new(),
206            pool: None,
207            http: MockHttp::new(),
208            env_vars: HashMap::new(),
209        }
210    }
211
212    /// Set a specific run ID.
213    pub fn with_run_id(mut self, id: Uuid) -> Self {
214        self.run_id = Some(id);
215        self
216    }
217
218    /// Set the scheduled time.
219    pub fn scheduled_at(mut self, time: DateTime<Utc>) -> Self {
220        self.scheduled_time = time;
221        self
222    }
223
224    /// Set the execution time.
225    pub fn executed_at(mut self, time: DateTime<Utc>) -> Self {
226        self.execution_time = time;
227        self
228    }
229
230    /// Set the timezone.
231    pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
232        self.timezone = tz.into();
233        self
234    }
235
236    /// Mark as a catch-up run.
237    pub fn as_catch_up(mut self) -> Self {
238        self.is_catch_up = true;
239        self
240    }
241
242    /// Set the authenticated user.
243    pub fn as_user(mut self, id: Uuid) -> Self {
244        self.user_id = Some(id);
245        self
246    }
247
248    /// Add a role.
249    pub fn with_role(mut self, role: impl Into<String>) -> Self {
250        self.roles.push(role.into());
251        self
252    }
253
254    /// Set the database pool.
255    pub fn with_pool(mut self, pool: PgPool) -> Self {
256        self.pool = Some(pool);
257        self
258    }
259
260    /// Add an HTTP mock.
261    pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
262    where
263        F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
264    {
265        self.http.add_mock_sync(pattern, handler);
266        self
267    }
268
269    /// Add an HTTP mock that returns a JSON response.
270    pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
271        let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
272        self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
273    }
274
275    /// Set a single environment variable.
276    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
277        self.env_vars.insert(key.into(), value.into());
278        self
279    }
280
281    /// Set multiple environment variables.
282    pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
283        self.env_vars.extend(vars);
284        self
285    }
286
287    /// Build the test context.
288    pub fn build(self) -> TestCronContext {
289        let auth = if let Some(user_id) = self.user_id {
290            AuthContext::authenticated(user_id, self.roles, self.claims)
291        } else {
292            AuthContext::unauthenticated()
293        };
294
295        TestCronContext {
296            run_id: self.run_id.unwrap_or_else(Uuid::new_v4),
297            cron_name: self.cron_name.clone(),
298            scheduled_time: self.scheduled_time,
299            execution_time: self.execution_time,
300            timezone: self.timezone,
301            is_catch_up: self.is_catch_up,
302            auth,
303            log: TestCronLog::new(self.cron_name),
304            pool: self.pool,
305            http: Arc::new(self.http),
306            env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_cron_context_creation() {
317        let ctx = TestCronContext::builder("daily_cleanup").build();
318
319        assert_eq!(ctx.cron_name, "daily_cleanup");
320        assert!(!ctx.is_catch_up);
321        assert!(!ctx.is_late());
322    }
323
324    #[test]
325    fn test_catch_up_run() {
326        let ctx = TestCronContext::builder("hourly_sync")
327            .as_catch_up()
328            .build();
329
330        assert!(ctx.is_catch_up);
331    }
332
333    #[test]
334    fn test_late_detection() {
335        let scheduled = Utc::now() - Duration::minutes(5);
336        let ctx = TestCronContext::builder("quick_task")
337            .scheduled_at(scheduled)
338            .build();
339
340        assert!(ctx.is_late());
341        assert!(ctx.delay() >= Duration::minutes(4));
342    }
343
344    #[test]
345    fn test_logging() {
346        let ctx = TestCronContext::builder("test_cron").build();
347
348        ctx.log.info("Starting");
349        ctx.log.warn("Warning message");
350        ctx.log.error("Error occurred");
351
352        let entries = ctx.log.entries();
353        assert_eq!(entries.len(), 3);
354        assert_eq!(entries[0].level, "info");
355        assert_eq!(entries[1].level, "warn");
356        assert_eq!(entries[2].level, "error");
357    }
358
359    #[test]
360    fn test_timezone() {
361        let ctx = TestCronContext::builder("tz_test")
362            .with_timezone("America/New_York")
363            .build();
364
365        assert_eq!(ctx.timezone, "America/New_York");
366    }
367}