//! Tests for [`super`]. use super::*; use std::io::Write; use tempfile::TempDir; fn setup() -> (TempDir, Database, SampleStore) { let dir = TempDir::new().unwrap(); let db = Database::open_in_memory().unwrap(); let store_dir = dir.path().join("store"); let store = SampleStore::new(&store_dir).unwrap(); (dir, db, store) } fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf { let path = dir.path().join(name); let mut f = fs::File::create(&path).unwrap(); f.write_all(content).unwrap(); path } /// Count every file anywhere under `root`, shard directories included. /// /// Blob-count assertions must not stop at the root's own entries: under the /// sharded layout that reads 0 whatever the store actually holds, which would /// turn an orphan-detection test into one that cannot fail. fn count_blobs_recursively(root: &Path) -> usize { let Ok(entries) = fs::read_dir(root) else { return 0; }; entries .filter_map(std::result::Result::ok) .map(|e| { let path = e.path(); if path.is_dir() { count_blobs_recursively(&path) } else { 1 } }) .sum() } /// Give a sample one VFS placement, so CASCADE behaviour and placement /// preservation are observable. fn place_sample(db: &Database, hash: &str) { db.conn() .execute( "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \ VALUES (1, 'Library', 0, 0)", [], ) .unwrap(); db.conn() .execute( "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \ VALUES (1, NULL, ?1, 'sample', ?1, 0)", [hash], ) .unwrap(); } fn count(db: &Database, sql: &str, hash: &str) -> i64 { db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap() } #[test] fn tombstone_hides_sample_and_undelete_restores() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); // Live: visible to the read path, absent from Trash. assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok()); assert!(tombstoned_samples(&db).unwrap().is_empty()); // Tombstone is a one-shot: the second call is a no-op. assert!(tombstone_sample(&db, &hash).unwrap()); assert!(!tombstone_sample(&db, &hash).unwrap()); // Hidden from the read path, surfaced in Trash with a timestamp. assert!(matches!( sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())), Err(CoreError::SampleNotFound(_)) )); let trash = tombstoned_samples(&db).unwrap(); assert_eq!(trash.len(), 1); assert_eq!(trash[0].hash, hash); assert!(trash[0].deleted_at > 0); // Undelete restores it; a second undelete is a no-op. assert!(undelete_sample(&db, &hash).unwrap()); assert!(!undelete_sample(&db, &hash).unwrap()); assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok()); assert!(tombstoned_samples(&db).unwrap().is_empty()); } #[test] fn tombstone_preserves_placements_and_blob() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); place_sample(&db, &hash); assert!(tombstone_sample(&db, &hash).unwrap()); // The whole point of soft delete: placements and the blob survive so the // user can recover everything. assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash ), 1 ); assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn remove_purges_tombstoned_row_and_cascades() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); place_sample(&db, &hash); assert!(tombstone_sample(&db, &hash).unwrap()); // Permanent delete must work on a tombstoned row even though the // filtered `sample_extension` would hide it, `remove` resolves the // blob path unfiltered. store .remove(&crate::SampleHash::from_trusted(hash.clone()), &db) .unwrap(); assert!( !store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); assert_eq!( count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash), 0 ); assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash ), 0 ); } #[test] fn sweep_hard_deletes_only_expired_tombstones() { let (dir, db, store) = setup(); let live = store .import(&create_test_file(&dir, "live.wav", b"live audio"), &db) .unwrap(); let fresh = store .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db) .unwrap(); let old = store .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db) .unwrap(); place_sample(&db, &old); // fresh: tombstoned just now. old: tombstoned beyond the 30-day window. assert!(tombstone_sample(&db, &fresh).unwrap()); assert!(tombstone_sample(&db, &old).unwrap()); db.conn() .execute( "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", rusqlite::params![unix_now() - 31 * 86_400, old], ) .unwrap(); let removed = store.sweep_expired_tombstones(&db).unwrap(); assert_eq!(removed, 1); // old is gone (row, blob, and CASCADE'd placement); fresh + live stay. assert!( !store .exists(&crate::SampleHash::from_trusted(old.clone()), "wav") .unwrap() ); assert_eq!( count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old), 0 ); assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &old ), 0 ); assert_eq!(tombstoned_samples(&db).unwrap().len(), 1); assert!(sample_extension(&db, &crate::SampleHash::from_trusted(live.clone())).is_ok()); assert!( store .exists(&crate::SampleHash::from_trusted(fresh.clone()), "wav") .unwrap() ); } #[test] fn sweep_respects_retain_days_config() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "s.wav", b"some audio"), &db) .unwrap(); assert!(tombstone_sample(&db, &hash).unwrap()); db.conn() .execute( "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", rusqlite::params![unix_now() - 5 * 86_400, hash], ) .unwrap(); // 5 days old, default 30-day window: not yet expired. assert_eq!(tombstone_retain_days(&db), 30); assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0); // Shrink the window to 3 days: now it sweeps. db.conn() .execute( "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'", [], ) .unwrap(); assert_eq!(tombstone_retain_days(&db), 3); assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1); } #[test] fn clamp_original_name_caps_length_on_char_boundary() { // Short names pass through untouched. assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav"); // Over-long ASCII is capped to 255 bytes. let long = "a".repeat(1000); assert_eq!(clamp_original_name(long).len(), 255); // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8). let multibyte = "é".repeat(200); // 400 bytes let clamped = clamp_original_name(multibyte); assert!(clamped.len() <= 255); assert!(std::str::from_utf8(clamped.as_bytes()).is_ok()); } #[test] fn hash_file_matches_import_and_rejects_bad_input() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); // hash_file's digest equals the hash import() records. let (hash, size) = hash_file(&src).unwrap(); assert_eq!(size, "fake audio data".len() as i64); let imported = store.import(&src, &db).unwrap(); assert_eq!(hash, imported); // Zero-byte and non-audio files are rejected (same guards as import). let empty = create_test_file(&dir, "empty.wav", b""); assert!(hash_file(&empty).is_err()); let txt = create_test_file(&dir, "notes.txt", b"hello"); assert!(hash_file(&txt).is_err()); } #[test] fn hash_file_matches_the_reference_sha256_digest() { // The content address is the library's primary key, so the exact hex // string a given byte sequence produces is a compatibility guarantee: // change it and every stored path and database row stops resolving. // Pinned against an independent SHA-256 of the same bytes, so a hasher // or hex-encoding swap has to survive a known answer, not just agree // with itself. let (dir, _db, _store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); let (hash, _) = hash_file(&src).unwrap(); assert_eq!( hash, "cec560f942befcb4e4a4d1161c5c03b3a787e2d525f650042641e62bf8773c69", "SHA-256 of b\"fake audio data\", lowercase hex, no separators" ); } #[test] fn import_hashed_matches_serial_import() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"some audio bytes"); // Pre-hash then record, the prehashed path must land the same blob + row // as the all-in-one import(). let (hash, size) = hash_file(&src).unwrap(); store .import_hashed( &src, &crate::SampleHash::from_trusted(hash.clone()), size, &db, ) .unwrap(); assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); let count: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM samples WHERE hash = ?1", [&hash], |r| r.get(0), ) .unwrap(); assert_eq!(count, 1); } #[test] fn hash_files_parallel_aligns_results() { let (dir, _db, _store) = setup(); let a = create_test_file(&dir, "a.wav", b"aaaa"); let b = create_test_file(&dir, "b.wav", b"bbbbbb"); let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]); assert_eq!(results.len(), 3); // Aligned to input order; each Ok hash equals a direct hash_file call. assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0); assert_eq!(results[1].as_ref().unwrap().1, 6); assert!(results[2].is_err()); } #[test] fn import_creates_file_and_row() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); let hash = store.import(&src, &db).unwrap(); // File exists in store assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); // Row exists in DB let count: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM samples WHERE hash = ?1", [&hash], |row| row.get(0), ) .unwrap(); assert_eq!(count, 1); } /// The two entry points make opposite promises about the directory fsync, /// and the split is the whole of the 27% the hoist bought: `import_hashed` /// is the batch path and leaves its shard directory owing a flush, while /// `import` is one-shot and owes nothing on return. /// /// Pins the bookkeeping rather than the fsync, which is not observable. The /// regression it guards is a caller (or a future one) that loops /// `import_hashed` and never flushes, or an `import` that quietly starts /// deferring on behalf of callers that have no batch end. #[test] fn the_batch_path_defers_its_directory_fsync_and_the_one_shot_path_does_not() { let (dir, db, store) = setup(); let batched = create_test_file(&dir, "batched.wav", b"batched bytes"); let (hash, size) = hash_file(&batched).unwrap(); let hash = crate::SampleHash::from_trusted(hash); store.import_hashed(&batched, &hash, size, &db).unwrap(); assert_eq!( store.pending_dirs().len(), 1, "the batch path leaves its shard directory owing an fsync", ); // Deferring the fsync must not defer the blob: it is renamed into place // and readable now, which is why the batch end is soon enough. assert!(store.exists(&hash, "wav").unwrap()); store.flush_dirs(); assert!( store.pending_dirs().is_empty(), "flush_dirs drains the set, so a second run does not re-sync the world", ); let one_shot = create_test_file(&dir, "one_shot.wav", b"one-shot bytes"); store.import(&one_shot, &db).unwrap(); assert!( store.pending_dirs().is_empty(), "the one-shot path has no batch end to defer to, so it flushes itself", ); } #[test] fn import_deduplicates() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"same content"); let hash1 = store.import(&src, &db).unwrap(); let hash2 = store.import(&src, &db).unwrap(); assert_eq!(hash1, hash2); // Only one row let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn import_same_bytes_different_extension_reuses_blob() { let (dir, db, store) = setup(); // Identical bytes, two different audio extensions (is_audio_file keys on // extension, so the content can be arbitrary). let wav = create_test_file(&dir, "loop.wav", b"identical bytes"); let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes"); let h1 = store.import(&wav, &db).unwrap(); let h2 = store.import(&aiff, &db).unwrap(); assert_eq!(h1, h2, "identical bytes hash to the same sample"); // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not // write an unreachable `{hash}.aiff` orphan. Counted recursively, because // blobs live under a shard directory now; counting only the root's own files // would read 0 here and pass this test vacuously for the wrong reason. let blob_count = || count_blobs_recursively(store.root()); assert_eq!( blob_count(), 1, "second extension must reuse the first blob" ); // And remove() leaves nothing behind, the orphan would otherwise survive, // since remove() resolves the blob path from the DB row's extension. store .remove(&crate::SampleHash::from_trusted(h1.clone()), &db) .unwrap(); assert_eq!(blob_count(), 0, "no orphan blob remains after remove"); } #[test] fn import_repairs_a_truncated_preexisting_blob() { // Reproduces the corrupt-blob trap: a crash mid-copy (or any partial // write) can leave a truncated file at the canonical content-addressed // path. A content-addressed store must never trust it, import must // detect the size mismatch and rewrite the correct bytes. let (dir, db, store) = setup(); let content = b"the genuine full sample payload"; let src = create_test_file(&dir, "kick.wav", content); // Pre-place a truncated blob at the canonical path the real import targets. let hash = hex::encode(Sha256::digest(content)); let dest = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); fs::create_dir_all(dest.parent().unwrap()).unwrap(); fs::write(&dest, b"trunc").unwrap(); let imported = store.import(&src, &db).unwrap(); assert_eq!(imported, hash); // The stored blob now matches the source bytes exactly (hash verified). let stored = fs::read(&dest).unwrap(); assert_eq!(stored, content, "truncated blob must be repaired on import"); assert_eq!( hex::encode(Sha256::digest(&stored)), hash, "repaired blob hashes back to its content address" ); } #[test] fn remove_deletes_file_and_row() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"snare data"); let hash = store.import(&src, &db).unwrap(); assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); store .remove(&crate::SampleHash::from_trusted(hash.clone()), &db) .unwrap(); assert!( !store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn remove_nonexistent_returns_error() { let (_dir, db, store) = setup(); // Use a valid 64-char hex hash that doesn't exist in the DB let fake_hash = "a".repeat(64); let result = store.remove(&crate::SampleHash::from_trusted(fake_hash.clone()), &db); assert!(matches!(result, Err(CoreError::SampleNotFound(_)))); } #[test] fn sample_path_rejects_traversal() { let (_dir, _db, store) = setup(); let result = store.sample_path( &crate::SampleHash::from_trusted("../../../etc/passwd"), "wav", ); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_rejects_short_hash() { let (_dir, _db, store) = setup(); let result = store.sample_path(&crate::SampleHash::from_trusted("abcdef"), "wav"); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_rejects_uppercase() { let (_dir, _db, store) = setup(); let hash = "A".repeat(64); let result = store.sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav"); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_accepts_valid_hash() { let (_dir, _db, store) = setup(); let hash = "a1b2c3d4e5f6".to_string() + &"0".repeat(52); let path = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); assert!(path.to_string_lossy().ends_with(".wav")); } #[test] fn sample_path_rejects_traversal_in_extension() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); let result = store.sample_path( &crate::SampleHash::from_trusted(hash.clone()), "../etc/passwd", ); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn sample_path_rejects_path_separator_in_extension() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); let result = store.sample_path( &crate::SampleHash::from_trusted(hash.clone()), "wav/../../etc", ); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn sample_path_accepts_common_extensions() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] { assert!( store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), ext) .is_ok(), "rejected valid ext: {ext}" ); } } #[test] fn query_sample_field_rejects_disallowed_field() { let (_dir, db, _store) = setup(); let hash = "a".repeat(64); let result = query_sample_field( &db, &crate::SampleHash::from_trusted(hash.clone()), "hash; DROP TABLE samples --", ); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn verify_sample_matches_after_import() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "hihat.wav", b"hihat audio data"); let hash = store.import(&src, &db).unwrap(); assert!( store .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn verify_sample_detects_corruption() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"original data"); let hash = store.import(&src, &db).unwrap(); // Corrupt the stored file. Store blobs are written read-only (the // mirror write-through guard), so clear that first to simulate bit-rot. let stored_path = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); let mut perms = fs::metadata(&stored_path).unwrap().permissions(); // Intentionally clear read-only to simulate bit-rot on a stored blob. #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&stored_path, perms).unwrap(); fs::write(&stored_path, b"corrupted data").unwrap(); assert!( !store .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn verify_sample_errors_on_missing_file() { let (_dir, _db, store) = setup(); let fake_hash = "b".repeat(64); let result = store.verify_sample(&crate::SampleHash::from_trusted(fake_hash.clone()), "wav"); assert!(matches!(result, Err(CoreError::Io { .. }))); } #[test] fn import_hashed_rejects_hash_that_does_not_match_bytes() { // Simulates the source file mutating between the parallel pre-hash pass // and the serial copy: import_hashed is handed a hash that does not // describe the bytes now on disk. The copy must be rejected, never // committed to `{wrong_hash}.ext`. let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"the actual bytes on disk"); let wrong_hash = hex::encode(Sha256::digest(b"what we hashed earlier")); let result = store.import_hashed( &src, &crate::SampleHash::from_trusted(wrong_hash.clone()), 24, &db, ); assert!( matches!(result, Err(CoreError::HashMismatch(_))), "expected HashMismatch, got: {result:?}" ); // No blob may exist at the wrong content address, and no row inserted. assert!( !store .sample_path(&crate::SampleHash::from_trusted(wrong_hash.clone()), "wav") .unwrap() .exists() ); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); // And no temp file leaked in the store directory. let leaked = fs::read_dir(store.root()) .into_iter() .flatten() .flatten() .any(|e| e.file_name().to_string_lossy().contains(".tmp")); assert!(!leaked, "a .tmp file leaked after the rejected import"); } #[test] fn import_hashed_accepts_matching_hash() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "clap.wav", b"clap bytes"); let (hash, size) = hash_file(&src).unwrap(); store .import_hashed( &src, &crate::SampleHash::from_trusted(hash.clone()), size, &db, ) .unwrap(); assert!( store .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn scrub_passes_a_clean_store() { let (dir, db, store) = setup(); let a = store .import(&create_test_file(&dir, "a.wav", b"aaaa"), &db) .unwrap(); let b = store .import(&create_test_file(&dir, "b.wav", b"bbbb"), &db) .unwrap(); assert_ne!(a, b); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 2); assert!(corrupt.is_empty()); } #[test] fn scrub_reports_a_corrupt_blob() { let (dir, db, store) = setup(); store .import(&create_test_file(&dir, "good.wav", b"good"), &db) .unwrap(); let bad = store .import(&create_test_file(&dir, "bad.wav", b"original"), &db) .unwrap(); // Corrupt the stored blob in place (clear read-only first, as the store // marks canonical blobs read-only). let path = store .sample_path(&crate::SampleHash::from_trusted(bad.clone()), "wav") .unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&path, perms).unwrap(); fs::write(&path, b"tampered").unwrap(); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 2); assert_eq!(corrupt, vec![bad]); } #[test] fn scrub_flags_a_missing_blob_as_corrupt() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "gone.wav", b"here now"), &db) .unwrap(); let path = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&path, perms).unwrap(); fs::remove_file(&path).unwrap(); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 1); assert_eq!(corrupt, vec![hash]); } #[test] fn import_rejects_zero_byte_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "empty.wav", b""); let result = store.import(&src, &db); assert!(result.is_err()); let err_msg = format!("{}", result.unwrap_err()); assert!( err_msg.contains("zero-byte"), "expected zero-byte error, got: {err_msg}" ); // No row should have been inserted let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn import_accepts_non_empty_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "valid.wav", b"audio content"); let hash = store.import(&src, &db).unwrap(); assert!(!hash.is_empty()); assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn remove_tolerates_missing_file() { // If the blob has already been deleted out from under us (manual rm, // crash mid-remove, etc.), the DB row should still be cleaned up. let (dir, db, store) = setup(); let src = create_test_file(&dir, "ghost.wav", b"ghost data"); let hash = store.import(&src, &db).unwrap(); let stored = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); fs::remove_file(&stored).unwrap(); store .remove(&crate::SampleHash::from_trusted(hash.clone()), &db) .unwrap(); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn remove_deletes_file_before_db_row() { // Verify that after remove(), both the DB row and the file are gone. // The ordering guarantee (file first, then DB row) means a dangling DB // row is the only possible failure mode, never an orphaned blob. let (dir, db, store) = setup(); let src = create_test_file(&dir, "tom.wav", b"tom data"); let hash = store.import(&src, &db).unwrap(); let stored_path = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); assert!(stored_path.exists()); store .remove(&crate::SampleHash::from_trusted(hash.clone()), &db) .unwrap(); // DB row gone let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); // File gone assert!(!stored_path.exists()); } #[test] fn remove_orphaned_samples_cleans_unreferenced() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"kick data"); let src2 = create_test_file(&dir, "snare.wav", b"snare data"); let hash1 = store.import(&src1, &db).unwrap(); let hash2 = store.import(&src2, &db).unwrap(); // Create a VFS and link only hash1 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link( &db, vfs_id, None, "kick.wav", &crate::SampleHash::from_trusted(hash1.clone()), ) .unwrap(); // hash2 is orphaned (no VFS node), hash1 is referenced let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); // hash1 still exists, hash2 is gone assert!( store .exists(&crate::SampleHash::from_trusted(hash1.clone()), "wav") .unwrap() ); assert!( !store .exists(&crate::SampleHash::from_trusted(hash2.clone()), "wav") .unwrap() ); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn imported_blob_is_read_only() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"kick data"); let hash = store.import(&src, &db).unwrap(); let path = store .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap(); assert!( fs::metadata(&path).unwrap().permissions().readonly(), "store blobs must be read-only so a mirror write-through fails loudly" ); } #[test] fn orphan_cleanup_does_not_push_sync_delete() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"kick data"); let src2 = create_test_file(&dir, "snare.wav", b"snare data"); let hash1 = store.import(&src1, &db).unwrap(); let _hash2 = store.import(&src2, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link( &db, vfs_id, None, "kick.wav", &crate::SampleHash::from_trusted(hash1.clone()), ) .unwrap(); // Clear anything import/link logged, then GC the orphan (hash2). db.conn().execute("DELETE FROM sync_changelog", []).unwrap(); let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); // Local GC must never push a destructive `samples` DELETE: that would // cascade-wipe another device's placements of the same blob. let pushed: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'samples' AND op = 'DELETE'", [], |r| r.get(0), ) .unwrap(); assert_eq!( pushed, 0, "orphan cleanup must be local-only (sync suppressed)" ); // And the applying_remote flag is left cleared. let flag: String = db .conn() .query_row( "SELECT value FROM sync_state WHERE key = 'applying_remote'", [], |r| r.get(0), ) .unwrap(); assert_eq!(flag, "0"); } #[test] fn remove_orphaned_samples_keeps_referenced() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "hat.wav", b"hat data"); let hash = store.import(&src, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link( &db, vfs_id, None, "hat.wav", &crate::SampleHash::from_trusted(hash.clone()), ) .unwrap(); let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 0); assert!( store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } #[test] fn remove_orphaned_after_vfs_delete() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "clap.wav", b"clap data"); let hash = store.import(&src, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link( &db, vfs_id, None, "clap.wav", &crate::SampleHash::from_trusted(hash.clone()), ) .unwrap(); // Delete the VFS (cascades to vfs_nodes) crate::vfs::delete_vfs(&db, vfs_id).unwrap(); // Sample is now orphaned let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); assert!( !store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); } // --- Loose-files mode tests --- #[test] fn import_loose_files_does_not_copy_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe kick data"); let hash = store.import_loose_files(&src, &db).unwrap(); // No file in the store assert!( !store .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") .unwrap() ); // Row exists in DB with source_path set let sp: Option = db .conn() .query_row( "SELECT source_path FROM samples WHERE hash = ?1", [&hash], |row| row.get(0), ) .unwrap(); assert!(sp.is_some()); assert!(sp.unwrap().ends_with("kick.wav")); } #[test] fn import_loose_files_deduplicates() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"same unsafe content"); let hash1 = store.import_loose_files(&src, &db).unwrap(); let hash2 = store.import_loose_files(&src, &db).unwrap(); assert_eq!(hash1, hash2); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn sample_source_path_returns_none_for_normal() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"normal import"); let hash = store.import(&src, &db).unwrap(); assert!(sample_source_path(&db, &hash).unwrap().is_none()); } #[test] fn sample_source_path_returns_path_for_loose_files() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe import"); let hash = store.import_loose_files(&src, &db).unwrap(); let sp = sample_source_path(&db, &hash).unwrap(); assert!(sp.is_some()); } #[test] fn resolve_file_path_prefers_source_path() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test"); let hash = store.import_loose_files(&src, &db).unwrap(); let resolved = resolve_file_path( &store, &db, &crate::SampleHash::from_trusted(hash.clone()), "wav", ) .unwrap(); // Should resolve to the original file, not the store assert!(!resolved.starts_with(store.root())); } #[test] fn resolve_file_path_falls_back_to_store() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fallback test"); // Import normally (file exists in store) let hash = store.import(&src, &db).unwrap(); let resolved = resolve_file_path( &store, &db, &crate::SampleHash::from_trusted(hash.clone()), "wav", ) .unwrap(); assert!(resolved.starts_with(store.root())); } #[test] fn relocate_sample_rejects_hash_mismatch() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"original content"); let hash = store.import_loose_files(&src, &db).unwrap(); let wrong_file = create_test_file(&dir, "snare.wav", b"different content"); let result = relocate_sample(&store, &db, &hash, &wrong_file); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("hash mismatch")); } #[test] fn relocate_sample_updates_source_path() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"relocate content"); let hash = store.import_loose_files(&src, &db).unwrap(); // Move the file let new_loc = dir.path().join("moved_kick.wav"); fs::copy(&src, &new_loc).unwrap(); relocate_sample(&store, &db, &hash, &new_loc).unwrap(); let sp = sample_source_path(&db, &hash).unwrap().unwrap(); assert!(sp.contains("moved_kick.wav")); } #[test] fn check_loose_files_integrity_counts_correctly() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"integrity kick"); let src2 = create_test_file(&dir, "snare.wav", b"integrity snare"); store.import_loose_files(&src1, &db).unwrap(); let hash2 = store.import_loose_files(&src2, &db).unwrap(); // Delete snare from disk to simulate missing file let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); fs::remove_file(&sp).unwrap(); let (valid, missing) = check_loose_files_integrity(&db).unwrap(); assert_eq!(valid, 1); assert_eq!(missing, 1); } #[test] fn purge_missing_loose_files_removes_only_missing() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"purge kick"); let src2 = create_test_file(&dir, "snare.wav", b"purge snare"); let hash1 = store.import_loose_files(&src1, &db).unwrap(); let hash2 = store.import_loose_files(&src2, &db).unwrap(); // Delete snare from disk let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); fs::remove_file(&sp).unwrap(); let purged = purge_missing_loose_files(&db).unwrap(); assert_eq!(purged, 1); // kick still exists, snare is gone assert!(sample_source_path(&db, &hash1).is_ok()); assert!(matches!( sample_source_path(&db, &hash2), Err(CoreError::SampleNotFound(_)) )); } #[test] fn purge_missing_loose_files_noop_when_all_valid() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"all valid"); store.import_loose_files(&src, &db).unwrap(); let purged = purge_missing_loose_files(&db).unwrap(); assert_eq!(purged, 0); } #[test] fn relocate_missing_finds_moved_file_by_hash() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"moved-away content"); let hash = store.import_loose_files(&src, &db).unwrap(); // Move the source into a subdirectory and delete the original path. let subdir = dir.path().join("relocated"); fs::create_dir_all(&subdir).unwrap(); let moved = subdir.join("kick.wav"); fs::rename(&src, &moved).unwrap(); assert_eq!(check_loose_files_integrity(&db).unwrap(), (0, 1)); let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap(); assert_eq!(relocated, 1); assert_eq!(still_missing, 0); // source_path now points at the moved file, and integrity is restored. let sp = sample_source_path(&db, &hash).unwrap().unwrap(); assert!(sp.contains("relocated")); assert_eq!(check_loose_files_integrity(&db).unwrap(), (1, 0)); } #[test] fn relocate_missing_reports_still_missing_when_absent() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "ghost.wav", b"gone forever"); store.import_loose_files(&src, &db).unwrap(); fs::remove_file(&src).unwrap(); // Search a fresh empty directory, nothing to find. let empty = dir.path().join("empty"); fs::create_dir_all(&empty).unwrap(); let (relocated, still_missing) = relocate_missing_loose_files(&db, &empty).unwrap(); assert_eq!(relocated, 0); assert_eq!(still_missing, 1); } #[test] fn relocate_missing_ignores_same_name_different_content() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"the real bytes"); store.import_loose_files(&src, &db).unwrap(); fs::remove_file(&src).unwrap(); // A decoy with the same basename but different content must NOT match // (hash verify guards against same-name collisions). let decoy_dir = dir.path().join("decoy"); fs::create_dir_all(&decoy_dir).unwrap(); fs::write(decoy_dir.join("kick.wav"), b"an impostor with other bytes").unwrap(); let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap(); assert_eq!(relocated, 0); assert_eq!(still_missing, 1); }