From 9c1a6d317e0cf256defb5a83cd0b1e983ca55206 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Fri, 28 Aug 2026 14:42:44 +0000 Subject: [PATCH] test(e2e): cover watching against soulfind, and read the reply as written The e2e job proved nothing about watching: it passed because the suite has no test that exercises it. Two now run against a real soulfind. - a watch reply carries status and share statistics in one message, checked against a subject sharing a known two files, and unwatching drops both the watch and the snapshot - a username the server does not know is answered with an absence flag and must not stay in the watch list Also reads the two obsolete fields of the reply as the two 32-bit values the server writes, matching soulfind and the sibling GetUserStats (36) handler. The byte count is identical, so this is not a behaviour change -- but the handler now says what the wire actually carries. --- .../src/message/server/watch_user.rs | 10 +- soulseek-rs-lib/tests/e2e.rs | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/soulseek-rs-lib/src/message/server/watch_user.rs b/soulseek-rs-lib/src/message/server/watch_user.rs index ecbd50d..1499189 100644 --- a/soulseek-rs-lib/src/message/server/watch_user.rs +++ b/soulseek-rs-lib/src/message/server/watch_user.rs @@ -31,7 +31,12 @@ impl MessageHandler for WatchUserHandler { let watched = if exists { let status = message.read_int32(); let average_speed = message.read_int32(); - let _upload_number = message.read_int64(); // obsolete + // Two obsolete 32-bit fields, exactly as GetUserStats (36) + // carries them -- not one 64-bit value. The byte count is the + // same either way, but reading them as the server writes them + // keeps this handler and its sibling honest. + let _upload_number = message.read_int32(); // obsolete, always 0 + let _unknown = message.read_int32(); // obsolete, always 0 let shared_files = message.read_int32(); let shared_folders = message.read_int32(); ServerMessage::WatchedUserReceived { @@ -71,7 +76,8 @@ mod tests { .write_int8(1) // exists .write_int32(2) // status: online .write_int32(1024) // average speed - .write_int64(0) // obsolete upload number + .write_int32(0) // obsolete upload number + .write_int32(0) // obsolete unknown .write_int32(4321) // shared files .write_int32(77); // shared folders }); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 072bc21..430a4ce 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -2622,3 +2622,101 @@ fn a_peer_that_hangs_up_its_control_connection_still_delivers_the_file() { let _ = std::fs::remove_dir_all(&download_dir); } + +#[test] +fn watching_a_user_returns_their_status_and_share_counts() { + let server = server_or_skip!(); + + // A subject with a known share, so the watch reply's statistics are + // checkable rather than merely present. + let share_dir = unique_download_dir().join("watch_share"); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + for name in ["one.flac", "two.flac"] { + std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap(); + } + let mut subject = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_watch_subject", "pw") + }); + subject.connect().expect("subject connect"); + assert!(subject.login().expect("subject login")); + + let mut watcher = + Client::with_settings(server.settings("e2e_watch_watcher", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + // Let the subject's share counts reach the server before watching, so + // the reply describes a user it already knows the statistics for. + std::thread::sleep(Duration::from_millis(750)); + + watcher + .watch_user("e2e_watch_subject") + .expect("watch the subject"); + assert_eq!( + watcher.watched_users(), + vec!["e2e_watch_subject".to_string()] + ); + + // One reply carries both halves, unlike the two GetUserStatus and + // GetUserStats answers request_user_info waits on. + let deadline = Instant::now() + Duration::from_secs(15); + let mut info = None; + while Instant::now() < deadline { + match watcher.user_info("e2e_watch_subject") { + Some(found) if found.is_complete() => { + info = Some(found); + break; + } + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + let info = info.expect("the watch reply should carry status and stats"); + + let presence = info.presence.expect("presence should have arrived"); + assert!( + presence.status.is_reachable(), + "a logged-in user should not read as offline, got {}", + presence.status + ); + let stats = info.stats.expect("stats should have arrived"); + assert_eq!( + stats.shared_files, 2, + "the file count must survive the obsolete fields ahead of it" + ); + assert_eq!(stats.shared_folders, 1); + + // Unwatching drops the user and what we knew about them, so a later + // re-watch reports a fresh answer rather than this stale one. + watcher + .unwatch_user("e2e_watch_subject") + .expect("unwatch the subject"); + assert!(watcher.watched_users().is_empty()); + assert!(watcher.user_info("e2e_watch_subject").is_none()); +} + +#[test] +fn watching_a_user_the_server_does_not_know_reports_their_absence() { + let server = server_or_skip!(); + + let mut watcher = + Client::with_settings(server.settings("e2e_watch_ghost_asker", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + watcher + .watch_user("e2e_watch_nobody_here") + .expect("watch an unknown user"); + + // The server answers that it has no such user; the watch is dropped + // rather than left showing a permanently blank row. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && !watcher.watched_users().is_empty() { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + watcher.watched_users().is_empty(), + "a username the server does not know must not stay watched" + ); + assert!(watcher.user_info("e2e_watch_nobody_here").is_none()); +}