Skip to main content

forge_core/testing/context/
cron.rs

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