Files
index/tests/integration.rs
Connor Johnstone 884acfcd18 Initial commit
2026-03-17 14:32:52 -04:00

192 lines
5.7 KiB
Rust

use std::fs;
use std::io::Write;
use lofty::tag::TagExt;
use shanty_db::{Database, queries};
use shanty_index::{ScanConfig, run_scan};
use tempfile::TempDir;
/// Create a minimal valid MP3 file with ID3v2 tags using lofty.
fn create_test_mp3(dir: &std::path::Path, filename: &str, title: &str, artist: &str, album: &str) {
use lofty::config::WriteOptions;
use lofty::tag::{Accessor, ItemKey, Tag, TagType};
let path = dir.join(filename);
// Write a minimal valid MPEG frame (silence, ~0.026s at 128kbps)
// MPEG1 Layer 3, 128kbps, 44100Hz, stereo frame header + padding
let frame_header: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00];
let frame_size = 417; // standard frame size for 128kbps/44100Hz
let mut frame_data = vec![0u8; frame_size];
frame_data[..4].copy_from_slice(&frame_header);
// Write a few frames so lofty recognizes it as valid audio
let mut file = fs::File::create(&path).unwrap();
for _ in 0..10 {
file.write_all(&frame_data).unwrap();
}
drop(file);
// Now write tags using lofty
let mut tag = Tag::new(TagType::Id3v2);
tag.set_title(title.to_string());
tag.set_artist(artist.to_string());
tag.set_album(album.to_string());
tag.set_track(1);
tag.set_disk(1);
tag.set_year(2024);
tag.set_genre("Rock".to_string());
tag.insert(lofty::tag::TagItem::new(
ItemKey::AlbumArtist,
lofty::tag::ItemValue::Text(artist.to_string()),
));
tag.save_to_path(&path, WriteOptions::default()).unwrap();
}
async fn test_db() -> Database {
Database::new("sqlite::memory:")
.await
.expect("failed to create test database")
}
#[tokio::test]
async fn test_scan_indexes_music_files() {
let db = test_db().await;
let dir = TempDir::new().unwrap();
create_test_mp3(dir.path(), "song1.mp3", "Time", "Pink Floyd", "DSOTM");
create_test_mp3(dir.path(), "song2.mp3", "Money", "Pink Floyd", "DSOTM");
let config = ScanConfig {
root: dir.path().to_owned(),
dry_run: false,
concurrency: 2,
};
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_found, 2);
assert_eq!(stats.files_indexed, 2);
assert_eq!(stats.files_errored, 0);
// Verify tracks in DB
let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap();
assert_eq!(tracks.len(), 2);
// Verify artist was created
let artist = queries::artists::find_by_name(db.conn(), "Pink Floyd")
.await
.unwrap();
assert!(artist.is_some());
// Verify album was created and linked to artist
let albums = queries::albums::get_by_artist(db.conn(), artist.unwrap().id)
.await
.unwrap();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].name, "DSOTM");
}
#[tokio::test]
async fn test_incremental_scan_skips_unchanged() {
let db = test_db().await;
let dir = TempDir::new().unwrap();
create_test_mp3(dir.path(), "song.mp3", "Time", "Pink Floyd", "DSOTM");
let config = ScanConfig {
root: dir.path().to_owned(),
dry_run: false,
concurrency: 1,
};
// First scan
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_indexed, 1);
assert_eq!(stats.files_skipped, 0);
// Second scan — should skip since mtime unchanged
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_indexed, 0);
assert_eq!(stats.files_skipped, 1);
}
#[tokio::test]
async fn test_dry_run_does_not_write() {
let db = test_db().await;
let dir = TempDir::new().unwrap();
create_test_mp3(dir.path(), "song.mp3", "Time", "Pink Floyd", "DSOTM");
let config = ScanConfig {
root: dir.path().to_owned(),
dry_run: true,
concurrency: 1,
};
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_found, 1);
assert_eq!(stats.files_indexed, 1); // counted as indexed in dry-run
// But DB should be empty
let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap();
assert!(tracks.is_empty());
}
#[tokio::test]
async fn test_partial_metadata_still_indexed() {
let db = test_db().await;
let dir = TempDir::new().unwrap();
// Create a file with minimal valid audio but no tags
let frame_header: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00];
let frame_size = 417;
let mut frame_data = vec![0u8; frame_size];
frame_data[..4].copy_from_slice(&frame_header);
let path = dir.path().join("untagged.mp3");
let mut file = fs::File::create(&path).unwrap();
for _ in 0..10 {
file.write_all(&frame_data).unwrap();
}
drop(file);
let config = ScanConfig {
root: dir.path().to_owned(),
dry_run: false,
concurrency: 1,
};
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_found, 1);
assert_eq!(stats.files_indexed, 1);
// Track should exist but with NULL metadata
let tracks = queries::tracks::list(db.conn(), 100, 0).await.unwrap();
assert_eq!(tracks.len(), 1);
assert!(tracks[0].title.is_none());
assert!(tracks[0].artist.is_none());
// But should still have file-level info
assert!(tracks[0].file_size > 0);
assert!(tracks[0].codec.is_some());
}
#[tokio::test]
async fn test_non_music_files_ignored() {
let db = test_db().await;
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("readme.txt"), b"not music").unwrap();
fs::write(dir.path().join("cover.jpg"), b"not music").unwrap();
fs::write(dir.path().join("data.json"), b"{}").unwrap();
let config = ScanConfig {
root: dir.path().to_owned(),
dry_run: false,
concurrency: 1,
};
let stats = run_scan(db.conn(), &config).await.unwrap();
assert_eq!(stats.files_found, 0);
}