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