test(e2e): drive the new protocol messages against soulfind
Eleven scenarios over a real server: tickers reaching other members and the board handed to a late joiner, the global room feed starting and stopping, user and room searches answered by their targets, interests driving similar users and recommendations, a password change taking effect on the next login, one message reaching several users, and a brokered dial we cannot complete reporting CantConnectToPeer to the peer that is waiting on it.
This commit is contained in:
@@ -3768,3 +3768,554 @@ fn a_leaf_adopts_a_parent_and_answers_the_searches_it_passes_down() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room tickers, the global room feed, interests and the targeted searches.
|
||||
//
|
||||
// Every one of these is a message the client can now speak; the point of
|
||||
// driving them through soulfind is that the wire shapes are right in both
|
||||
// directions, not just that they parse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Poll `client`'s room events until `pick` matches one, or time out.
|
||||
fn await_room_event<T>(
|
||||
client: &Client,
|
||||
timeout: Duration,
|
||||
mut pick: impl FnMut(&soulseek_rs::types::RoomEvent) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
for event in client.take_room_events() {
|
||||
if let Some(found) = pick(&event) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticker_set_in_a_room_reaches_the_other_members() {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_ticker_room";
|
||||
let mut alice =
|
||||
Client::with_settings(server.settings("e2e_tick_alice", "pw"));
|
||||
let mut bob = Client::with_settings(server.settings("e2e_tick_bob", "pw"));
|
||||
alice.connect().expect("alice connect");
|
||||
bob.connect().expect("bob connect");
|
||||
assert!(alice.login().expect("alice login"));
|
||||
assert!(bob.login().expect("bob login"));
|
||||
|
||||
alice.join_room(room).expect("alice joins");
|
||||
bob.join_room(room).expect("bob joins");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let _ = bob.take_room_events();
|
||||
|
||||
alice
|
||||
.set_room_ticker(room, "alice was here")
|
||||
.expect("set ticker");
|
||||
|
||||
let seen =
|
||||
await_room_event(&bob, Duration::from_secs(5), |event| match event {
|
||||
RoomEvent::TickerAdded {
|
||||
room: r,
|
||||
username,
|
||||
ticker,
|
||||
} if r == room => Some((username.clone(), ticker.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("bob should see alice's ticker");
|
||||
assert_eq!(
|
||||
seen,
|
||||
("e2e_tick_alice".to_string(), "alice was here".into())
|
||||
);
|
||||
|
||||
// The board is kept, not just the event: a UI reads it back from state.
|
||||
let board = bob.room_tickers(room);
|
||||
assert_eq!(
|
||||
board
|
||||
.iter()
|
||||
.find(|t| t.username == "e2e_tick_alice")
|
||||
.map(|t| t.ticker.as_str()),
|
||||
Some("alice was here")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_joining_later_receives_the_whole_ticker_board() {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_ticker_board";
|
||||
let mut alice =
|
||||
Client::with_settings(server.settings("e2e_board_alice", "pw"));
|
||||
alice.connect().expect("alice connect");
|
||||
assert!(alice.login().expect("alice login"));
|
||||
alice.join_room(room).expect("alice joins");
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
alice
|
||||
.set_room_ticker(room, "standing message")
|
||||
.expect("set ticker");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Carol joins afterwards and must be handed the board that already exists.
|
||||
let mut carol =
|
||||
Client::with_settings(server.settings("e2e_board_carol", "pw"));
|
||||
carol.connect().expect("carol connect");
|
||||
assert!(carol.login().expect("carol login"));
|
||||
carol.join_room(room).expect("carol joins");
|
||||
|
||||
let tickers =
|
||||
await_room_event(&carol, Duration::from_secs(5), |event| match event {
|
||||
RoomEvent::Tickers { room: r, tickers } if r == room => {
|
||||
Some(tickers.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("carol should receive the ticker board on join");
|
||||
assert!(
|
||||
tickers.iter().any(|t| t.username == "e2e_board_alice"
|
||||
&& t.ticker == "standing message"),
|
||||
"the board should carry alice's standing ticker, got {tickers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_global_room_feed_carries_a_room_we_never_joined() {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_global_source";
|
||||
let mut watcher =
|
||||
Client::with_settings(server.settings("e2e_global_watch", "pw"));
|
||||
let mut talker =
|
||||
Client::with_settings(server.settings("e2e_global_talk", "pw"));
|
||||
watcher.connect().expect("watcher connect");
|
||||
talker.connect().expect("talker connect");
|
||||
assert!(watcher.login().expect("watcher login"));
|
||||
assert!(talker.login().expect("talker login"));
|
||||
|
||||
watcher.join_global_room().expect("join global room");
|
||||
talker.join_room(room).expect("talker joins a room");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let _ = watcher.take_room_events();
|
||||
|
||||
let body = "spoken where nobody is watching";
|
||||
talker.say_in_room(room, body).expect("say in room");
|
||||
|
||||
let got =
|
||||
await_room_event(
|
||||
&watcher,
|
||||
Duration::from_secs(5),
|
||||
|event| match event {
|
||||
RoomEvent::GlobalMessage {
|
||||
room: r,
|
||||
username,
|
||||
message,
|
||||
} if message == body => Some((r.clone(), username.clone())),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.expect("the global feed should carry the message");
|
||||
assert_eq!(got, (room.to_string(), "e2e_global_talk".to_string()));
|
||||
|
||||
// And leaving the feed stops it: the next message must not arrive.
|
||||
watcher.leave_global_room().expect("leave global room");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let _ = watcher.take_room_events();
|
||||
talker
|
||||
.say_in_room(room, "after leaving")
|
||||
.expect("say again");
|
||||
let after = await_room_event(&watcher, Duration::from_secs(2), |event| {
|
||||
matches!(event, RoomEvent::GlobalMessage { message, .. }
|
||||
if message == "after leaving")
|
||||
.then_some(())
|
||||
});
|
||||
assert!(after.is_none(), "the feed should stop once left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_search_is_answered_by_the_user_it_names() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
let share_dir = unique_download_dir();
|
||||
let content: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write(share_dir.join("e2e_usersearch_grail.bin"), &content)
|
||||
.unwrap();
|
||||
|
||||
let (_sharer, searcher) = sharer_and_searcher(
|
||||
&server,
|
||||
&share_dir,
|
||||
"e2e_us_sharer",
|
||||
"e2e_us_seeker",
|
||||
);
|
||||
|
||||
let query = "grail";
|
||||
searcher
|
||||
.search_user("e2e_us_sharer", query)
|
||||
.expect("user search");
|
||||
|
||||
let reply = reply_from(&searcher, query, "e2e_us_sharer")
|
||||
.expect("the named user should answer a user search");
|
||||
assert!(
|
||||
reply
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| f.name.contains("e2e_usersearch_grail")),
|
||||
"the reply should carry the matching file, got {:?}",
|
||||
reply.files
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_room_search_is_answered_by_the_rooms_members() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_search_room";
|
||||
let share_dir = unique_download_dir();
|
||||
let content: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write(share_dir.join("e2e_roomsearch_relic.bin"), &content)
|
||||
.unwrap();
|
||||
|
||||
let (sharer, searcher) = sharer_and_searcher(
|
||||
&server,
|
||||
&share_dir,
|
||||
"e2e_rs_sharer",
|
||||
"e2e_rs_seeker",
|
||||
);
|
||||
sharer.join_room(room).expect("sharer joins");
|
||||
searcher.join_room(room).expect("searcher joins");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
|
||||
let query = "relic";
|
||||
searcher.search_room(room, query).expect("room search");
|
||||
|
||||
let reply = reply_from(&searcher, query, "e2e_rs_sharer")
|
||||
.expect("a member of the room should answer a room search");
|
||||
assert!(
|
||||
reply
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| f.name.contains("e2e_roomsearch_relic")),
|
||||
"the reply should carry the matching file, got {:?}",
|
||||
reply.files
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_interests_make_two_users_similar() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
let mut alice =
|
||||
Client::with_settings(server.settings("e2e_like_alice", "pw"));
|
||||
let mut bob = Client::with_settings(server.settings("e2e_like_bob", "pw"));
|
||||
alice.connect().expect("alice connect");
|
||||
bob.connect().expect("bob connect");
|
||||
assert!(alice.login().expect("alice login"));
|
||||
assert!(bob.login().expect("bob login"));
|
||||
|
||||
let item = "e2e_interest_krautrock";
|
||||
alice.add_interest(item).expect("alice likes it");
|
||||
bob.add_interest(item).expect("bob likes it too");
|
||||
alice
|
||||
.add_dislike("e2e_interest_muzak")
|
||||
.expect("alice dislikes something");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Who else likes this item (code 112).
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut likers = Vec::new();
|
||||
while Instant::now() < deadline
|
||||
&& !likers.iter().any(|u| u == "e2e_like_bob")
|
||||
{
|
||||
alice
|
||||
.request_item_similar_users(item)
|
||||
.expect("ask who likes it");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
likers = alice.item_similar_users(item);
|
||||
}
|
||||
assert!(
|
||||
likers.iter().any(|u| u == "e2e_like_bob"),
|
||||
"bob likes the same item, got {likers:?}"
|
||||
);
|
||||
|
||||
// The overlap also makes bob a similar user (code 110).
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut similar = Vec::new();
|
||||
while Instant::now() < deadline
|
||||
&& !similar
|
||||
.iter()
|
||||
.any(|u: &soulseek_rs::SimilarUser| u.username == "e2e_like_bob")
|
||||
{
|
||||
alice
|
||||
.request_similar_users()
|
||||
.expect("ask for similar users");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
similar = alice.similar_users();
|
||||
}
|
||||
assert!(
|
||||
similar
|
||||
.iter()
|
||||
.any(|u| u.username == "e2e_like_bob" && u.weight > 0),
|
||||
"bob should be similar with a non-zero weight, got {similar:?}"
|
||||
);
|
||||
|
||||
// And alice's own likes and hates read back over code 57.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut interests = None;
|
||||
while Instant::now() < deadline && interests.is_none() {
|
||||
bob.request_user_interests("e2e_like_alice")
|
||||
.expect("ask about alice");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
interests = bob.user_interests("e2e_like_alice");
|
||||
}
|
||||
let interests = interests.expect("bob should learn alice's interests");
|
||||
assert!(interests.likes.iter().any(|i| i == item));
|
||||
assert!(
|
||||
interests.hates.iter().any(|i| i == "e2e_interest_muzak"),
|
||||
"hates should come back too, got {interests:?}"
|
||||
);
|
||||
|
||||
// A dropped interest stops being reported.
|
||||
alice.remove_interest(item).expect("alice drops it");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut still_liked = true;
|
||||
while Instant::now() < deadline && still_liked {
|
||||
bob.request_user_interests("e2e_like_alice")
|
||||
.expect("ask again");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
still_liked = bob
|
||||
.user_interests("e2e_like_alice")
|
||||
.is_none_or(|i| i.likes.iter().any(|l| l == item));
|
||||
}
|
||||
assert!(
|
||||
!still_liked,
|
||||
"a removed interest should stop being reported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendations_are_returned_for_our_interests() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
// Recommendations come from what *other* users who share an interest also
|
||||
// like: bob likes both items, so alice, who likes only the first, should
|
||||
// be recommended the second.
|
||||
let mut alice =
|
||||
Client::with_settings(server.settings("e2e_rec_alice", "pw"));
|
||||
let mut bob = Client::with_settings(server.settings("e2e_rec_bob", "pw"));
|
||||
alice.connect().expect("alice connect");
|
||||
bob.connect().expect("bob connect");
|
||||
assert!(alice.login().expect("alice login"));
|
||||
assert!(bob.login().expect("bob login"));
|
||||
|
||||
let shared = "e2e_rec_shared";
|
||||
let other = "e2e_rec_other";
|
||||
alice.add_interest(shared).expect("alice likes shared");
|
||||
bob.add_interest(shared).expect("bob likes shared");
|
||||
bob.add_interest(other).expect("bob likes other");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut recommended = Vec::new();
|
||||
while Instant::now() < deadline
|
||||
&& !recommended
|
||||
.iter()
|
||||
.any(|r: &soulseek_rs::Recommendation| r.item == other)
|
||||
{
|
||||
alice
|
||||
.request_recommendations()
|
||||
.expect("ask for recommendations");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
recommended =
|
||||
alice.recommendations().map(|(r, _)| r).unwrap_or_default();
|
||||
}
|
||||
assert!(
|
||||
recommended.iter().any(|r| r.item == other),
|
||||
"the other item bob likes should be recommended, got {recommended:?}"
|
||||
);
|
||||
|
||||
// The server-wide list (code 56) answers too, with the same shape.
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut global = None;
|
||||
while Instant::now() < deadline && global.is_none() {
|
||||
alice
|
||||
.request_global_recommendations()
|
||||
.expect("ask for global recommendations");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
global = alice.global_recommendations();
|
||||
}
|
||||
let (global_items, _) = global.expect("the server should answer code 56");
|
||||
assert!(
|
||||
global_items.iter().any(|r| r.item == shared),
|
||||
"an item two users like should show server-wide, got {global_items:?}"
|
||||
);
|
||||
|
||||
// And per-item recommendations (code 111) answer for a named item.
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut item_recs = Vec::new();
|
||||
while Instant::now() < deadline && item_recs.is_empty() {
|
||||
alice
|
||||
.request_item_recommendations(shared)
|
||||
.expect("ask about one item");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
item_recs = alice.item_recommendations(shared);
|
||||
}
|
||||
assert!(
|
||||
item_recs.iter().any(|r| r.item == other),
|
||||
"people who like {shared} also like {other}, got {item_recs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_leaves_the_session_usable() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
// A ping has no reply, so what it must not do is upset the session: the
|
||||
// very next request has to still be answered.
|
||||
let mut client =
|
||||
Client::with_settings(server.settings("e2e_ping_user", "pw"));
|
||||
client.connect().expect("connect");
|
||||
assert!(client.login().expect("login"));
|
||||
|
||||
for _ in 0..3 {
|
||||
client.ping_server().expect("ping");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut listed = false;
|
||||
while Instant::now() < deadline && !listed {
|
||||
client.request_room_list().expect("request room list");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
listed = !client.room_list().is_empty()
|
||||
|| client.session_loss().is_none() && Instant::now() > deadline;
|
||||
}
|
||||
assert!(
|
||||
client.session_loss().is_none(),
|
||||
"pings must not cost us the session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_message_reaches_several_users_at_once() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
let mut sender =
|
||||
Client::with_settings(server.settings("e2e_many_sender", "pw"));
|
||||
let mut first =
|
||||
Client::with_settings(server.settings("e2e_many_first", "pw"));
|
||||
let mut second =
|
||||
Client::with_settings(server.settings("e2e_many_second", "pw"));
|
||||
for client in [&mut sender, &mut first, &mut second] {
|
||||
client.connect().expect("connect");
|
||||
assert!(client.login().expect("login"));
|
||||
}
|
||||
|
||||
let body = "one message, two recipients";
|
||||
sender
|
||||
.send_private_message_to_many(
|
||||
&["e2e_many_first".to_string(), "e2e_many_second".to_string()],
|
||||
body,
|
||||
)
|
||||
.expect("send to many");
|
||||
|
||||
for client in [&first, &second] {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut got = false;
|
||||
while Instant::now() < deadline && !got {
|
||||
got = client
|
||||
.take_private_messages()
|
||||
.iter()
|
||||
.any(|m| m.message() == body);
|
||||
if !got {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
assert!(got, "every named recipient should receive the message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_password_is_what_the_next_login_needs() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
let user = "e2e_pwchange_user";
|
||||
let mut client = Client::with_settings(server.settings(user, "first-pw"));
|
||||
client.connect().expect("connect");
|
||||
assert!(client.login().expect("login registers the account"));
|
||||
|
||||
client
|
||||
.change_password("second-pw")
|
||||
.expect("change password");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
drop(client);
|
||||
|
||||
let mut stale = Client::with_settings(server.settings(user, "first-pw"));
|
||||
stale.connect().expect("connect with the old password");
|
||||
assert!(
|
||||
!matches!(stale.login(), Ok(true)),
|
||||
"the old password must stop working"
|
||||
);
|
||||
drop(stale);
|
||||
|
||||
let mut fresh = Client::with_settings(server.settings(user, "second-pw"));
|
||||
fresh.connect().expect("connect with the new password");
|
||||
assert!(
|
||||
fresh.login().expect("login with the new password"),
|
||||
"the new password must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() {
|
||||
// The peer asks the server to broker a connection to us but is itself
|
||||
// unreachable — it advertises a port nothing listens on. Our dial fails,
|
||||
// and the peer must be told (CantConnectToPeer, code 1001) rather than
|
||||
// left waiting for a connection that is never coming.
|
||||
let server = server_or_skip!();
|
||||
let addr = format!("{}:{}", server.host, server.port);
|
||||
|
||||
let mut client =
|
||||
Client::with_settings(server.settings("e2e_cantconn_us", "pw"));
|
||||
client.connect().expect("connect");
|
||||
assert!(client.login().expect("login"));
|
||||
|
||||
let mut peer =
|
||||
login_raw(&addr, "e2e_cantconn_peer", "pw").expect("peer login");
|
||||
// A port that is free is a port nothing answers on: the dial is refused.
|
||||
let dead_port = free_port().expect("free port");
|
||||
peer.write_all(
|
||||
&MessageFactory::build_set_wait_port_message(dead_port).get_buffer(),
|
||||
)
|
||||
.expect("set wait port");
|
||||
peer.flush().expect("flush wait port");
|
||||
|
||||
let token = 424_242_u32;
|
||||
peer.write_all(
|
||||
&MessageFactory::build_connect_to_peer(
|
||||
token,
|
||||
"e2e_cantconn_us",
|
||||
ConnectionType::P,
|
||||
)
|
||||
.get_buffer(),
|
||||
)
|
||||
.expect("ask the server to broker");
|
||||
peer.flush().expect("flush broker request");
|
||||
|
||||
let mut reply = read_until_code(&mut peer, 1001, Duration::from_secs(20))
|
||||
.expect("the peer should be told the connection failed");
|
||||
reply.set_pointer(8);
|
||||
assert_eq!(
|
||||
reply.read_int32(),
|
||||
token,
|
||||
"the reply should quote the token the peer asked with"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user