swactor/crates/datastore/tests/storage_tests.rs
Zachery Aaron Shores-Chmielewski 3d5a539479 feat: distributed datastore primitives protocol
Implements MVP for a swactor distributed datastore, addressing large files via content addressing.
2026-02-15 17:18:05 +07:00

240 lines
8.6 KiB
Rust

//! Tests for StorageBackend implementations — parameterized across backends.
use std::collections::HashSet;
use swactor_datastore::chunking::chunk_blob;
use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend, StorageBackend};
use swactor_datastore::types::{ChunkRef, ContentHash, ObjectManifest};
// ═══════════════════════════════════════════════════════════════════════════
// Backend factory helpers
// ═══════════════════════════════════════════════════════════════════════════
fn run_with_both_backends(test: impl Fn(&mut dyn StorageBackend)) {
// In-memory
let mut mem = InMemoryBackend::new();
test(&mut mem);
// Filesystem
let dir = tempfile::tempdir().unwrap();
let mut fs = FilesystemBackend::new(dir.path().to_path_buf());
test(&mut fs);
}
// ═══════════════════════════════════════════════════════════════════════════
// Scenario: chunk storage contract
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn store_and_retrieve_a_single_chunk() {
run_with_both_backends(|backend| {
let data = b"hello chunk";
let hash = ContentHash::of(data);
backend.write_chunk(&hash, data).unwrap();
let read_back = backend.read_chunk(&hash).unwrap();
assert_eq!(read_back, Some(data.to_vec()));
});
}
#[test]
fn chunk_not_found_returns_none() {
run_with_both_backends(|backend| {
let hash = ContentHash::of(b"nonexistent");
assert_eq!(backend.read_chunk(&hash).unwrap(), None);
});
}
#[test]
fn delete_chunk_makes_it_unretrievable() {
run_with_both_backends(|backend| {
let data = b"ephemeral";
let hash = ContentHash::of(data);
backend.write_chunk(&hash, data).unwrap();
backend.delete_chunk(&hash).unwrap();
assert_eq!(backend.read_chunk(&hash).unwrap(), None);
});
}
#[test]
fn has_chunk_reflects_storage_state() {
run_with_both_backends(|backend| {
let data = b"existence check";
let hash = ContentHash::of(data);
assert!(!backend.has_chunk(&hash));
backend.write_chunk(&hash, data).unwrap();
assert!(backend.has_chunk(&hash));
backend.delete_chunk(&hash).unwrap();
assert!(!backend.has_chunk(&hash));
});
}
#[test]
fn list_chunks_returns_all_stored_hashes() {
run_with_both_backends(|backend| {
let mut expected = HashSet::new();
for i in 0u8..5 {
let data = vec![i; 32];
let hash = ContentHash::of(&data);
backend.write_chunk(&hash, &data).unwrap();
expected.insert(hash);
}
let listed: HashSet<ContentHash> = backend.list_chunks().into_iter().collect();
assert_eq!(listed, expected);
});
}
#[test]
fn store_and_retrieve_manifest() {
run_with_both_backends(|backend| {
let manifest = ObjectManifest {
content_hash: ContentHash::of(b"my-blob"),
chunks: vec![ChunkRef {
hash: ContentHash::of(b"chunk-0"),
offset: 0,
size: 1024,
}],
total_size: 1024,
chunk_size: 1024,
content_type: Some("text/plain".to_string()),
};
backend.write_manifest(&manifest).unwrap();
let read_back = backend.read_manifest(&manifest.content_hash).unwrap();
assert_eq!(read_back, Some(manifest));
});
}
#[test]
fn delete_manifest_removes_it() {
run_with_both_backends(|backend| {
let manifest = ObjectManifest {
content_hash: ContentHash::of(b"deletable"),
chunks: vec![],
total_size: 0,
chunk_size: 1024,
content_type: None,
};
backend.write_manifest(&manifest).unwrap();
backend.delete_manifest(&manifest.content_hash).unwrap();
assert_eq!(backend.read_manifest(&manifest.content_hash).unwrap(), None);
});
}
#[test]
fn overwriting_chunk_is_idempotent() {
run_with_both_backends(|backend| {
let data = b"idempotent write";
let hash = ContentHash::of(data);
backend.write_chunk(&hash, data).unwrap();
backend.write_chunk(&hash, data).unwrap();
assert_eq!(backend.read_chunk(&hash).unwrap(), Some(data.to_vec()));
assert_eq!(backend.list_chunks().len(), 1);
});
}
#[test]
fn chunked_blob_round_trips_through_storage() {
run_with_both_backends(|backend| {
let original: Vec<u8> = (0..500).map(|i| (i % 256) as u8).collect();
let (_, manifest, chunks) = chunk_blob(&original, 128);
// Store all chunks and manifest.
for (hash, data) in &chunks {
backend.write_chunk(hash, data).unwrap();
}
backend.write_manifest(&manifest).unwrap();
// Read back and reassemble.
let read_manifest = backend.read_manifest(&manifest.content_hash).unwrap().unwrap();
let mut reassembled = Vec::new();
for chunk_ref in &read_manifest.chunks {
let data = backend.read_chunk(&chunk_ref.hash).unwrap().unwrap();
reassembled.extend_from_slice(&data);
}
assert_eq!(reassembled, original);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Filesystem-only scenarios
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn backend_rescans_chunks_on_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let data = b"persistent";
let hash = ContentHash::of(data);
// Write with first instance.
{
let mut backend = FilesystemBackend::new(path.clone());
backend.write_chunk(&hash, data).unwrap();
}
// Reopen — should discover existing chunks.
let backend = FilesystemBackend::new(path);
assert!(backend.has_chunk(&hash));
assert_eq!(backend.read_chunk(&hash).unwrap(), Some(data.to_vec()));
}
#[test]
fn sharding_creates_expected_directory_structure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let data = b"shard-check";
let hash = ContentHash::of(data);
let hex = hash.to_hex();
let mut backend = FilesystemBackend::new(path.clone());
backend.write_chunk(&hash, data).unwrap();
// Verify the 2-level sharded path exists.
let expected_path = path
.join("chunks")
.join(&hex[..2])
.join(&hex[2..4])
.join(&hex);
assert!(expected_path.exists(), "sharded chunk path should exist: {expected_path:?}");
}
// ═══════════════════════════════════════════════════════════════════════════
// Property-based tests
// ═══════════════════════════════════════════════════════════════════════════
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn any_chunk_survives_write_read_round_trip(
data in proptest::collection::vec(any::<u8>(), 1..4096),
) {
// In-memory
let mut mem = InMemoryBackend::new();
let hash = ContentHash::of(&data);
mem.write_chunk(&hash, &data).unwrap();
prop_assert_eq!(mem.read_chunk(&hash).unwrap(), Some(data.clone()));
// Filesystem
let dir = tempfile::tempdir().unwrap();
let mut fs = FilesystemBackend::new(dir.path().to_path_buf());
fs.write_chunk(&hash, &data).unwrap();
prop_assert_eq!(fs.read_chunk(&hash).unwrap(), Some(data));
}
}
}