use std::{ collections::BTreeMap, sync::{Arc, Mutex}, time::SystemTime, }; use fuser::INodeNo; use musicfs::control::ClientControlServiceImpl; use musicfs::db::entities; use musicfs::item::{FileType, Item}; use musicfs::music::db::entities::{artists, music_metadata as mm_entity}; use musicfs::music::metadata::MusicMetadata; use musicfs::origins::attrs::FileAttrs; use musicfs_proto::{ ClientControl, GetMusicMetadataRequest, ListFilesRequest, UpdateMusicMetadataRequest, }; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; use tonic::{Code, Request}; // ── Test helpers ─────────────────────────────────────────────────────── fn make_attrs() -> FileAttrs { FileAttrs { size: 4096, blocks: 8, atime: SystemTime::UNIX_EPOCH, mtime: SystemTime::UNIX_EPOCH, ctime: SystemTime::UNIX_EPOCH, crtime: SystemTime::UNIX_EPOCH, perm: 0o644, nlink: 1, uid: 0, gid: 0, rdev: 0, blksize: 4096, } } fn make_flac_item(inode: u64, name: &str, metadata: Option) -> Item { Item { inode: INodeNo(inode), parent_inode: INodeNo(1), name: name.to_string(), original_path: format!("/{name}").into(), local_path: format!("Artist/Album/{name}").into(), file_type: FileType::File, attrs: make_attrs(), music_metadata: metadata, hash: inode, } } fn make_flac_metadata() -> MusicMetadata { MusicMetadata { artist: vec!["Test Artist".to_string()], album_artist: Some("Test Artist".to_string()), album: "Test Album".to_string(), track_number: 1, track_title: "Test Track".to_string(), other_tags: vec![], header: vec![], picture_block_headers: vec![], picture_data_ranges: vec![], real_audio_start: 0, vorbis_comment_offset: 0, vorbis_comment_length: 0, } } /// Mock DB for get tests (no INSERT operations expected). fn mock_db_readonly() -> sea_orm::DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres).into_connection() } /// Mock DB for update with one artist. /// save_music_metadata does: DELETE, INSERT music_metadata, INSERT artists. /// persist_layout_changes does: UPDATE items (RETURNING), then one DELETE /// per orphaned virtual directory (worst case = 2: artist + album dirs). fn mock_db_update_with_artist(inode: i64) -> sea_orm::DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .append_query_results([vec![mm_entity::Model { inode, track_title: String::new(), album: String::new(), track_number: 0, header: vec![], real_audio_start: 0, }]]) .append_query_results([vec![artists::Model { inode, artist: String::new(), }]]) .append_query_results([vec![entities::Model { inode, name: String::new(), original_path: String::new(), local_path: String::new(), file_type: "file".to_string(), hash: 0, }]]) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .into_connection() } /// Mock DB for update with no artists. /// save_music_metadata does: DELETE, INSERT music_metadata only. /// persist_layout_changes does: UPDATE items (RETURNING), then up to two /// DELETEs for orphaned virtual directories. fn mock_db_update_no_artist(inode: i64) -> sea_orm::DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .append_query_results([vec![mm_entity::Model { inode, track_title: String::new(), album: String::new(), track_number: 0, header: vec![], real_audio_start: 0, }]]) .append_query_results([vec![entities::Model { inode, name: String::new(), original_path: String::new(), local_path: String::new(), file_type: "file".to_string(), hash: 0, }]]) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .append_exec_results([MockExecResult { rows_affected: 1, ..Default::default() }]) .into_connection() } fn make_dir_item(inode: u64, name: &str) -> Item { Item { inode: INodeNo(inode), parent_inode: INodeNo(1), name: name.to_string(), original_path: format!("/{name}").into(), local_path: name.into(), file_type: FileType::Directory, attrs: make_attrs(), music_metadata: None, hash: inode, } } #[tokio::test] async fn update_removes_orphaned_virtual_dirs_when_last_file_moves_out() { let source_dir = tempfile::tempdir().unwrap(); let source = source_dir.path(); let mut files = BTreeMap::new(); files.insert( INodeNo(0), Item { inode: INodeNo(0), parent_inode: INodeNo::ROOT, name: "/".to_string(), original_path: source.to_path_buf(), local_path: "/".into(), file_type: FileType::Directory, attrs: make_attrs(), music_metadata: None, hash: 0, }, ); files.insert( INodeNo(100), make_flac_item(100, "song.flac", Some(make_flac_metadata())), ); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_with_artist(100)); // Pre-create the virtual dirs for the file's current layout so we can // assert they get pruned after the metadata update moves the file. { use musicfs::virtual_dirs::{ensure_virtual_dirs, virtual_inode}; let mut guard = files.lock().unwrap(); ensure_virtual_dirs( std::path::Path::new("Artist/Album/song.flac"), source, &mut guard, ); assert!(guard.contains_key(&virtual_inode("Artist"))); assert!(guard.contains_key(&virtual_inode("Artist/Album"))); } svc.update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 100, artist: vec!["New Artist".to_string()], album_artist: Some("New Artist".to_string()), album: "New Album".to_string(), track_number: 1, track_title: "New Title".to_string(), other_tags: vec![], })) .await .expect("update should succeed"); let guard = files.lock().unwrap(); use musicfs::virtual_dirs::virtual_inode; assert!( !guard.contains_key(&virtual_inode("Artist")), "old Artist dir must be removed when no files remain under it" ); assert!( !guard.contains_key(&virtual_inode("Artist/Album")), "old Album dir must be removed" ); assert!(guard.contains_key(&virtual_inode("New Artist"))); assert!(guard.contains_key(&virtual_inode("New Artist/New Album"))); let item = guard.get(&INodeNo(100)).expect("file still present"); assert_eq!( item.local_path, std::path::PathBuf::from("New Artist/New Album/01 - New Title.flac") ); } #[tokio::test] async fn update_keeps_shared_artist_dir_when_other_album_remains() { let source_dir = tempfile::tempdir().unwrap(); let source = source_dir.path(); let mut files = BTreeMap::new(); files.insert( INodeNo(0), Item { inode: INodeNo(0), parent_inode: INodeNo::ROOT, name: "/".to_string(), original_path: source.to_path_buf(), local_path: "/".into(), file_type: FileType::Directory, attrs: make_attrs(), music_metadata: None, hash: 0, }, ); files.insert( INodeNo(100), make_flac_item(100, "a.flac", Some(make_flac_metadata())), ); files.insert( INodeNo(101), make_flac_item(101, "b.flac", Some(make_flac_metadata())), ); files.get_mut(&INodeNo(101)).unwrap().local_path = "Artist/Other Album/b.flac".into(); // Pre-create virtual dirs for both files' layouts: pruning after the // update should remove Album/ (file 100's old parent) but keep Artist/ // (still has Other Album/ with file 101 in it). { use musicfs::virtual_dirs::ensure_virtual_dirs; ensure_virtual_dirs( std::path::Path::new("Artist/Album/a.flac"), source, &mut files, ); ensure_virtual_dirs( std::path::Path::new("Artist/Other Album/b.flac"), source, &mut files, ); } let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_with_artist(100)); svc.update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 100, artist: vec!["New Artist".to_string()], album_artist: Some("New Artist".to_string()), album: "New Album".to_string(), track_number: 1, track_title: "New Title".to_string(), other_tags: vec![], })) .await .expect("update should succeed"); let guard = files.lock().unwrap(); use musicfs::virtual_dirs::virtual_inode; assert!( guard.contains_key(&virtual_inode("Artist")), "Artist dir must remain while Other Album still has files" ); assert!( !guard.contains_key(&virtual_inode("Artist/Album")), "Artist/Album dir must be removed" ); } // ── ListFiles ───────────────────────────────────────────────────────── #[tokio::test] async fn list_files_returns_all_files_with_full_field_projection() { let mut files = BTreeMap::new(); files.insert( INodeNo(100), make_flac_item(100, "a.flac", Some(make_flac_metadata())), ); files.insert(INodeNo(101), make_flac_item(101, "b.flac", None)); // Directory entries must be filtered out. files.insert(INodeNo(2), make_dir_item(2, "Artist")); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let resp = svc .list_files(Request::new(ListFilesRequest {})) .await .expect("list should succeed"); let mut entries = resp.into_inner().files; assert_eq!(entries.len(), 2, "directories must be filtered out"); entries.sort_by_key(|e| e.inode); assert_eq!(entries[0].inode, 100); assert_eq!(entries[0].name, "a.flac"); assert_eq!(entries[0].original_path, "/a.flac"); assert_eq!(entries[0].local_path, "Artist/Album/a.flac"); assert!(entries[0].metadata.is_some()); assert_eq!(entries[1].inode, 101); assert_eq!(entries[1].name, "b.flac"); assert!(entries[1].metadata.is_none(), "no metadata on this file"); } #[tokio::test] async fn list_files_empty_map_returns_empty_list() { let files = Arc::new(Mutex::new(BTreeMap::new())); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let resp = svc .list_files(Request::new(ListFilesRequest {})) .await .expect("list should succeed"); assert!(resp.into_inner().files.is_empty()); } #[tokio::test] async fn list_files_skips_directories_and_root() { let mut files = BTreeMap::new(); files.insert(INodeNo(0), make_dir_item(0, "/")); // root files.insert(INodeNo(1), make_dir_item(1, "Artist")); files.insert(INodeNo(2), make_dir_item(2, "Album")); files.insert(INodeNo(100), make_flac_item(100, "track.flac", None)); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let resp = svc .list_files(Request::new(ListFilesRequest {})) .await .expect("list should succeed"); let inodes: Vec = resp .into_inner() .files .into_iter() .map(|e| e.inode) .collect(); assert_eq!(inodes, vec![100]); } #[tokio::test] async fn get_returns_metadata_for_valid_inode() { let mut files = BTreeMap::new(); files.insert( INodeNo(100), make_flac_item(100, "song.flac", Some(make_flac_metadata())), ); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let resp = svc .get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 100 })) .await .expect("get should succeed"); let md = resp.into_inner().metadata.expect("metadata present"); assert_eq!(md.track_title, "Test Track"); assert_eq!(md.album, "Test Album"); assert_eq!(md.artist, vec!["Test Artist".to_string()]); assert_eq!(md.track_number, 1); } #[tokio::test] async fn get_returns_not_found_for_missing_inode() { let files = Arc::new(Mutex::new(BTreeMap::new())); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let err = svc .get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 999 })) .await .expect_err("should be NOT_FOUND"); assert_eq!(err.code(), Code::NotFound); } #[tokio::test] async fn get_returns_not_found_when_item_has_no_metadata() { let mut files = BTreeMap::new(); files.insert(INodeNo(100), make_flac_item(100, "song.flac", None)); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let err = svc .get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 100 })) .await .expect_err("should be NOT_FOUND"); assert_eq!(err.code(), Code::NotFound); } // ── UpdateMusicMetadata ─────────────────────────────────────────────── #[tokio::test] async fn update_changes_tags_and_response_reflects_new_values() { let mut files = BTreeMap::new(); files.insert( INodeNo(100), make_flac_item(100, "song.flac", Some(make_flac_metadata())), ); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_with_artist(100)); let resp = svc .update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 100, artist: vec!["Updated Artist".to_string()], album_artist: Some("Updated Artist".to_string()), album: "Updated Album".to_string(), track_number: 7, track_title: "Updated Title".to_string(), other_tags: vec![], })) .await .expect("update should succeed"); let md = resp.into_inner().metadata.expect("metadata present"); assert_eq!(md.track_title, "Updated Title"); assert_eq!(md.album, "Updated Album"); assert_eq!(md.artist, vec!["Updated Artist".to_string()]); assert_eq!(md.track_number, 7); let guard = files.lock().unwrap(); let item = guard.get(&INodeNo(100)).expect("item still in map"); let mm = item.music_metadata.as_ref().expect("metadata present"); assert_eq!(mm.track_title, "Updated Title"); assert_eq!(mm.album, "Updated Album"); assert_eq!(mm.artist, vec!["Updated Artist".to_string()]); assert_eq!(mm.track_number, 7); // Layout was recomputed from the new metadata. assert_eq!(item.name, "07 - Updated Title.flac"); assert_eq!( item.local_path, std::path::PathBuf::from("Updated Artist/Updated Album/07 - Updated Title.flac") ); } #[tokio::test] async fn update_returns_not_found_for_missing_inode() { let files = Arc::new(Mutex::new(BTreeMap::new())); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let err = svc .update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 999, artist: vec![], album_artist: None, album: String::new(), track_number: 0, track_title: String::new(), other_tags: vec![], })) .await .expect_err("should be NOT_FOUND"); assert_eq!(err.code(), Code::NotFound); } #[tokio::test] async fn update_returns_not_found_when_item_has_no_metadata() { let mut files = BTreeMap::new(); files.insert(INodeNo(100), make_flac_item(100, "song.flac", None)); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files, mock_db_readonly()); let err = svc .update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 100, artist: vec!["X".to_string()], album_artist: None, album: "X".to_string(), track_number: 1, track_title: "X".to_string(), other_tags: vec![], })) .await .expect_err("should be NOT_FOUND"); assert_eq!(err.code(), Code::NotFound); } #[tokio::test] async fn update_clears_tags_when_empty_values_sent() { let mut files = BTreeMap::new(); files.insert( INodeNo(100), make_flac_item(100, "song.flac", Some(make_flac_metadata())), ); let files = Arc::new(Mutex::new(files)); let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_no_artist(100)); let resp = svc .update_music_metadata(Request::new(UpdateMusicMetadataRequest { inode: 100, artist: vec![], album_artist: None, album: String::new(), track_number: 0, track_title: String::new(), other_tags: vec![], })) .await .expect("update should succeed"); let md = resp.into_inner().metadata.expect("metadata present"); assert!(md.artist.is_empty()); assert!(md.album.is_empty()); assert!(md.track_title.is_empty()); let guard = files.lock().unwrap(); let mm = guard .get(&INodeNo(100)) .unwrap() .music_metadata .as_ref() .unwrap(); assert!(mm.artist.is_empty()); assert!(mm.album.is_empty()); }