diff --git a/samod/src/storage.rs b/samod/src/storage.rs index c0c60f7..1ec6653 100644 --- a/samod/src/storage.rs +++ b/samod/src/storage.rs @@ -8,6 +8,8 @@ mod filesystem; mod in_memory; pub use in_memory::InMemoryStorage; +pub mod testing; + #[cfg(feature = "tokio")] pub use filesystem::tokio::FilesystemStorage as TokioFilesystemStorage; diff --git a/samod/src/storage/testing.rs b/samod/src/storage/testing.rs new file mode 100644 index 0000000..8ebd449 --- /dev/null +++ b/samod/src/storage/testing.rs @@ -0,0 +1,191 @@ +//! Storage adapter testing utilities +//! +//! rewritten from: +//! automerge-repo/packages/automerge-repo/src/helpers/tests/storage-adapter-tests.ts +//! +//! Provides a test suite for any implementation of the `Storage` trait. +//! Based on the TypeScript `runStorageAdapterTests` from automerge-repo. + +#![allow(dead_code)] + +use rand::Rng; +use std::future::Future; +use std::pin::Pin; +use std::sync::LazyLock; + +use super::{Storage, StorageKey}; + +pub fn payload_a() -> Vec { + vec![0, 1, 127, 99, 154, 235] +} + +pub fn payload_b() -> Vec { + vec![1, 76, 160, 53, 57, 10, 230] +} + +pub fn payload_c() -> Vec { + vec![2, 111, 74, 131, 236, 96, 142, 193] +} + +static LARGE_PAYLOAD: LazyLock> = LazyLock::new(|| { + let mut vec = vec![0u8; 100000]; + rand::rng().fill(&mut vec[..]); + vec +}); + +pub fn large_payload() -> Vec { + LARGE_PAYLOAD.clone() +} + +/// Trait for storage test fixtures +pub trait StorageTestFixture: Sized + Send { + /// The storage type being tested + type Storage: Storage + Send + Sync + 'static; + + /// Setup the test fixture + fn setup() -> impl std::future::Future + Send; + + /// Get reference to the storage adapter + fn storage(&self) -> &Self::Storage; + + /// Optional cleanup + fn teardown(self) -> impl std::future::Future + Send { + async {} + } +} + +/// Helper to run a single test with setup and teardown +async fn run_test(test_fn: TestFn) +where + F: StorageTestFixture, + TestFn: for<'a> FnOnce(&'a F::Storage) -> Pin + Send + 'a>> + Send, +{ + let fixture = F::setup().await; + test_fn(fixture.storage()).await; + fixture.teardown().await; +} + +/// Run all storage adapter acceptance tests +pub async fn run_storage_adapter_tests() { + run_test::(|a| Box::pin(test_load_should_return_none_if_no_data(a))).await; + run_test::(|a| Box::pin(test_save_and_load_should_return_data_that_was_saved(a))).await; + run_test::(|a| Box::pin(test_save_and_load_should_work_with_composite_keys(a))).await; + run_test::(|a| Box::pin(test_save_and_load_should_work_with_large_payload(a))).await; + run_test::(|a| Box::pin(test_load_range_should_return_empty_if_no_data(a))).await; + run_test::(|a| Box::pin(test_save_and_load_range_should_return_all_matching_data(a))) + .await; + run_test::(|a| Box::pin(test_save_and_load_range_should_only_load_matching_values(a))) + .await; + run_test::(|a| Box::pin(test_save_and_remove_should_be_empty_after_removing(a))).await; + run_test::(|a| Box::pin(test_save_and_save_should_overwrite(a))).await; +} + +// describe("load") +pub async fn test_load_should_return_none_if_no_data(adapter: &S) { + let actual = adapter + .load(StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap()) + .await; + + assert_eq!(actual, None); +} + +// describe("save and load") +pub async fn test_save_and_load_should_return_data_that_was_saved(adapter: &S) { + let key = StorageKey::from_parts(["storage-adapter-id"]).unwrap(); + adapter.put(key.clone(), payload_a()).await; + + let actual = adapter.load(key).await; + + assert_eq!(actual, Some(payload_a())); +} + +pub async fn test_save_and_load_should_work_with_composite_keys(adapter: &S) { + let key = StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap(); + adapter.put(key.clone(), payload_a()).await; + + let actual = adapter.load(key).await; + + assert_eq!(actual, Some(payload_a())); +} + +pub async fn test_save_and_load_should_work_with_large_payload(adapter: &S) { + let key = StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap(); + adapter.put(key.clone(), large_payload()).await; + + let actual = adapter.load(key).await; + + assert_eq!(actual, Some(large_payload())); +} + +// describe("loadRange") +pub async fn test_load_range_should_return_empty_if_no_data(adapter: &S) { + let result = adapter.load_range(StorageKey::from_parts(["AAAAA"]).unwrap()).await; + + assert_eq!(result.len(), 0); +} + +// describe("save and loadRange") +pub async fn test_save_and_load_range_should_return_all_matching_data(adapter: &S) { + let key_a = StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap(); + let key_b = StorageKey::from_parts(["AAAAA", "snapshot", "yyyyy"]).unwrap(); + let key_c = StorageKey::from_parts(["AAAAA", "sync-state", "zzzzz"]).unwrap(); + + adapter.put(key_a.clone(), payload_a()).await; + adapter.put(key_b.clone(), payload_b()).await; + adapter.put(key_c.clone(), payload_c()).await; + + let result = adapter.load_range(StorageKey::from_parts(["AAAAA"]).unwrap()).await; + + assert_eq!(result.len(), 3); + assert_eq!(result.get(&key_a), Some(&payload_a())); + assert_eq!(result.get(&key_b), Some(&payload_b())); + assert_eq!(result.get(&key_c), Some(&payload_c())); + + let sync_result = adapter + .load_range(StorageKey::from_parts(["AAAAA", "sync-state"]).unwrap()) + .await; + + assert_eq!(sync_result.len(), 2); + assert_eq!(sync_result.get(&key_a), Some(&payload_a())); + assert_eq!(sync_result.get(&key_c), Some(&payload_c())); +} + +pub async fn test_save_and_load_range_should_only_load_matching_values(adapter: &S) { + let key_a = StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap(); + let key_c = StorageKey::from_parts(["BBBBB", "sync-state", "zzzzz"]).unwrap(); + + adapter.put(key_a.clone(), payload_a()).await; + adapter.put(key_c.clone(), payload_c()).await; + + let actual = adapter.load_range(StorageKey::from_parts(["AAAAA"]).unwrap()).await; + + assert_eq!(actual.len(), 1); + assert_eq!(actual.get(&key_a), Some(&payload_a())); +} + +// describe("save and remove") +pub async fn test_save_and_remove_should_be_empty_after_removing(adapter: &S) { + let key = StorageKey::from_parts(["AAAAA", "snapshot", "xxxxx"]).unwrap(); + adapter.put(key.clone(), payload_a()).await; + adapter.delete(key.clone()).await; + + let range_result = adapter.load_range(StorageKey::from_parts(["AAAAA"]).unwrap()).await; + assert_eq!(range_result.len(), 0); + + let load_result = adapter.load(key).await; + assert_eq!(load_result, None); +} + +// describe("save and save") +pub async fn test_save_and_save_should_overwrite(adapter: &S) { + let key = StorageKey::from_parts(["AAAAA", "sync-state", "xxxxx"]).unwrap(); + adapter.put(key.clone(), payload_a()).await; + adapter.put(key.clone(), payload_b()).await; + + let result = adapter + .load_range(StorageKey::from_parts(["AAAAA", "sync-state"]).unwrap()) + .await; + + assert_eq!(result.len(), 1); + assert_eq!(result.get(&key), Some(&payload_b())); +} diff --git a/samod/tests/gio_storage_test.rs b/samod/tests/gio_storage_test.rs index 096cb2e..85d6c39 100644 --- a/samod/tests/gio_storage_test.rs +++ b/samod/tests/gio_storage_test.rs @@ -1,9 +1,8 @@ #[cfg(feature = "gio")] mod gio_tests { - use std::collections::HashMap; use tempfile::TempDir; - use samod::storage::{GioFilesystemStorage, Storage}; + use samod::storage::{testing::StorageTestFixture, GioFilesystemStorage, Storage}; use samod_core::StorageKey; fn init_logging() { @@ -12,93 +11,36 @@ mod gio_tests { .try_init(); } - #[test] - fn test_gio_storage_basic_operations() { - init_logging(); - - let main_context = glib::MainContext::new(); - main_context.block_on(async { - let temp_dir = TempDir::new().unwrap(); - let storage = GioFilesystemStorage::new(temp_dir.path()); - - let key = StorageKey::from_parts(vec!["test"]).unwrap(); - let data = b"hello world".to_vec(); - - // Test put and load - storage.put(key.clone(), data.clone()).await; - let loaded = storage.load(key.clone()).await; - assert_eq!(loaded, Some(data)); - - // Test delete - storage.delete(key.clone()).await; - let loaded_after_delete = storage.load(key).await; - assert_eq!(loaded_after_delete, None); - }); - } - - #[test] - fn test_gio_storage_load_range() { - init_logging(); - - let main_context = glib::MainContext::new(); - main_context.block_on(async { - let temp_dir = TempDir::new().unwrap(); - let storage = GioFilesystemStorage::new(temp_dir.path()); - - let base_key = StorageKey::from_parts(vec!["test_prefix"]).unwrap(); - - // Put multiple files with the same prefix - let key1 = StorageKey::from_parts(vec!["test_prefix", "file1"]).unwrap(); - let key2 = StorageKey::from_parts(vec!["test_prefix", "file2"]).unwrap(); - let key3 = StorageKey::from_parts(vec!["test_prefix", "subdir", "file3"]).unwrap(); - - let data1 = b"data1".to_vec(); - let data2 = b"data2".to_vec(); - let data3 = b"data3".to_vec(); - - storage.put(key1.clone(), data1.clone()).await; - storage.put(key2.clone(), data2.clone()).await; - storage.put(key3.clone(), data3.clone()).await; - - // Load range - let loaded_range = storage.load_range(base_key).await; - - let mut expected = HashMap::new(); - expected.insert(key1, data1); - expected.insert(key2, data2); - expected.insert(key3, data3); - - assert_eq!(loaded_range, expected); - }); + struct GioFilesystemStorageFixture { + storage: GioFilesystemStorage, + _temp_dir: TempDir, } - #[test] - fn test_gio_storage_nonexistent_file() { - init_logging(); + impl StorageTestFixture for GioFilesystemStorageFixture { + type Storage = GioFilesystemStorage; - let main_context = glib::MainContext::new(); - main_context.block_on(async { + async fn setup() -> Self { let temp_dir = TempDir::new().unwrap(); let storage = GioFilesystemStorage::new(temp_dir.path()); - - let key = StorageKey::from_parts(vec!["nonexistent"]).unwrap(); - let loaded = storage.load(key).await; - assert_eq!(loaded, None); - }); + Self { + storage, + _temp_dir: temp_dir, + } + } + + fn storage(&self) -> &Self::Storage { + &self.storage + } } #[test] - fn test_gio_storage_empty_range() { + fn gio_filesystem_storage_standard_tests() { init_logging(); let main_context = glib::MainContext::new(); main_context.block_on(async { - let temp_dir = TempDir::new().unwrap(); - let storage = GioFilesystemStorage::new(temp_dir.path()); - - let prefix = StorageKey::from_parts(vec!["empty_prefix"]).unwrap(); - let loaded_range = storage.load_range(prefix).await; - assert!(loaded_range.is_empty()); + samod::storage::testing::run_storage_adapter_tests::() + .await; }); } diff --git a/samod/tests/in_memory_storage_test.rs b/samod/tests/in_memory_storage_test.rs new file mode 100644 index 0000000..f03a8ea --- /dev/null +++ b/samod/tests/in_memory_storage_test.rs @@ -0,0 +1,33 @@ +mod in_memory_tests { + use samod::storage::{testing::StorageTestFixture, InMemoryStorage}; + + fn init_logging() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + } + + struct InMemoryStorageFixture { + storage: InMemoryStorage, + } + + impl StorageTestFixture for InMemoryStorageFixture { + type Storage = InMemoryStorage; + + async fn setup() -> Self { + Self { + storage: InMemoryStorage::new(), + } + } + + fn storage(&self) -> &Self::Storage { + &self.storage + } + } + + #[tokio::test] + async fn in_memory_storage_standard_tests() { + init_logging(); + samod::storage::testing::run_storage_adapter_tests::().await; + } +} diff --git a/samod/tests/tokio_storage_test.rs b/samod/tests/tokio_storage_test.rs index 56e5f26..b0ee193 100644 --- a/samod/tests/tokio_storage_test.rs +++ b/samod/tests/tokio_storage_test.rs @@ -1,9 +1,8 @@ #[cfg(feature = "tokio")] mod tokio_tests { - use std::collections::HashMap; use tempfile::TempDir; - use samod::storage::{Storage, TokioFilesystemStorage}; + use samod::storage::{testing::StorageTestFixture, Storage, TokioFilesystemStorage}; use samod_core::StorageKey; fn init_logging() { @@ -12,82 +11,32 @@ mod tokio_tests { .try_init(); } - #[tokio::test] - async fn test_tokio_storage_basic_operations() { - init_logging(); - - let temp_dir = TempDir::new().unwrap(); - let storage = TokioFilesystemStorage::new(temp_dir.path()); - - let key = StorageKey::from_parts(vec!["test"]).unwrap(); - let data = b"hello world".to_vec(); - - // Test put and load - storage.put(key.clone(), data.clone()).await; - let loaded = storage.load(key.clone()).await; - assert_eq!(loaded, Some(data)); - - // Test delete - storage.delete(key.clone()).await; - let loaded_after_delete = storage.load(key).await; - assert_eq!(loaded_after_delete, None); + struct TokioFilesystemStorageFixture { + storage: TokioFilesystemStorage, + _temp_dir: TempDir, } - #[tokio::test] - async fn test_tokio_storage_load_range() { - init_logging(); - - let temp_dir = TempDir::new().unwrap(); - let storage = TokioFilesystemStorage::new(temp_dir.path()); - - let base_key = StorageKey::from_parts(vec!["test_prefix"]).unwrap(); - - // Put multiple files with the same prefix - let key1 = StorageKey::from_parts(vec!["test_prefix", "file1"]).unwrap(); - let key2 = StorageKey::from_parts(vec!["test_prefix", "file2"]).unwrap(); - let key3 = StorageKey::from_parts(vec!["test_prefix", "subdir", "file3"]).unwrap(); - - let data1 = b"data1".to_vec(); - let data2 = b"data2".to_vec(); - let data3 = b"data3".to_vec(); + impl StorageTestFixture for TokioFilesystemStorageFixture { + type Storage = TokioFilesystemStorage; - storage.put(key1.clone(), data1.clone()).await; - storage.put(key2.clone(), data2.clone()).await; - storage.put(key3.clone(), data3.clone()).await; - - // Load range - let loaded_range = storage.load_range(base_key).await; - - let mut expected = HashMap::new(); - expected.insert(key1, data1); - expected.insert(key2, data2); - expected.insert(key3, data3); - - assert_eq!(loaded_range, expected); - } - - #[tokio::test] - async fn test_tokio_storage_nonexistent_file() { - init_logging(); - - let temp_dir = TempDir::new().unwrap(); - let storage = TokioFilesystemStorage::new(temp_dir.path()); + async fn setup() -> Self { + let temp_dir = TempDir::new().unwrap(); + let storage = TokioFilesystemStorage::new(temp_dir.path()); + Self { + storage, + _temp_dir: temp_dir, + } + } - let key = StorageKey::from_parts(vec!["nonexistent"]).unwrap(); - let loaded = storage.load(key).await; - assert_eq!(loaded, None); + fn storage(&self) -> &Self::Storage { + &self.storage + } } #[tokio::test] - async fn test_tokio_storage_empty_range() { + async fn tokio_filesystem_storage_standard_tests() { init_logging(); - - let temp_dir = TempDir::new().unwrap(); - let storage = TokioFilesystemStorage::new(temp_dir.path()); - - let prefix = StorageKey::from_parts(vec!["empty_prefix"]).unwrap(); - let loaded_range = storage.load_range(prefix).await; - assert!(loaded_range.is_empty()); + samod::storage::testing::run_storage_adapter_tests::().await; } #[tokio::test] @@ -133,21 +82,6 @@ mod tokio_tests { assert_eq!(loaded, Some(data)); } - #[tokio::test] - async fn test_tokio_storage_large_data() { - init_logging(); - - let temp_dir = TempDir::new().unwrap(); - let storage = TokioFilesystemStorage::new(temp_dir.path()); - - let key = StorageKey::from_parts(vec!["large_file"]).unwrap(); - let data = vec![42u8; 1024 * 1024]; // 1MB of data - - storage.put(key.clone(), data.clone()).await; - let loaded = storage.load(key).await; - assert_eq!(loaded, Some(data)); - } - #[tokio::test] async fn test_tokio_storage_concurrent_operations() { init_logging();