use mineral_model::{ArtistId, ArtistRef, Song, SongId, SourceKind};
#[tokio::test]
async fn batch_reads_requested_metadata_with_ordered_artists() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let store = crate::persistence::ServerStore::open(&dir.path().join("batch.db")).await?;
let scope = store.scope(SourceKind::NETEASE);
let songs = (0..450)
.map(|index| {
mineral_test::with_artists(mineral_test::song(&index.to_string()), &["主唱", "客串"])
})
.collect::<Vec<_>>();
scope
.upsert_meta_batch(&songs.iter().collect::<Vec<_>>())
.await?;
scope
.upsert_meta(&mineral_test::song("unrequested"))
.await?;
store
.scope(SourceKind::BILIBILI)
.upsert_meta(&mineral_test::with_name(
mineral_test::with_source(mineral_test::song("0"), SourceKind::BILIBILI),
"另一个来源",
))
.await?;
let mut ids = songs.iter().map(|song| song.id.clone()).collect::<Vec<_>>();
ids.push(SongId::new(SourceKind::NETEASE, "0"));
ids.push(SongId::new(SourceKind::NETEASE, "missing"));
let got = scope.get_meta_batch(&ids).await?;
assert_eq!(got.len(), songs.len());
for song in &songs {
assert_eq!(got.get(&song.id), Some(song), "艺人及其顺序和来源都应保持");
}
assert!(scope.get_meta_batch(&[]).await?.is_empty());
assert!(
crate::persistence::ServerStore::disabled()
.scope(SourceKind::NETEASE)
.get_meta_batch(&ids)
.await?
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn batch_duplicate_songs_keep_latest_nonempty_fields() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let store = crate::persistence::ServerStore::open(&dir.path().join("batch.db")).await?;
let scope = store.scope(SourceKind::NETEASE);
let rich = mineral_test::with_artists(
mineral_test::with_alias(mineral_test::song("1"), "译名"),
&["旧艺人"],
);
let revised = mineral_test::with_artists(mineral_test::song("1"), &["主唱", "客串"]);
let poor = mineral_test::with_name(mineral_test::song("1"), "新歌名");
scope.upsert_meta_batch(&[&rich, &revised, &poor]).await?;
let expected = mineral_test::with_name(mineral_test::with_alias(revised, "译名"), "新歌名");
assert_eq!(scope.get_meta(&rich.id).await?, Some(expected));
Ok(())
}
#[tokio::test]
async fn failed_metadata_batch_rolls_back_earlier_rows() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let store = crate::persistence::ServerStore::open(&dir.path().join("batch.db")).await?;
let scope = store.scope(SourceKind::NETEASE);
let songs = (0..120)
.map(|index| mineral_test::song(&index.to_string()))
.collect::<Vec<_>>();
let invalid = mineral_test::with_duration(mineral_test::song("invalid"), u64::MAX);
let mut batch = songs.iter().collect::<Vec<_>>();
batch.push(&invalid);
assert!(
matches!(scope.upsert_meta_batch(&batch).await, Err(crate::persistence::Error::Duration { song, .. }) if song == invalid.id.qualified())
);
assert!(scope.list_meta().await?.is_empty());
Ok(())
}
#[tokio::test]
async fn list_meta_returns_all_with_ordered_artists() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
let mk = |v: &str, name: &str, artists: Vec<ArtistRef>| {
Song::builder()
.id(SongId::new(SourceKind::NETEASE, v))
.name(name.to_owned())
.artists(artists)
.build()
};
let a = |id: &str, name: &str| ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, id),
name: name.to_owned(),
};
s.upsert_meta(&mk("1", "晴天", vec![a("j", "周杰伦"), a("x", "第二艺人")]))
.await?;
s.upsert_meta(&mk("2", "无艺人歌", Vec::new())).await?;
p.scope(SourceKind::BILIBILI)
.upsert_meta(
&Song::builder()
.id(SongId::new(SourceKind::BILIBILI, "BV1"))
.name("B站歌".to_owned())
.build(),
)
.await?;
let all = s.list_meta().await?;
assert_eq!(all.len(), 2, "本 namespace 应只有两首: {all:?}");
let Some(one) = all.iter().find(|x| x.id.value() == "1") else {
return Err(color_eyre::eyre::eyre!("应含 id=1"));
};
let names = one
.artists
.iter()
.map(|x| x.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["周杰伦", "第二艺人"], "艺人应保序");
Ok(())
}
#[tokio::test]
async fn upsert_meta_null_fields_do_not_regress() -> color_eyre::Result<()> {
use mineral_model::{AlbumId, AlbumRef, MediaUrl};
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
let id = SongId::new(SourceKind::NETEASE, "42");
let rich = Song::builder()
.id(id.clone())
.name("ButterFly".to_owned())
.alias(Some("黄油飞".to_owned()))
.artists(vec![ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "a1"),
name: "和田光司".to_owned(),
}])
.album(Some(AlbumRef {
id: AlbumId::new(SourceKind::NETEASE, "al1"),
name: "数码宝贝".to_owned(),
}))
.duration_ms(Some(259_000))
.cover_url(Some(MediaUrl::remote("https://p1.example/c.jpg")?))
.build();
s.upsert_meta(&rich).await?;
let poor = Song::builder()
.id(id.clone())
.name("Butter-Fly".to_owned())
.build();
s.upsert_meta(&poor).await?;
let got = s
.get_meta(&id)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("应命中 meta"))?;
assert_eq!(got.name, "Butter-Fly", "name 恒以新值为准");
assert_eq!(
got.alias.as_deref(),
Some("黄油飞"),
"alias 不得被 NULL 回退"
);
assert_eq!(got.duration_ms, Some(259_000), "duration 不得被 NULL 回退");
assert!(got.cover_url.is_some(), "cover 不得被 NULL 回退");
assert!(got.album.is_some(), "album 不得被 NULL 回退");
assert_eq!(got.artists.len(), 1, "空艺人列表应保留已存行");
let newer = Song::builder()
.id(id.clone())
.name("Butter-Fly".to_owned())
.alias(Some("黄油飞(数码宝贝OP)".to_owned()))
.duration_ms(Some(260_000))
.build();
s.upsert_meta(&newer).await?;
let got2 = s
.get_meta(&id)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("应命中 meta"))?;
assert_eq!(got2.alias.as_deref(), Some("黄油飞(数码宝贝OP)"));
assert_eq!(got2.duration_ms, Some(260_000));
Ok(())
}
#[tokio::test]
async fn upsert_meta_preserves_artist_order_and_clears_stale() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
let id = SongId::new(SourceKind::NETEASE, "777");
let song = Song::builder()
.id(id.clone())
.name("多人合作".to_owned())
.artists(vec![
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "a1"),
name: "甲".to_owned(),
},
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "a2"),
name: "乙".to_owned(),
},
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "a3"),
name: "丙".to_owned(),
},
])
.duration_ms(Some(123_000))
.build();
s.upsert_meta(&song).await?;
let got = s.get_meta(&id).await?;
assert!(got.is_some());
if let Some(g) = got {
let names = g
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<String>>();
assert_eq!(
names,
vec!["甲".to_owned(), "乙".to_owned(), "丙".to_owned()]
);
}
let updated = Song::builder()
.id(id.clone())
.name("改为单人".to_owned())
.artists(vec![ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "b1"),
name: "丁".to_owned(),
}])
.duration_ms(Some(99_000))
.build();
s.upsert_meta(&updated).await?;
let got2 = s.get_meta(&id).await?;
assert!(got2.is_some());
if let Some(g) = got2 {
assert_eq!(g.name, "改为单人");
let names = g
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<String>>();
assert_eq!(names, vec!["丁".to_owned()]);
}
Ok(())
}
#[tokio::test]
async fn get_meta_miss_returns_none() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
assert!(
s.get_meta(&SongId::new(SourceKind::NETEASE, "nope"))
.await?
.is_none()
);
Ok(())
}