Skip to main content

forge_core/testing/context/
daemon.rs

1//! Test context for daemon functions.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use sqlx::PgPool;
7use tokio::sync::{Mutex, watch};
8use uuid::Uuid;
9
10use super::super::mock_http::{MockHttp, MockRequest, MockResponse};
11use crate::Result;
12use crate::env::{EnvAccess, EnvProvider, MockEnvProvider};
13
14/// Test context for daemon functions.
15///
16/// Provides an isolated testing environment for daemons with shutdown signal
17/// control, HTTP mocking, and optional database access.
18///
19/// # Example
20///
21/// ```ignore
22/// let ctx = TestDaemonContext::builder("heartbeat_daemon")
23///     .with_pool(db_pool)
24///     .build();
25///
26/// // Test shutdown handling
27/// assert!(!ctx.is_shutdown_requested());
28/// ctx.request_shutdown();
29/// assert!(ctx.is_shutdown_requested());
30/// ```
31pub struct TestDaemonContext {
32    /// Daemon name.
33    pub daemon_name: String,
34    /// Unique instance ID.
35    pub instance_id: Uuid,
36    /// Optional database pool.
37    pool: Option<PgPool>,
38    /// Mock HTTP client.
39    http: Arc<MockHttp>,
40    /// Shutdown signal sender (for triggering shutdown in tests).
41    pub shutdown_tx: watch::Sender<bool>,
42    /// Shutdown signal receiver (wrapped in Mutex for interior mutability).
43    shutdown_rx: Mutex<watch::Receiver<bool>>,
44    /// Mock environment provider.
45    env_provider: Arc<MockEnvProvider>,
46}
47
48impl TestDaemonContext {
49    /// Create a new builder.
50    pub fn builder(daemon_name: impl Into<String>) -> TestDaemonContextBuilder {
51        TestDaemonContextBuilder::new(daemon_name)
52    }
53
54    /// Get the database pool (if available).
55    pub fn db(&self) -> Option<&PgPool> {
56        self.pool.as_ref()
57    }
58
59    /// Get the mock HTTP client.
60    pub fn http(&self) -> &MockHttp {
61        &self.http
62    }
63
64    /// Check if shutdown has been requested.
65    pub fn is_shutdown_requested(&self) -> bool {
66        self.shutdown_rx
67            .try_lock()
68            .map(|rx| *rx.borrow())
69            .unwrap_or(false)
70    }
71
72    /// Request shutdown (trigger from test).
73    pub fn request_shutdown(&self) {
74        let _ = self.shutdown_tx.send(true);
75    }
76
77    /// Wait for shutdown signal.
78    ///
79    /// Use this in a `tokio::select!` to handle graceful shutdown:
80    ///
81    /// ```ignore
82    /// tokio::select! {
83    ///     _ = tokio::time::sleep(Duration::from_secs(60)) => {}
84    ///     _ = ctx.shutdown_signal() => break,
85    /// }
86    /// ```
87    pub async fn shutdown_signal(&self) {
88        let mut rx = self.shutdown_rx.lock().await;
89        while !*rx.borrow_and_update() {
90            if rx.changed().await.is_err() {
91                break;
92            }
93        }
94    }
95
96    /// Simulate heartbeat (no-op in tests).
97    pub async fn heartbeat(&self) -> Result<()> {
98        Ok(())
99    }
100
101    /// Get the mock env provider for verification.
102    pub fn env_mock(&self) -> &MockEnvProvider {
103        &self.env_provider
104    }
105}
106
107impl EnvAccess for TestDaemonContext {
108    fn env_provider(&self) -> &dyn EnvProvider {
109        self.env_provider.as_ref()
110    }
111}
112
113/// Builder for TestDaemonContext.
114pub struct TestDaemonContextBuilder {
115    daemon_name: String,
116    instance_id: Option<Uuid>,
117    pool: Option<PgPool>,
118    http: MockHttp,
119    env_vars: HashMap<String, String>,
120}
121
122impl TestDaemonContextBuilder {
123    /// Create a new builder with daemon name.
124    pub fn new(daemon_name: impl Into<String>) -> Self {
125        Self {
126            daemon_name: daemon_name.into(),
127            instance_id: None,
128            pool: None,
129            http: MockHttp::new(),
130            env_vars: HashMap::new(),
131        }
132    }
133
134    /// Set a specific instance ID.
135    pub fn with_instance_id(mut self, id: Uuid) -> Self {
136        self.instance_id = Some(id);
137        self
138    }
139
140    /// Set the database pool.
141    pub fn with_pool(mut self, pool: PgPool) -> Self {
142        self.pool = Some(pool);
143        self
144    }
145
146    /// Add an HTTP mock with a custom handler.
147    pub fn mock_http<F>(self, pattern: &str, handler: F) -> Self
148    where
149        F: Fn(&MockRequest) -> MockResponse + Send + Sync + 'static,
150    {
151        self.http.add_mock_sync(pattern, handler);
152        self
153    }
154
155    /// Add an HTTP mock that returns a JSON response.
156    pub fn mock_http_json<T: serde::Serialize>(self, pattern: &str, response: T) -> Self {
157        let json = serde_json::to_value(response).unwrap_or(serde_json::Value::Null);
158        self.mock_http(pattern, move |_| MockResponse::json(json.clone()))
159    }
160
161    /// Set a single environment variable.
162    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
163        self.env_vars.insert(key.into(), value.into());
164        self
165    }
166
167    /// Set multiple environment variables.
168    pub fn with_envs(mut self, vars: HashMap<String, String>) -> Self {
169        self.env_vars.extend(vars);
170        self
171    }
172
173    /// Build the test context.
174    pub fn build(self) -> TestDaemonContext {
175        let (shutdown_tx, shutdown_rx) = watch::channel(false);
176
177        TestDaemonContext {
178            daemon_name: self.daemon_name,
179            instance_id: self.instance_id.unwrap_or_else(Uuid::new_v4),
180            pool: self.pool,
181            http: Arc::new(self.http),
182            shutdown_tx,
183            shutdown_rx: Mutex::new(shutdown_rx),
184            env_provider: Arc::new(MockEnvProvider::with_vars(self.env_vars)),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_daemon_context_creation() {
195        let ctx = TestDaemonContext::builder("heartbeat_daemon").build();
196
197        assert_eq!(ctx.daemon_name, "heartbeat_daemon");
198        assert!(!ctx.is_shutdown_requested());
199    }
200
201    #[test]
202    fn test_shutdown_request() {
203        let ctx = TestDaemonContext::builder("test").build();
204
205        assert!(!ctx.is_shutdown_requested());
206        ctx.request_shutdown();
207        assert!(ctx.is_shutdown_requested());
208    }
209
210    #[tokio::test]
211    async fn test_shutdown_signal() {
212        let ctx = TestDaemonContext::builder("test").build();
213
214        // Spawn a task to request shutdown after a delay
215        let shutdown_tx = ctx.shutdown_tx.clone();
216        tokio::spawn(async move {
217            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
218            let _ = shutdown_tx.send(true);
219        });
220
221        // Wait for shutdown signal
222        tokio::time::timeout(std::time::Duration::from_millis(200), ctx.shutdown_signal())
223            .await
224            .expect("Shutdown signal should complete");
225
226        assert!(ctx.is_shutdown_requested());
227    }
228
229    #[test]
230    fn test_with_instance_id() {
231        let id = Uuid::new_v4();
232        let ctx = TestDaemonContext::builder("test")
233            .with_instance_id(id)
234            .build();
235
236        assert_eq!(ctx.instance_id, id);
237    }
238}