Skip to main content

forge_core/testing/context/
daemon.rs

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