563 lines
18 KiB
Rust
563 lines
18 KiB
Rust
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<MusicMetadata>) -> 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<u64> = 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());
|
|
}
|