Files
frid/crates/music-dht/tests/integration.rs
T
2026-07-16 17:24:43 +03:00

190 lines
7.0 KiB
Rust

//! Integration test: three DHT nodes in one Tokio runtime synchronizing and
//! searching a small music library (artists, releases, tracks).
use std::path::Path;
use std::time::Duration;
use music_dht::{
EndpointId, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtEventReceiver, MusicDhtService,
NetworkId,
};
/// Hard cap on the test so a regression can never hang CI.
const TEST_TIMEOUT: Duration = Duration::from_secs(240);
/// Serializes the network-facing tests: many concurrent endpoints contend on
/// relay discovery and produce spurious timeouts.
static NET_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
type Started = (MusicDhtService, MusicDhtEventReceiver);
async fn start(dir: &Path, network: &str) -> Started {
let config = MusicDhtConfig::builder()
.data_dir(dir)
.network_id(NetworkId::from_name(network))
.republish_interval(Duration::from_secs(5))
.expire_interval(Duration::from_secs(2))
.request_timeout(Duration::from_secs(5))
.lookup_timeout(Duration::from_secs(10))
.build()
.expect("valid config");
MusicDhtService::start(config)
.await
.expect("service starts")
}
fn spec(local_key: &str, kind: ItemKind, name: &str, artists: &[&str]) -> ItemSpec {
ItemSpec {
local_key: local_key.to_string(),
kind,
name: name.to_string(),
artist_names: artists.iter().map(|s| s.to_string()).collect(),
year: Some(1998),
release_type: (kind == ItemKind::Release).then(|| "album".to_string()),
duration_seconds: (kind == ItemKind::Track).then_some(287.0),
}
}
/// Polls `check` until it returns `Some` or the deadline passes.
async fn wait_for<T>(
what: &str,
deadline: Duration,
mut check: impl AsyncFnMut() -> Option<T>,
) -> T {
let started = std::time::Instant::now();
loop {
if let Some(value) = check().await {
return value;
}
assert!(started.elapsed() < deadline, "timed out waiting for {what}");
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
fn knows_peer(service: &MusicDhtService, peer: EndpointId) -> bool {
service
.known_peers()
.iter()
.any(|contact| contact.peer_id == peer)
}
/// Searches repeatedly until `accept` returns true for the results, or the
/// deadline passes (the DHT is eventually consistent and a transiently
/// dropped connection can make a single search come up short).
async fn search_until(
what: &str,
service: &MusicDhtService,
query: &str,
accept: impl Fn(&[music_dht::LibraryItem]) -> bool,
) -> Vec<music_dht::LibraryItem> {
wait_for(what, Duration::from_secs(45), async || {
let outcome = service.search_network(query).await.expect("search");
accept(&outcome.network_results).then_some(outcome.network_results)
})
.await
}
#[tokio::test]
async fn library_sync_and_distributed_search() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let dir_c = tempfile::tempdir().expect("tempdir");
// Bootstrap: B and C join through A's ticket; C learns about B via
// peer exchange.
let (a, _events_a) = start(dir_a.path(), "test-library").await;
let ticket_a = a.ticket().await.expect("ticket");
let (b, _events_b) = start(dir_b.path(), "test-library").await;
b.connect(ticket_a.clone()).await.expect("b connects to a");
let (c, _events_c) = start(dir_c.path(), "test-library").await;
c.connect(ticket_a.clone()).await.expect("c connects to a");
wait_for(
"C to learn about B via peer exchange",
Duration::from_secs(30),
async || knows_peer(&c, b.endpoint_id()).then_some(()),
)
.await;
// B publishes a small library: an artist, their release, one track.
let library = vec![
spec("artist:1", ItemKind::Artist, "Massive Attack", &[]),
spec(
"release:1",
ItemKind::Release,
"Mezzanine",
&["Massive Attack"],
),
spec("track:1", ItemKind::Track, "Teardrop", &["Massive Attack"]),
];
let stats = b.sync_library(library.clone()).await.expect("sync");
assert_eq!(stats.added, 3, "all three items are new");
assert_eq!(stats.failed, 0);
// A immediately-repeated sync publishes nothing.
let stats = b.sync_library(library.clone()).await.expect("sync");
assert_eq!(stats.unchanged, 3, "identical sync must be a no-op");
// A finds the track by its exact title.
let results = search_until("A to find the track", &a, "teardrop", |items| {
items.len() == 1
})
.await;
let track = &results[0];
assert_eq!(track.kind, ItemKind::Track);
assert_eq!(track.name, "Teardrop");
assert_eq!(track.artist_names, vec!["Massive Attack".to_string()]);
assert_eq!(track.duration_seconds, Some(287.0));
assert_eq!(track.owner, b.endpoint_id());
// C searches by the artist's name and finds all three kinds: the
// artist itself plus the release and track carrying its name.
search_until("C to find all three kinds", &c, "massive attack", |items| {
let kinds: Vec<ItemKind> = items.iter().map(|item| item.kind).collect();
[ItemKind::Artist, ItemKind::Release, ItemKind::Track]
.iter()
.all(|kind| kinds.contains(kind))
})
.await;
// A content change is republished with a higher revision.
let mut changed = library.clone();
changed[1].year = Some(1997);
let stats = b.sync_library(changed.clone()).await.expect("sync");
assert_eq!(stats.updated, 1);
assert_eq!(stats.unchanged, 2);
let results = search_until("A to see the updated release", &a, "mezzanine", |items| {
items.len() == 1 && items[0].year == Some(1997)
})
.await;
assert!(results[0].revision >= 2);
// Removing the track from the library tombstones it network-wide.
let mut shrunk = changed.clone();
shrunk.retain(|spec| spec.local_key != "track:1");
let stats = b.sync_library(shrunk).await.expect("sync");
assert_eq!(stats.removed, 1);
search_until("the tombstone to hide the track", &a, "teardrop", |items| {
items.is_empty()
})
.await;
// The rest of the library is still reachable — including from
// replicas after the owner shuts down.
b.shutdown().await.expect("shutdown b");
search_until(
"the release to survive on replicas after the owner left",
&c,
"mezzanine",
|items| items.len() == 1,
)
.await;
a.shutdown().await.expect("shutdown a");
c.shutdown().await.expect("shutdown c");
})
.await
.expect("test timed out");
}