Skip to main content

ferro_rs/tenant/
worker.rs

1//! Bridge between ferro-queue's TenantScopeProvider and the framework's tenant infrastructure.
2
3use crate::tenant::context::{tenant_scope, with_tenant_scope};
4use crate::tenant::TenantLookup;
5use async_trait::async_trait;
6use ferro_queue::{Error as QueueError, TenantScopeProvider};
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11/// Framework implementation of ferro-queue's TenantScopeProvider.
12///
13/// Uses TenantLookup::find_by_id() to restore full TenantContext from the
14/// tenant_id stored in the job payload, then wraps job execution in a
15/// task-local tenant scope so current_tenant() works inside job handlers.
16pub struct FrameworkTenantScopeProvider {
17    lookup: Arc<dyn TenantLookup>,
18}
19
20impl FrameworkTenantScopeProvider {
21    /// Create a new provider backed by the given TenantLookup.
22    pub fn new(lookup: Arc<dyn TenantLookup>) -> Self {
23        Self { lookup }
24    }
25}
26
27#[async_trait]
28impl TenantScopeProvider for FrameworkTenantScopeProvider {
29    async fn with_scope(
30        &self,
31        tenant_id: i64,
32        f: Pin<Box<dyn Future<Output = Result<(), QueueError>> + Send>>,
33    ) -> Result<(), QueueError> {
34        let tenant = self
35            .lookup
36            .find_by_id(tenant_id)
37            .await
38            .ok_or_else(|| QueueError::tenant_not_found(tenant_id))?;
39
40        let scope = tenant_scope();
41        {
42            let mut guard = scope.write().await;
43            *guard = Some(tenant);
44        }
45        with_tenant_scope(scope, f).await
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::tenant::context::current_tenant;
53    use crate::tenant::TenantContext;
54
55    fn make_tenant(id: i64, slug: &str) -> TenantContext {
56        TenantContext {
57            id,
58            slug: slug.to_string(),
59            name: format!("Tenant {slug}"),
60            plan: None,
61            #[cfg(feature = "stripe")]
62            subscription: None,
63        }
64    }
65
66    struct MockLookup {
67        tenant: Option<TenantContext>,
68    }
69
70    impl MockLookup {
71        fn returning(tenant: TenantContext) -> Self {
72            Self {
73                tenant: Some(tenant),
74            }
75        }
76
77        fn not_found() -> Self {
78            Self { tenant: None }
79        }
80    }
81
82    #[async_trait]
83    impl TenantLookup for MockLookup {
84        async fn find_by_slug(&self, _slug: &str) -> Option<TenantContext> {
85            self.tenant.clone()
86        }
87
88        async fn find_by_id(&self, _id: i64) -> Option<TenantContext> {
89            self.tenant.clone()
90        }
91    }
92
93    /// FrameworkTenantScopeProvider::with_scope(1, job_future) calls find_by_id(1)
94    /// and runs the job future inside a tenant scope.
95    #[tokio::test]
96    async fn with_scope_restores_tenant_context() {
97        let tenant = make_tenant(1, "acme");
98        let lookup = Arc::new(MockLookup::returning(tenant.clone()));
99        let provider = FrameworkTenantScopeProvider::new(lookup);
100
101        let result = provider.with_scope(1, Box::pin(async { Ok(()) })).await;
102
103        assert!(result.is_ok(), "Expected Ok(()), got: {result:?}");
104    }
105
106    /// FrameworkTenantScopeProvider::with_scope(999, job_future) returns TenantNotFound
107    /// when find_by_id returns None.
108    #[tokio::test]
109    async fn with_scope_returns_tenant_not_found_for_unknown_id() {
110        let lookup = Arc::new(MockLookup::not_found());
111        let provider = FrameworkTenantScopeProvider::new(lookup);
112
113        let result = provider.with_scope(999, Box::pin(async { Ok(()) })).await;
114
115        assert!(result.is_err(), "Expected Err for unknown tenant id");
116        assert!(
117            matches!(result, Err(QueueError::TenantNotFound { tenant_id: 999 })),
118            "Expected TenantNotFound(999), got: {result:?}"
119        );
120    }
121
122    /// Job future running inside with_scope can call current_tenant() and get the correct TenantContext.
123    #[tokio::test]
124    async fn current_tenant_accessible_inside_scope() {
125        let tenant = make_tenant(42, "beta");
126        let lookup = Arc::new(MockLookup::returning(tenant.clone()));
127        let provider = FrameworkTenantScopeProvider::new(lookup);
128
129        let observed_tenant = Arc::new(tokio::sync::Mutex::new(None::<TenantContext>));
130        let observed_clone = observed_tenant.clone();
131
132        let result = provider
133            .with_scope(
134                42,
135                Box::pin(async move {
136                    *observed_clone.lock().await = current_tenant();
137                    Ok(())
138                }),
139            )
140            .await;
141
142        assert!(result.is_ok());
143        let observed = observed_tenant.lock().await;
144        assert!(
145            observed.is_some(),
146            "current_tenant() must return Some inside scope"
147        );
148        let t = observed.as_ref().unwrap();
149        assert_eq!(t.id, 42);
150        assert_eq!(t.slug, "beta");
151    }
152}