forge_runtime/testing/
context.rs

1//! Test context for integration tests.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use serde::{de::DeserializeOwned, Serialize};
8use uuid::Uuid;
9
10use forge_core::error::{ForgeError, Result};
11use forge_core::function::{AuthContext, MutationContext, QueryContext};
12use forge_core::job::JobStatus;
13use forge_core::workflow::WorkflowStatus;
14
15use super::mock::{MockHttp, MockRequest, MockResponse};
16use super::TestConfig;
17
18/// Test context for integration tests.
19///
20/// Provides an isolated testing environment with transaction-based
21/// isolation, mock HTTP support, and test utilities.
22pub struct TestContext {
23    /// Database pool (if connected).
24    pool: Option<sqlx::PgPool>,
25    /// Transaction for isolation.
26    #[allow(dead_code)]
27    tx: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
28    /// HTTP mock.
29    mock_http: MockHttp,
30    /// Auth context.
31    auth: AuthContext,
32    /// Test configuration.
33    #[allow(dead_code)]
34    config: TestConfig,
35    /// Dispatched jobs for verification.
36    dispatched_jobs: Vec<DispatchedJob>,
37    /// Started workflows for verification.
38    started_workflows: Vec<StartedWorkflow>,
39}
40
41/// Record of a dispatched job.
42#[derive(Debug, Clone)]
43pub struct DispatchedJob {
44    /// Job ID.
45    pub id: Uuid,
46    /// Job type name.
47    pub job_type: String,
48    /// Job input.
49    pub input: serde_json::Value,
50    /// Dispatch time.
51    pub dispatched_at: DateTime<Utc>,
52    /// Current status (for test verification).
53    pub status: JobStatus,
54}
55
56/// Record of a started workflow.
57#[derive(Debug, Clone)]
58pub struct StartedWorkflow {
59    /// Run ID.
60    pub run_id: Uuid,
61    /// Workflow name.
62    pub workflow_name: String,
63    /// Input.
64    pub input: serde_json::Value,
65    /// Started time.
66    pub started_at: DateTime<Utc>,
67    /// Current status.
68    pub status: WorkflowStatus,
69    /// Completed steps.
70    pub completed_steps: Vec<String>,
71}
72
73impl TestContext {
74    /// Create a new test context (without database).
75    pub fn new_without_db() -> Self {
76        Self {
77            pool: None,
78            tx: None,
79            mock_http: MockHttp::new(),
80            auth: AuthContext::unauthenticated(),
81            config: TestConfig::default(),
82            dispatched_jobs: Vec::new(),
83            started_workflows: Vec::new(),
84        }
85    }
86
87    /// Create a new test context with database connection.
88    pub async fn new() -> Result<Self> {
89        let config = TestConfig::default();
90        Self::with_config(config).await
91    }
92
93    /// Create a test context with custom configuration.
94    pub async fn with_config(config: TestConfig) -> Result<Self> {
95        let pool = if let Some(ref url) = config.database_url {
96            Some(
97                sqlx::postgres::PgPoolOptions::new()
98                    .max_connections(config.max_connections)
99                    .acquire_timeout(Duration::from_secs(30))
100                    .connect(url)
101                    .await
102                    .map_err(|e| ForgeError::Database(e.to_string()))?,
103            )
104        } else {
105            None
106        };
107
108        Ok(Self {
109            pool,
110            tx: None,
111            mock_http: MockHttp::new(),
112            auth: AuthContext::unauthenticated(),
113            config,
114            dispatched_jobs: Vec::new(),
115            started_workflows: Vec::new(),
116        })
117    }
118
119    /// Create a builder for more complex setup.
120    pub fn builder() -> TestContextBuilder {
121        TestContextBuilder::new()
122    }
123
124    /// Get the database pool.
125    pub fn pool(&self) -> Option<&sqlx::PgPool> {
126        self.pool.as_ref()
127    }
128
129    /// Get the auth context.
130    pub fn auth(&self) -> &AuthContext {
131        &self.auth
132    }
133
134    /// Get the user ID if authenticated.
135    pub fn user_id(&self) -> Option<Uuid> {
136        if self.auth.is_authenticated() {
137            self.auth.user_id()
138        } else {
139            None
140        }
141    }
142
143    /// Set the authenticated user.
144    pub fn set_user(&mut self, user_id: Uuid) {
145        self.auth = AuthContext::authenticated(user_id, vec![], HashMap::new());
146    }
147
148    /// Get the mock HTTP.
149    pub fn mock_http(&self) -> &MockHttp {
150        &self.mock_http
151    }
152
153    /// Get mutable mock HTTP.
154    pub fn mock_http_mut(&mut self) -> &mut MockHttp {
155        &mut self.mock_http
156    }
157
158    /// Execute a query function.
159    pub async fn query<F, I, O>(&self, _func: F, _input: I) -> Result<O>
160    where
161        F: Fn(
162            QueryContext,
163            I,
164        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<O>> + Send>>,
165        I: Serialize,
166        O: DeserializeOwned,
167    {
168        // In a real implementation, this would call the function with a proper context
169        Err(ForgeError::Internal(
170            "Query execution requires database connection".to_string(),
171        ))
172    }
173
174    /// Execute a mutation function.
175    pub async fn mutate<F, I, O>(&self, _func: F, _input: I) -> Result<O>
176    where
177        F: Fn(
178            MutationContext,
179            I,
180        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<O>> + Send>>,
181        I: Serialize,
182        O: DeserializeOwned,
183    {
184        // In a real implementation, this would call the function with a proper context
185        Err(ForgeError::Internal(
186            "Mutation execution requires database connection".to_string(),
187        ))
188    }
189
190    /// Dispatch a job for testing.
191    pub fn dispatch_job(&mut self, job_type: &str, input: serde_json::Value) -> Uuid {
192        let job_id = Uuid::new_v4();
193        self.dispatched_jobs.push(DispatchedJob {
194            id: job_id,
195            job_type: job_type.to_string(),
196            input,
197            dispatched_at: Utc::now(),
198            status: JobStatus::Pending,
199        });
200        job_id
201    }
202
203    /// Get dispatched jobs.
204    pub fn dispatched_jobs(&self) -> &[DispatchedJob] {
205        &self.dispatched_jobs
206    }
207
208    /// Check if a job was dispatched.
209    pub fn job_dispatched(&self, job_type: &str) -> bool {
210        self.dispatched_jobs.iter().any(|j| j.job_type == job_type)
211    }
212
213    /// Get job status.
214    pub fn job_status(&self, job_id: Uuid) -> Option<JobStatus> {
215        self.dispatched_jobs
216            .iter()
217            .find(|j| j.id == job_id)
218            .map(|j| j.status)
219    }
220
221    /// Mark a job as completed (for testing).
222    pub fn complete_job(&mut self, job_id: Uuid) {
223        if let Some(job) = self.dispatched_jobs.iter_mut().find(|j| j.id == job_id) {
224            job.status = JobStatus::Completed;
225        }
226    }
227
228    /// Run all pending jobs synchronously.
229    pub fn run_jobs(&mut self) {
230        for job in &mut self.dispatched_jobs {
231            if job.status == JobStatus::Pending {
232                job.status = JobStatus::Completed;
233            }
234        }
235    }
236
237    /// Start a workflow for testing.
238    pub fn start_workflow(&mut self, workflow_name: &str, input: serde_json::Value) -> Uuid {
239        let run_id = Uuid::new_v4();
240        self.started_workflows.push(StartedWorkflow {
241            run_id,
242            workflow_name: workflow_name.to_string(),
243            input,
244            started_at: Utc::now(),
245            status: WorkflowStatus::Created,
246            completed_steps: Vec::new(),
247        });
248        run_id
249    }
250
251    /// Get started workflows.
252    pub fn started_workflows(&self) -> &[StartedWorkflow] {
253        &self.started_workflows
254    }
255
256    /// Get workflow status.
257    pub fn workflow_status(&self, run_id: Uuid) -> Option<WorkflowStatus> {
258        self.started_workflows
259            .iter()
260            .find(|w| w.run_id == run_id)
261            .map(|w| w.status)
262    }
263
264    /// Mark a workflow step as completed.
265    pub fn complete_workflow_step(&mut self, run_id: Uuid, step_name: &str) {
266        if let Some(workflow) = self
267            .started_workflows
268            .iter_mut()
269            .find(|w| w.run_id == run_id)
270        {
271            workflow.completed_steps.push(step_name.to_string());
272        }
273    }
274
275    /// Complete a workflow.
276    pub fn complete_workflow(&mut self, run_id: Uuid) {
277        if let Some(workflow) = self
278            .started_workflows
279            .iter_mut()
280            .find(|w| w.run_id == run_id)
281        {
282            workflow.status = WorkflowStatus::Completed;
283        }
284    }
285
286    /// Check if a workflow step was completed.
287    pub fn workflow_step_completed(&self, run_id: Uuid, step_name: &str) -> bool {
288        self.started_workflows
289            .iter()
290            .find(|w| w.run_id == run_id)
291            .map(|w| w.completed_steps.contains(&step_name.to_string()))
292            .unwrap_or(false)
293    }
294}
295
296/// Builder for TestContext.
297pub struct TestContextBuilder {
298    config: TestConfig,
299    user_id: Option<Uuid>,
300    roles: Vec<String>,
301    custom_claims: HashMap<String, serde_json::Value>,
302    mock_http: MockHttp,
303}
304
305impl TestContextBuilder {
306    /// Create a new builder.
307    pub fn new() -> Self {
308        Self {
309            config: TestConfig::default(),
310            user_id: None,
311            roles: Vec::new(),
312            custom_claims: HashMap::new(),
313            mock_http: MockHttp::new(),
314        }
315    }
316
317    /// Set the database URL.
318    pub fn database_url(mut self, url: impl Into<String>) -> Self {
319        self.config.database_url = Some(url.into());
320        self
321    }
322
323    /// Set the authenticated user.
324    pub fn as_user(mut self, user_id: Uuid) -> Self {
325        self.user_id = Some(user_id);
326        self
327    }
328
329    /// Add roles.
330    pub fn with_roles(mut self, roles: Vec<String>) -> Self {
331        self.roles = roles;
332        self
333    }
334
335    /// Add custom claims.
336    pub fn with_claims(mut self, claims: HashMap<String, serde_json::Value>) -> Self {
337        self.custom_claims = claims;
338        self
339    }
340
341    /// Enable logging.
342    pub fn with_logging(mut self, enabled: bool) -> Self {
343        self.config.logging = enabled;
344        self
345    }
346
347    /// Add HTTP mock.
348    pub fn mock_http(
349        mut self,
350        pattern: &str,
351        handler: impl Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
352    ) -> Self {
353        self.mock_http.add_mock(pattern, handler);
354        self
355    }
356
357    /// Build the test context.
358    pub async fn build(self) -> Result<TestContext> {
359        let mut ctx = TestContext::with_config(self.config).await?;
360
361        if let Some(user_id) = self.user_id {
362            ctx.auth = AuthContext::authenticated(user_id, self.roles, self.custom_claims);
363        }
364
365        ctx.mock_http = self.mock_http;
366
367        Ok(ctx)
368    }
369}
370
371impl Default for TestContextBuilder {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_context_builder() {
383        let builder = TestContextBuilder::new()
384            .as_user(Uuid::new_v4())
385            .with_logging(true);
386
387        assert!(builder.user_id.is_some());
388        assert!(builder.config.logging);
389    }
390
391    #[test]
392    fn test_context_without_db() {
393        let ctx = TestContext::new_without_db();
394        assert!(ctx.pool().is_none());
395        assert!(!ctx.auth().is_authenticated());
396    }
397
398    #[test]
399    fn test_job_dispatch() {
400        let mut ctx = TestContext::new_without_db();
401        let job_id = ctx.dispatch_job("send_email", serde_json::json!({"to": "test@example.com"}));
402
403        assert!(ctx.job_dispatched("send_email"));
404        assert_eq!(ctx.job_status(job_id), Some(JobStatus::Pending));
405
406        ctx.complete_job(job_id);
407        assert_eq!(ctx.job_status(job_id), Some(JobStatus::Completed));
408    }
409
410    #[test]
411    fn test_workflow_tracking() {
412        let mut ctx = TestContext::new_without_db();
413        let run_id = ctx.start_workflow(
414            "onboarding",
415            serde_json::json!({"email": "test@example.com"}),
416        );
417
418        assert_eq!(ctx.workflow_status(run_id), Some(WorkflowStatus::Created));
419
420        ctx.complete_workflow_step(run_id, "create_user");
421        assert!(ctx.workflow_step_completed(run_id, "create_user"));
422        assert!(!ctx.workflow_step_completed(run_id, "send_email"));
423
424        ctx.complete_workflow(run_id);
425        assert_eq!(ctx.workflow_status(run_id), Some(WorkflowStatus::Completed));
426    }
427
428    #[test]
429    fn test_run_jobs() {
430        let mut ctx = TestContext::new_without_db();
431        let job1 = ctx.dispatch_job("job1", serde_json::json!({}));
432        let job2 = ctx.dispatch_job("job2", serde_json::json!({}));
433
434        ctx.run_jobs();
435
436        assert_eq!(ctx.job_status(job1), Some(JobStatus::Completed));
437        assert_eq!(ctx.job_status(job2), Some(JobStatus::Completed));
438    }
439}