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    pub level: String,
21    pub message: String,
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    pub fn new(cron_name: impl Into<String>) -> Self {
34        Self {
35            cron_name: cron_name.into(),
36            entries: Arc::new(RwLock::new(Vec::new())),
37        }
38    }
39
40    pub fn info(&self, message: &str) {
41        self.log("info", message, serde_json::Value::Null);
42    }
43
44    pub fn info_with(&self, message: &str, data: serde_json::Value) {
45        self.log("info", message, data);
46    }
47
48    pub fn warn(&self, message: &str) {
49        self.log("warn", message, serde_json::Value::Null);
50    }
51
52    pub fn warn_with(&self, message: &str, data: serde_json::Value) {
53        self.log("warn", message, data);
54    }
55
56    pub fn error(&self, message: &str) {
57        self.log("error", message, serde_json::Value::Null);
58    }
59
60    pub fn error_with(&self, message: &str, data: serde_json::Value) {
61        self.log("error", message, data);
62    }
63
64    pub fn debug(&self, message: &str) {
65        self.log("debug", message, serde_json::Value::Null);
66    }
67
68    fn log(&self, level: &str, message: &str, data: serde_json::Value) {
69        let entry = TestLogEntry {
70            level: level.to_string(),
71            message: message.to_string(),
72            data,
73        };
74        self.entries.write().unwrap().push(entry);
75    }
76
77    pub fn entries(&self) -> Vec<TestLogEntry> {
78        self.entries.read().unwrap().clone()
79    }
80
81    pub fn cron_name(&self) -> &str {
82        &self.cron_name
83    }
84}
85
86/// Test context for cron functions.
87pub struct TestCronContext {
88    pub run_id: Uuid,
89    pub cron_name: String,
90    pub scheduled_time: DateTime<Utc>,
91    pub execution_time: DateTime<Utc>,
92    pub timezone: String,
93    pub is_catch_up: bool,
94    pub auth: AuthContext,
95    pub log: TestCronLog,
96    pool: Option<PgPool>,
97    http: Arc<MockHttp>,
98    env_provider: Arc<MockEnvProvider>,
99}
100
101impl TestCronContext {
102    pub fn builder(cron_name: impl Into<String>) -> TestCronContextBuilder {
103        TestCronContextBuilder::new(cron_name)
104    }
105
106    pub fn db(&self) -> Option<&PgPool> {
107        self.pool.as_ref()
108    }
109
110    pub fn http(&self) -> &MockHttp {
111        &self.http
112    }
113
114    pub fn delay(&self) -> Duration {
115        self.execution_time - self.scheduled_time
116    }
117
118    /// More than 1 minute behind schedule.
119    pub fn is_late(&self) -> bool {
120        self.delay() > Duration::minutes(1)
121    }
122
123    pub fn env_mock(&self) -> &MockEnvProvider {
124        &self.env_provider
125    }
126}
127
128impl EnvAccess for TestCronContext {
129    fn env_provider(&self) -> &dyn EnvProvider {
130        self.env_provider.as_ref()
131    }
132}
133
134pub struct TestCronContextBuilder {
135    run_id: Option<Uuid>,
136    cron_name: String,
137    scheduled_time: DateTime<Utc>,
138    execution_time: DateTime<Utc>,
139    timezone: String,
140    is_catch_up: bool,
141    user_id: Option<Uuid>,
142    roles: Vec<String>,
143    claims: HashMap<String, serde_json::Value>,
144    pool: Option<PgPool>,
145    http: MockHttp,
146    env_vars: HashMap<String, String>,
147}
148
149impl TestCronContextBuilder {
150    pub fn new(cron_name: impl Into<String>) -> Self {
151        let now = Utc::now();
152        Self {
153            run_id: None,
154            cron_name: cron_name.into(),
155            scheduled_time: now,
156            execution_time: now,
157            timezone: "UTC".to_string(),
158            is_catch_up: false,
159            user_id: None,
160            roles: Vec::new(),
161            claims: HashMap::new(),
162            pool: None,
163            http: MockHttp::new(),
164            env_vars: HashMap::new(),
165        }
166    }
167
168    pub fn with_run_id(mut self, id: Uuid) -> Self {
169        self.run_id = Some(id);
170        self
171    }
172
173    pub fn scheduled_at(mut self, time: DateTime<Utc>) -> Self {
174        self.scheduled_time = time;
175        self
176    }
177
178    pub fn executed_at(mut self, time: DateTime<Utc>) -> Self {
179        self.execution_time = time;
180        self
181    }
182
183    pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
184        self.timezone = tz.into();
185        self
186    }
187
188    pub fn as_catch_up(mut self) -> Self {
189        self.is_catch_up = true;
190        self
191    }
192
193    pub fn as_user(mut self, id: Uuid) -> Self {
194        self.user_id = Some(id);
195        self
196    }
197
198    /// For non-UUID auth providers (Firebase, Clerk, etc.).
199    pub fn as_subject(mut self, subject: impl Into<String>) -> Self {
200        self.claims
201            .insert("sub".to_string(), serde_json::json!(subject.into()));
202        self
203    }
204
205    pub fn with_role(mut self, role: impl Into<String>) -> Self {
206        self.roles.push(role.into());
207        self
208    }
209
210    pub fn with_roles(mut self, roles: Vec<String>) -> Self {
211        self.roles.extend(roles);
212        self
213    }
214
215    pub fn with_claim(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
216        self.claims.insert(key.into(), value);
217        self
218    }
219
220    pub fn with_pool(mut self, pool: PgPool) -> Self {
221        self.pool = Some(pool);
222        self
223    }
224
225    pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
226    where
227        F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
228    {
229        self.http.add_mock_sync(pattern, handler);
230        self
231    }
232
233    pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
234        let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
235        self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
236    }
237
238    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
239        self.env_vars.insert(key.into(), value.into());
240        self
241    }
242
243    pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
244        self.env_vars.extend(vars);
245        self
246    }
247
248    pub fn build(self) -> TestCronContext {
249        TestCronContext {
250            run_id: self.run_id.unwrap_or_else(Uuid::new_v4),
251            cron_name: self.cron_name.clone(),
252            scheduled_time: self.scheduled_time,
253            execution_time: self.execution_time,
254            timezone: self.timezone,
255            is_catch_up: self.is_catch_up,
256            auth: build_test_auth(self.user_id, self.roles, self.claims),
257            log: TestCronLog::new(self.cron_name),
258            pool: self.pool,
259            http: Arc::new(self.http),
260            env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_cron_context_creation() {
271        let ctx = TestCronContext::builder("daily_cleanup").build();
272
273        assert_eq!(ctx.cron_name, "daily_cleanup");
274        assert!(!ctx.is_catch_up);
275        assert!(!ctx.is_late());
276    }
277
278    #[test]
279    fn test_catch_up_run() {
280        let ctx = TestCronContext::builder("hourly_sync")
281            .as_catch_up()
282            .build();
283
284        assert!(ctx.is_catch_up);
285    }
286
287    #[test]
288    fn test_late_detection() {
289        let scheduled = Utc::now() - Duration::minutes(5);
290        let ctx = TestCronContext::builder("quick_task")
291            .scheduled_at(scheduled)
292            .build();
293
294        assert!(ctx.is_late());
295        assert!(ctx.delay() >= Duration::minutes(4));
296    }
297
298    #[test]
299    fn test_logging() {
300        let ctx = TestCronContext::builder("test_cron").build();
301
302        ctx.log.info("Starting");
303        ctx.log.warn("Warning message");
304        ctx.log.error("Error occurred");
305
306        let entries = ctx.log.entries();
307        assert_eq!(entries.len(), 3);
308        assert_eq!(entries[0].level, "info");
309        assert_eq!(entries[1].level, "warn");
310        assert_eq!(entries[2].level, "error");
311    }
312
313    #[test]
314    fn test_timezone() {
315        let ctx = TestCronContext::builder("tz_test")
316            .with_timezone("America/New_York")
317            .build();
318
319        assert_eq!(ctx.timezone, "America/New_York");
320    }
321}