#![allow(clippy::unwrap_used)] //! UAT for Milestone 1, Phase 3: Storage Engine Trait and fjall Backend. //! //! These tests exercise acceptance criteria gaps not covered by //! `tidal/tests/storage.rs` or the unit tests in `storage/`. use tidaldb::schema::{EntityId, EntityKind}; use tidaldb::storage::{ FjallAtomicBatch, FjallStorage, InMemoryBackend, StorageEngine, Tag, WriteBatch, encode_key, entity_prefix, entity_tag_prefix, parse_key, }; // ============================================================================= // UAT-01: Out-of-order entity ID insert, scan_prefix returns numeric order // (Fjall backend) // ============================================================================= #[test] fn uat01_fjall_scan_prefix_returns_numeric_order_after_out_of_order_insert() { let dir = tempfile::tempdir().unwrap(); let storage = FjallStorage::open(dir.path()).unwrap(); let items = storage.backend(EntityKind::Item); // Insert entity IDs wildly out of numeric order let ids: Vec = vec![9999, 1, 500, 42, 10000, 7, 256, 3]; for &id_val in &ids { let id = EntityId::new(id_val); let key = encode_key(id, Tag::Meta, b""); items.put(&key, b"data").unwrap(); } // Full scan (empty prefix) to get all keys // Use entity_prefix for each ID and verify ordering let mut sorted_ids = ids; sorted_ids.sort_unstable(); sorted_ids.dedup(); // Scan with a common prefix that matches all keys (use entity prefix for smallest ID, // but that won't work for all). Instead, scan all keys by using an empty prefix. let all: Vec<_> = items .scan_prefix(b"") .collect::, _>>() .unwrap(); assert_eq!(all.len(), sorted_ids.len()); // Verify keys come back in entity ID numeric order let mut prev_id: Option = None; for (key_bytes, _) in &all { let (entity_id, tag, _suffix) = parse_key(key_bytes).unwrap(); assert_eq!(tag, Tag::Meta); if let Some(prev) = prev_id { assert!( entity_id.to_be_bytes() > EntityId::new(prev).to_be_bytes(), "entity {entity_id:?} should sort after entity with raw id {prev}" ); } prev_id = Some(u64::from_be_bytes(entity_id.to_be_bytes())); } // Verify the actual ID sequence matches sorted order let returned_ids: Vec = all .iter() .map(|(k, _)| { let (id, _, _) = parse_key(k).unwrap(); u64::from_be_bytes(id.to_be_bytes()) }) .collect(); assert_eq!(returned_ids, sorted_ids); } // ============================================================================= // UAT-02: Persistence across reopen for ALL three keyspaces // ============================================================================= #[test] fn uat02_fjall_persistence_all_three_keyspaces() { let dir = tempfile::tempdir().unwrap(); let item_key = encode_key(EntityId::new(1), Tag::Meta, b"item"); let user_key = encode_key(EntityId::new(2), Tag::Meta, b"user"); let creator_key = encode_key(EntityId::new(3), Tag::Meta, b"creator"); // Write to all three keyspaces, flush, and drop { let storage = FjallStorage::open(dir.path()).unwrap(); storage .backend(EntityKind::Item) .put(&item_key, b"item_value") .unwrap(); storage .backend(EntityKind::User) .put(&user_key, b"user_value") .unwrap(); storage .backend(EntityKind::Creator) .put(&creator_key, b"creator_value") .unwrap(); storage.flush_all().unwrap(); } // Reopen and verify all three keyspaces survived { let storage = FjallStorage::open(dir.path()).unwrap(); assert_eq!( storage .backend(EntityKind::Item) .get(&item_key) .unwrap() .as_deref(), Some(b"item_value".as_slice()), "Item keyspace data should survive reopen" ); assert_eq!( storage .backend(EntityKind::User) .get(&user_key) .unwrap() .as_deref(), Some(b"user_value".as_slice()), "User keyspace data should survive reopen" ); assert_eq!( storage .backend(EntityKind::Creator) .get(&creator_key) .unwrap() .as_deref(), Some(b"creator_value".as_slice()), "Creator keyspace data should survive reopen" ); } } // ============================================================================= // UAT-03: FjallAtomicBatch remove operation // ============================================================================= #[test] fn uat03_fjall_atomic_batch_remove() { let dir = tempfile::tempdir().unwrap(); let storage = FjallStorage::open(dir.path()).unwrap(); let item_key = encode_key(EntityId::new(10), Tag::Meta, b""); let user_key = encode_key(EntityId::new(20), Tag::Meta, b""); // Pre-populate storage .backend(EntityKind::Item) .put(&item_key, b"old_item") .unwrap(); storage .backend(EntityKind::User) .put(&user_key, b"old_user") .unwrap(); // Atomic batch: remove item, put new user value let mut batch = FjallAtomicBatch::new(&storage); batch.remove(storage.backend(EntityKind::Item), &item_key); batch.put(storage.backend(EntityKind::User), &user_key, b"new_user"); batch.commit().unwrap(); // Item should be gone assert_eq!( storage.backend(EntityKind::Item).get(&item_key).unwrap(), None, "Atomic batch remove should delete the item key" ); // User should have the new value assert_eq!( storage .backend(EntityKind::User) .get(&user_key) .unwrap() .as_deref(), Some(b"new_user".as_slice()), "Atomic batch put should update the user key" ); } // ============================================================================= // UAT-04: Entity kind isolation with scan_prefix (not just get) // ============================================================================= #[test] fn uat04_entity_kind_isolation_scan_prefix() { let dir = tempfile::tempdir().unwrap(); let storage = FjallStorage::open(dir.path()).unwrap(); let id = EntityId::new(100); let prefix = entity_prefix(id); // Write multiple tags under same entity in Item keyspace let k1 = encode_key(id, Tag::Meta, b""); let k2 = encode_key(id, Tag::Sig, b"score"); let k3 = encode_key(id, Tag::Evt, b"ev1"); storage.backend(EntityKind::Item).put(&k1, b"meta").unwrap(); storage.backend(EntityKind::Item).put(&k2, b"sig").unwrap(); storage.backend(EntityKind::Item).put(&k3, b"evt").unwrap(); // Write same entity in User keyspace with different data storage .backend(EntityKind::User) .put(&k1, b"user_meta") .unwrap(); // Scan Item: should see 3 keys let item_results: Vec<_> = storage .backend(EntityKind::Item) .scan_prefix(&prefix) .collect::, _>>() .unwrap(); assert_eq!( item_results.len(), 3, "Item keyspace should have 3 keys for entity 100" ); // Scan User: should see exactly 1 key let user_results: Vec<_> = storage .backend(EntityKind::User) .scan_prefix(&prefix) .collect::, _>>() .unwrap(); assert_eq!( user_results.len(), 1, "User keyspace should have 1 key for entity 100" ); // Scan Creator: should see 0 keys let creator_results: Vec<_> = storage .backend(EntityKind::Creator) .scan_prefix(&prefix) .collect::, _>>() .unwrap(); assert_eq!( creator_results.len(), 0, "Creator keyspace should have 0 keys for entity 100" ); } // ============================================================================= // UAT-05: entity_tag_prefix scan isolates tags within an entity on fjall // ============================================================================= #[test] fn uat05_entity_tag_prefix_scan_fjall() { let dir = tempfile::tempdir().unwrap(); let storage = FjallStorage::open(dir.path()).unwrap(); let items = storage.backend(EntityKind::Item); let id = EntityId::new(777); // Write multiple keys across different tags items .put(&encode_key(id, Tag::Evt, b"e1"), b"event1") .unwrap(); items .put(&encode_key(id, Tag::Evt, b"e2"), b"event2") .unwrap(); items .put(&encode_key(id, Tag::Sig, b"s1"), b"sig1") .unwrap(); items.put(&encode_key(id, Tag::Meta, b""), b"meta").unwrap(); // entity_tag_prefix for Evt should return exactly 2 let evt_prefix = entity_tag_prefix(id, Tag::Evt); let evt_results: Vec<_> = items .scan_prefix(&evt_prefix) .collect::, _>>() .unwrap(); assert_eq!(evt_results.len(), 2, "Should find exactly 2 Evt keys"); // entity_tag_prefix for Sig should return exactly 1 let sig_prefix = entity_tag_prefix(id, Tag::Sig); let sig_results: Vec<_> = items .scan_prefix(&sig_prefix) .collect::, _>>() .unwrap(); assert_eq!(sig_results.len(), 1, "Should find exactly 1 Sig key"); // entity_tag_prefix for Meta should return exactly 1 let meta_prefix = entity_tag_prefix(id, Tag::Meta); let meta_results: Vec<_> = items .scan_prefix(&meta_prefix) .collect::, _>>() .unwrap(); assert_eq!(meta_results.len(), 1, "Should find exactly 1 Meta key"); // entity_tag_prefix for Rel should return 0 let rel_prefix = entity_tag_prefix(id, Tag::Rel); let rel_results: Vec<_> = items .scan_prefix(&rel_prefix) .collect::, _>>() .unwrap(); assert_eq!(rel_results.len(), 0, "Should find 0 Rel keys"); } // ============================================================================= // UAT-06: WriteBatch with deletes and puts interleaved on fjall // ============================================================================= #[test] fn uat06_fjall_write_batch_interleaved_ops() { let dir = tempfile::tempdir().unwrap(); let storage = FjallStorage::open(dir.path()).unwrap(); let items = storage.backend(EntityKind::Item); // Pre-populate let k1 = encode_key(EntityId::new(1), Tag::Meta, b""); let k2 = encode_key(EntityId::new(2), Tag::Meta, b""); let k3 = encode_key(EntityId::new(3), Tag::Meta, b""); items.put(&k1, b"v1").unwrap(); items.put(&k2, b"v2").unwrap(); // Batch: delete k1, put k3, delete k2 (interleaved) let mut batch = WriteBatch::new(); batch.delete(k1.clone()); batch.put(k3.clone(), b"v3".to_vec()); batch.delete(k2.clone()); items.write_batch(batch).unwrap(); assert_eq!(items.get(&k1).unwrap(), None, "k1 should be deleted"); assert_eq!(items.get(&k2).unwrap(), None, "k2 should be deleted"); assert_eq!( items.get(&k3).unwrap().as_deref(), Some(b"v3".as_slice()), "k3 should exist" ); } // ============================================================================= // UAT-07: encode_key/parse_key roundtrip for ALL Tag variants (explicit) // ============================================================================= #[test] fn uat07_encode_parse_roundtrip_all_tags() { let all_tags = [ Tag::Evt, Tag::Sig, Tag::Meta, Tag::Rel, Tag::Mv, Tag::Idx, Tag::Session, ]; let id = EntityId::new(u64::MAX); // boundary value for tag in all_tags { let suffix = format!("test_{tag:?}"); let key = encode_key(id, tag, suffix.as_bytes()); let (parsed_id, parsed_tag, parsed_suffix) = parse_key(&key).unwrap_or_else(|| panic!("parse_key should succeed for tag {tag:?}")); assert_eq!(parsed_id, id, "EntityId roundtrip for tag {tag:?}"); assert_eq!(parsed_tag, tag, "Tag roundtrip for tag {tag:?}"); assert_eq!( parsed_suffix, suffix.as_bytes(), "Suffix roundtrip for tag {tag:?}", ); } // Also test with EntityId(0) — the other boundary let id_zero = EntityId::new(0); for tag in all_tags { let key = encode_key(id_zero, tag, b""); let (parsed_id, parsed_tag, parsed_suffix) = parse_key(&key).unwrap(); assert_eq!(parsed_id, id_zero); assert_eq!(parsed_tag, tag); assert!(parsed_suffix.is_empty()); } } // ============================================================================= // UAT-08: Persistence survives reopen with scan_prefix verification // ============================================================================= #[test] fn uat08_persistence_verified_via_scan_prefix() { let dir = tempfile::tempdir().unwrap(); let id = EntityId::new(55); // Write multiple keys, flush, drop { let storage = FjallStorage::open(dir.path()).unwrap(); let items = storage.backend(EntityKind::Item); items.put(&encode_key(id, Tag::Meta, b""), b"meta").unwrap(); items .put(&encode_key(id, Tag::Sig, b"a"), b"sig_a") .unwrap(); items .put(&encode_key(id, Tag::Sig, b"b"), b"sig_b") .unwrap(); storage.flush_all().unwrap(); } // Reopen and verify via scan_prefix (not just single get) { let storage = FjallStorage::open(dir.path()).unwrap(); let items = storage.backend(EntityKind::Item); let prefix = entity_prefix(id); let results: Vec<_> = items .scan_prefix(&prefix) .collect::, _>>() .unwrap(); assert_eq!( results.len(), 3, "All 3 keys should survive reopen and be scan-discoverable" ); // Verify values too let values: Vec<&[u8]> = results.iter().map(|(_, v)| v.as_slice()).collect(); // Keys are sorted: Evt(0x01) < Sig(0x02) < Meta(0x03) // So order is: Sig "a", Sig "b", Meta "" assert!(values.contains(&b"meta".as_slice())); assert!(values.contains(&b"sig_a".as_slice())); assert!(values.contains(&b"sig_b".as_slice())); } } // ============================================================================= // UAT-09: FjallAtomicBatch persists across reopen // ============================================================================= #[test] fn uat09_atomic_batch_persists_across_reopen() { let dir = tempfile::tempdir().unwrap(); { let storage = FjallStorage::open(dir.path()).unwrap(); let mut batch = FjallAtomicBatch::new(&storage); batch.put( storage.backend(EntityKind::Item), &encode_key(EntityId::new(1), Tag::Meta, b""), b"atomic_item", ); batch.put( storage.backend(EntityKind::Creator), &encode_key(EntityId::new(2), Tag::Meta, b""), b"atomic_creator", ); batch.commit().unwrap(); storage.flush_all().unwrap(); } { let storage = FjallStorage::open(dir.path()).unwrap(); assert_eq!( storage .backend(EntityKind::Item) .get(&encode_key(EntityId::new(1), Tag::Meta, b"")) .unwrap() .as_deref(), Some(b"atomic_item".as_slice()), "Atomic batch item should persist across reopen" ); assert_eq!( storage .backend(EntityKind::Creator) .get(&encode_key(EntityId::new(2), Tag::Meta, b"")) .unwrap() .as_deref(), Some(b"atomic_creator".as_slice()), "Atomic batch creator should persist across reopen" ); } } // ============================================================================= // UAT-10: InMemoryBackend scan_prefix returns lexicographic order // with encoded keys inserted out of order // ============================================================================= #[test] fn uat10_in_memory_scan_all_returns_numeric_order() { let engine = InMemoryBackend::new(); // Insert in reverse numeric order for id_val in (1u64..=20).rev() { let key = encode_key(EntityId::new(id_val), Tag::Meta, b""); engine.put(&key, b"data").unwrap(); } // Scan all let all: Vec<_> = engine .scan_prefix(b"") .collect::, _>>() .unwrap(); assert_eq!(all.len(), 20); // Verify monotonically increasing entity IDs let ids: Vec = all .iter() .map(|(k, _)| { let (id, _, _) = parse_key(k).unwrap(); u64::from_be_bytes(id.to_be_bytes()) }) .collect(); for window in ids.windows(2) { assert!( window[0] < window[1], "IDs must be in ascending order: {} < {}", window[0], window[1] ); } assert_eq!(ids, (1u64..=20).collect::>()); }