Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions samod/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
191 changes: 191 additions & 0 deletions samod/src/storage/testing.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
vec![0, 1, 127, 99, 154, 235]
}

pub fn payload_b() -> Vec<u8> {
vec![1, 76, 160, 53, 57, 10, 230]
}

pub fn payload_c() -> Vec<u8> {
vec![2, 111, 74, 131, 236, 96, 142, 193]
}

static LARGE_PAYLOAD: LazyLock<Vec<u8>> = LazyLock::new(|| {
let mut vec = vec![0u8; 100000];
rand::rng().fill(&mut vec[..]);
vec
});

pub fn large_payload() -> Vec<u8> {
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<Output = Self> + Send;

/// Get reference to the storage adapter
fn storage(&self) -> &Self::Storage;

/// Optional cleanup
fn teardown(self) -> impl std::future::Future<Output = ()> + Send {
async {}
}
}

/// Helper to run a single test with setup and teardown
async fn run_test<F, TestFn>(test_fn: TestFn)
where
F: StorageTestFixture,
TestFn: for<'a> FnOnce(&'a F::Storage) -> Pin<Box<dyn Future<Output = ()> + 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<F: StorageTestFixture>() {
run_test::<F, _>(|a| Box::pin(test_load_should_return_none_if_no_data(a))).await;
run_test::<F, _>(|a| Box::pin(test_save_and_load_should_return_data_that_was_saved(a))).await;
run_test::<F, _>(|a| Box::pin(test_save_and_load_should_work_with_composite_keys(a))).await;
run_test::<F, _>(|a| Box::pin(test_save_and_load_should_work_with_large_payload(a))).await;
run_test::<F, _>(|a| Box::pin(test_load_range_should_return_empty_if_no_data(a))).await;
run_test::<F, _>(|a| Box::pin(test_save_and_load_range_should_return_all_matching_data(a)))
.await;
run_test::<F, _>(|a| Box::pin(test_save_and_load_range_should_only_load_matching_values(a)))
.await;
run_test::<F, _>(|a| Box::pin(test_save_and_remove_should_be_empty_after_removing(a))).await;
run_test::<F, _>(|a| Box::pin(test_save_and_save_should_overwrite(a))).await;
}

// describe("load")
pub async fn test_load_should_return_none_if_no_data<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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<S: Storage>(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()));
}
96 changes: 19 additions & 77 deletions samod/tests/gio_storage_test.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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::<GioFilesystemStorageFixture>()
.await;
});
}

Expand Down
33 changes: 33 additions & 0 deletions samod/tests/in_memory_storage_test.rs
Original file line number Diff line number Diff line change
@@ -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::<InMemoryStorageFixture>().await;
}
}
Loading