actor_sample/clients/
product_client.rs

1//! # Product Client
2//!
3//! Provides a high‑level API for interacting with the `Product` actor.
4//! It wraps a `ResourceClient<Product>` and exposes domain‑specific methods.
5use crate::model::{Product, ProductId};
6use crate::product_actor::ProductError;
7use actor_framework::ActorClient;
8use actor_framework::{FrameworkError, ResourceClient};
9use async_trait::async_trait;
10use tracing::{debug, instrument};
11
12/// Client for interacting with the Product actor.
13#[derive(Clone)]
14pub struct ProductClient {
15    inner: ResourceClient<Product>,
16}
17
18impl ProductClient {
19    pub fn new(inner: ResourceClient<Product>) -> Self {
20        Self { inner }
21    }
22}
23
24#[async_trait]
25impl ActorClient<Product> for ProductClient {
26    type Error = ProductError;
27
28    fn inner(&self) -> &ResourceClient<Product> {
29        &self.inner
30    }
31
32    fn map_error(e: FrameworkError) -> Self::Error {
33        ProductError::ActorCommunicationError(e.to_string())
34    }
35}
36
37impl ProductClient {
38    // Custom create method as it needs specific payload conversion
39
40    #[instrument(skip(self))]
41    pub async fn create_product(
42        &self,
43        params: crate::model::ProductCreate,
44    ) -> Result<ProductId, ProductError> {
45        debug!("Sending request");
46        self.inner
47            .create(params)
48            .await
49            .map_err(|e| ProductError::ActorCommunicationError(e.to_string()))
50    }
51
52    /// Check the current stock level for a product.
53    ///
54    /// Returns the quantity available.
55    #[instrument(skip(self))]
56    #[allow(dead_code)]
57    pub async fn check_stock(&self, id: ProductId) -> Result<u32, ProductError> {
58        debug!("Checking stock for product {}", id);
59        use crate::product_actor::{ProductAction, ProductActionResult};
60        match self
61            .inner
62            .perform_action(id, ProductAction::CheckStock)
63            .await
64        {
65            Ok(ProductActionResult::CheckStock(level)) => Ok(level),
66            Ok(_) => unreachable!("CheckStock action must return CheckStock result"),
67            Err(e) => Err(ProductError::ActorCommunicationError(e.to_string())),
68        }
69    }
70
71    /// Reserve a specific amount of stock for a product.
72    ///
73    /// Returns `Ok(())` if successful, or an error if insufficient stock.
74    #[instrument(skip(self))]
75    pub async fn reserve_stock(&self, id: ProductId, quantity: u32) -> Result<(), ProductError> {
76        debug!("Reserving {} units for product {}", quantity, id);
77        use crate::product_actor::{ProductAction, ProductActionResult};
78        match self
79            .inner
80            .perform_action(id, ProductAction::ReserveStock(quantity))
81            .await
82        {
83            Ok(ProductActionResult::ReserveStock(())) => Ok(()),
84            Ok(_) => unreachable!("ReserveStock action must return ReserveStock result"),
85            Err(e) => Err(ProductError::ActorCommunicationError(e.to_string())),
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::product_actor::{ProductAction, ProductActionResult};
94    use actor_framework::mock::{create_mock_client, expect_action};
95
96    #[tokio::test]
97    async fn test_check_stock_returns_correct_level() {
98        let (client, mut receiver) = create_mock_client::<Product>(10);
99        let product_client = ProductClient::new(client);
100
101        // Spawn task to call check_stock
102        let check_task =
103            tokio::spawn(async move { product_client.check_stock(ProductId(1)).await });
104
105        // Expect the action request
106        let (id, action, responder) = expect_action(&mut receiver)
107            .await
108            .expect("Expected Action request");
109
110        assert_eq!(id, ProductId(1));
111        assert!(matches!(action, ProductAction::CheckStock));
112
113        // Respond with stock level
114        responder
115            .send(Ok(ProductActionResult::CheckStock(42)))
116            .unwrap();
117
118        // Verify the result
119        let result = check_task.await.unwrap();
120        assert_eq!(result.unwrap(), 42);
121    }
122
123    #[tokio::test]
124    async fn test_reserve_stock_success() {
125        let (client, mut receiver) = create_mock_client::<Product>(10);
126        let product_client = ProductClient::new(client);
127
128        // Spawn task to call reserve_stock
129        let reserve_task =
130            tokio::spawn(async move { product_client.reserve_stock(ProductId(1), 5).await });
131
132        // Expect the action request
133        let (id, action, responder) = expect_action(&mut receiver)
134            .await
135            .expect("Expected Action request");
136
137        assert_eq!(id, ProductId(1));
138        match action {
139            ProductAction::ReserveStock(amount) => assert_eq!(amount, 5),
140            _ => panic!("Expected ReserveStock action"),
141        }
142
143        // Respond with success
144        responder
145            .send(Ok(ProductActionResult::ReserveStock(())))
146            .unwrap();
147
148        // Verify the result
149        let result = reserve_task.await.unwrap();
150        assert!(result.is_ok());
151    }
152
153    #[tokio::test]
154    async fn test_reserve_stock_insufficient_stock() {
155        let (client, mut receiver) = create_mock_client::<Product>(10);
156        let product_client = ProductClient::new(client);
157
158        // Spawn task to call reserve_stock
159        let reserve_task =
160            tokio::spawn(async move { product_client.reserve_stock(ProductId(1), 100).await });
161
162        // Expect the action request
163        let (id, action, responder) = expect_action(&mut receiver)
164            .await
165            .expect("Expected Action request");
166
167        assert_eq!(id, ProductId(1));
168        match action {
169            ProductAction::ReserveStock(amount) => assert_eq!(amount, 100),
170            _ => panic!("Expected ReserveStock action"),
171        }
172
173        // Respond with error
174        use actor_framework::FrameworkError;
175        responder
176            .send(Err(FrameworkError::EntityError(Box::new(
177                std::io::Error::other("Stock check failed"),
178            ))))
179            .unwrap();
180
181        // Verify the result is an error
182        let result = reserve_task.await.unwrap();
183        assert!(result.is_err());
184        match result {
185            Err(ProductError::ActorCommunicationError(msg)) => {
186                // Error message comes from the EntityError wrapper
187                assert!(msg.contains("Stock check failed") || msg.contains("Entity error"));
188            }
189            _ => panic!("Expected ActorCommunicationError"),
190        }
191    }
192
193    #[test]
194    fn test_type_safety_compile_time() {
195        // This test verifies compile-time type safety
196        // The fact that this compiles proves the type safety works
197
198        // The return types are exactly what we expect:
199        // - check_stock returns Result<u32, ProductError>
200        // - reserve_stock returns Result<(), ProductError>
201
202        // No pattern matching needed at the call site!
203        // The other tests demonstrate this in action.
204    }
205}