coven/storage/cloud/
test_utils.rs

1//! In-process CloudHome implementation for tests. Records every write keyed
2//! by cloud_key so tests can read back exactly what landed, and serves reads
3//! from the same map — enough to simulate two devices sharing a cloud bucket.
4//!
5//! Available under `#[cfg(test)]` in coven itself and to downstream crates
6//! that enable the `test-utils` feature.
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10
11use async_trait::async_trait;
12
13use super::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
14
15/// In-memory CloudHome backed by a HashMap. Thread-safe; cheap to share
16/// between simulated devices via `Arc`.
17pub struct InMemoryCloudHome {
18    writes: Mutex<HashMap<String, Vec<u8>>>,
19    deletes: Mutex<Vec<String>>,
20}
21
22impl InMemoryCloudHome {
23    pub fn new() -> Self {
24        Self {
25            writes: Mutex::new(HashMap::new()),
26            deletes: Mutex::new(Vec::new()),
27        }
28    }
29
30    /// Snapshot of every key currently in the cloud. Useful for assertions
31    /// that don't want to hold the lock across an await.
32    pub fn keys(&self) -> Vec<String> {
33        self.writes.lock().unwrap().keys().cloned().collect()
34    }
35
36    /// Snapshot of the bytes at `key`, or `None` if absent. Cloned so the
37    /// caller can hold the result across `await` points without retaining
38    /// the internal lock.
39    pub fn get(&self, key: &str) -> Option<Vec<u8>> {
40        self.writes.lock().unwrap().get(key).cloned()
41    }
42
43    /// Number of objects stored. Cheap snapshot.
44    pub fn len(&self) -> usize {
45        self.writes.lock().unwrap().len()
46    }
47
48    /// Returns true if the store is empty.
49    pub fn is_empty(&self) -> bool {
50        self.writes.lock().unwrap().is_empty()
51    }
52
53    /// Snapshot of every delete that's been requested, in arrival order.
54    pub fn deletes_seen(&self) -> Vec<String> {
55        self.deletes.lock().unwrap().clone()
56    }
57}
58
59impl Default for InMemoryCloudHome {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65#[async_trait]
66impl CloudHome for InMemoryCloudHome {
67    async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
68        self.writes.lock().unwrap().insert(key.to_string(), data);
69        Ok(())
70    }
71
72    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
73        self.writes
74            .lock()
75            .unwrap()
76            .get(key)
77            .cloned()
78            .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))
79    }
80
81    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
82        let data = self.read(key).await?;
83        let s = start as usize;
84        let e = (end as usize).min(data.len());
85        if s > data.len() {
86            return Err(CloudHomeError::NotFound(format!("range past end of {key}")));
87        }
88        Ok(data[s..e].to_vec())
89    }
90
91    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
92        Ok(self
93            .writes
94            .lock()
95            .unwrap()
96            .keys()
97            .filter(|k| k.starts_with(prefix))
98            .cloned()
99            .collect())
100    }
101
102    async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
103        self.writes.lock().unwrap().remove(key);
104        self.deletes.lock().unwrap().push(key.to_string());
105        Ok(())
106    }
107
108    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
109        Ok(self.writes.lock().unwrap().contains_key(key))
110    }
111
112    async fn grant_access(&self, _member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError> {
113        Err(CloudHomeError::Storage(
114            "InMemoryCloudHome does not grant access".into(),
115        ))
116    }
117
118    async fn revoke_access(&self, _member_id: &str) -> Result<(), CloudHomeError> {
119        Ok(())
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[tokio::test]
128    async fn write_then_read_roundtrips() {
129        let h = InMemoryCloudHome::new();
130        h.write("foo", b"hello".to_vec()).await.unwrap();
131        assert_eq!(h.read("foo").await.unwrap(), b"hello");
132        assert!(h.exists("foo").await.unwrap());
133        assert!(!h.exists("bar").await.unwrap());
134    }
135
136    #[tokio::test]
137    async fn read_range_returns_a_slice() {
138        let h = InMemoryCloudHome::new();
139        h.write("k", b"0123456789".to_vec()).await.unwrap();
140        assert_eq!(h.read_range("k", 2, 5).await.unwrap(), b"234");
141    }
142
143    #[tokio::test]
144    async fn list_filters_by_prefix() {
145        let h = InMemoryCloudHome::new();
146        h.write("a/x", vec![1]).await.unwrap();
147        h.write("a/y", vec![2]).await.unwrap();
148        h.write("b/x", vec![3]).await.unwrap();
149        let mut got = h.list("a/").await.unwrap();
150        got.sort();
151        assert_eq!(got, vec!["a/x".to_string(), "a/y".to_string()]);
152    }
153
154    #[tokio::test]
155    async fn delete_removes_and_records() {
156        let h = InMemoryCloudHome::new();
157        h.write("k", vec![1]).await.unwrap();
158        h.delete("k").await.unwrap();
159        assert!(matches!(
160            h.read("k").await,
161            Err(CloudHomeError::NotFound(_))
162        ));
163        assert_eq!(h.deletes_seen(), vec!["k".to_string()]);
164    }
165}