1use 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
18pub struct TestContext {
23 pool: Option<sqlx::PgPool>,
25 #[allow(dead_code)]
27 tx: Option<sqlx::Transaction<'static, sqlx::Postgres>>,
28 mock_http: MockHttp,
30 auth: AuthContext,
32 #[allow(dead_code)]
34 config: TestConfig,
35 dispatched_jobs: Vec<DispatchedJob>,
37 started_workflows: Vec<StartedWorkflow>,
39}
40
41#[derive(Debug, Clone)]
43pub struct DispatchedJob {
44 pub id: Uuid,
46 pub job_type: String,
48 pub input: serde_json::Value,
50 pub dispatched_at: DateTime<Utc>,
52 pub status: JobStatus,
54}
55
56#[derive(Debug, Clone)]
58pub struct StartedWorkflow {
59 pub run_id: Uuid,
61 pub workflow_name: String,
63 pub input: serde_json::Value,
65 pub started_at: DateTime<Utc>,
67 pub status: WorkflowStatus,
69 pub completed_steps: Vec<String>,
71}
72
73impl TestContext {
74 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 pub async fn new() -> Result<Self> {
89 let config = TestConfig::default();
90 Self::with_config(config).await
91 }
92
93 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 pub fn builder() -> TestContextBuilder {
121 TestContextBuilder::new()
122 }
123
124 pub fn pool(&self) -> Option<&sqlx::PgPool> {
126 self.pool.as_ref()
127 }
128
129 pub fn auth(&self) -> &AuthContext {
131 &self.auth
132 }
133
134 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 pub fn set_user(&mut self, user_id: Uuid) {
145 self.auth = AuthContext::authenticated(user_id, vec![], HashMap::new());
146 }
147
148 pub fn mock_http(&self) -> &MockHttp {
150 &self.mock_http
151 }
152
153 pub fn mock_http_mut(&mut self) -> &mut MockHttp {
155 &mut self.mock_http
156 }
157
158 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 Err(ForgeError::Internal(
170 "Query execution requires database connection".to_string(),
171 ))
172 }
173
174 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 Err(ForgeError::Internal(
186 "Mutation execution requires database connection".to_string(),
187 ))
188 }
189
190 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 pub fn dispatched_jobs(&self) -> &[DispatchedJob] {
205 &self.dispatched_jobs
206 }
207
208 pub fn job_dispatched(&self, job_type: &str) -> bool {
210 self.dispatched_jobs.iter().any(|j| j.job_type == job_type)
211 }
212
213 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 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 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 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 pub fn started_workflows(&self) -> &[StartedWorkflow] {
253 &self.started_workflows
254 }
255
256 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 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 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 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
296pub 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 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 pub fn database_url(mut self, url: impl Into<String>) -> Self {
319 self.config.database_url = Some(url.into());
320 self
321 }
322
323 pub fn as_user(mut self, user_id: Uuid) -> Self {
325 self.user_id = Some(user_id);
326 self
327 }
328
329 pub fn with_roles(mut self, roles: Vec<String>) -> Self {
331 self.roles = roles;
332 self
333 }
334
335 pub fn with_claims(mut self, claims: HashMap<String, serde_json::Value>) -> Self {
337 self.custom_claims = claims;
338 self
339 }
340
341 pub fn with_logging(mut self, enabled: bool) -> Self {
343 self.config.logging = enabled;
344 self
345 }
346
347 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 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}