diff --git a/README.md b/README.md index ea19b7c..df9b10f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,30 @@ To run the formatter: cargo fmt ``` +### End-to-end tests + +The `soulseek-rs-lib/tests/e2e.rs` suite exercises the client against a real +Soulseek server using [soulfind](https://github.com/soulfind-dev/soulfind), a +local server implementation. The tests are **server-optional**: they run when a +server is available and otherwise skip (so `cargo test` stays green everywhere). + +They locate a server in this order: + +1. `SOULSEEK_TEST_SERVER=host:port` — connect to an already-running server, or +2. `SOULFIND_BIN=/path/to/soulfind` (or a `soulfind/bin/soulfind` checkout in a + parent directory) — spawn soulfind on an ephemeral port with a throwaway + database. + +```bash +# Build soulfind once (see its BUILDING.md), then: +SOULFIND_BIN=/path/to/soulfind/bin/soulfind \ + cargo test -p soulseek-rs-lib --test e2e -- --nocapture +``` + +> On macOS, soulfind's `SQLITE_CONFIG_SINGLETHREAD` optimization is rejected by +> the system SQLite; building soulfind with that call made non-fatal lets it run +> locally. It is only a mutex optimization and does not affect the server. + ## License This project is licensed under the MIT License — see the [LICENSE](./LICENSE) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs new file mode 100644 index 0000000..1f51c2c --- /dev/null +++ b/soulseek-rs-lib/tests/e2e.rs @@ -0,0 +1,193 @@ +//! End-to-end integration tests against a real Soulseek server (soulfind). +//! +//! These tests are SERVER-OPTIONAL so `cargo test` stays green everywhere: +//! * If `SOULSEEK_TEST_SERVER=host:port` is set, they connect to it. +//! * Else if a soulfind binary is found (via `SOULFIND_BIN`, or a sibling +//! `../soulfind/bin/soulfind` checkout), they spawn it on an ephemeral port +//! with a throwaway database. +//! * Else they SKIP with a printed notice (the test still passes). +//! +//! To run them against a locally built soulfind: +//! ```sh +//! SOULFIND_BIN=/path/to/soulfind \ +//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture +//! ``` +//! +//! No external crates are used — the library forbids dependencies, so the +//! harness sticks to `std` and the library's own public API. + +#![allow(clippy::doc_markdown)] + +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use soulseek_rs::{Client, ClientSettings, PeerAddress}; + +/// A Soulseek server to test against: either a child soulfind process we +/// spawned, or an external server referenced by `SOULSEEK_TEST_SERVER`. +struct TestServer { + host: String, + port: u16, + child: Option, + db: Option, +} + +impl TestServer { + /// Resolve a server to test against, or `None` if the suite should skip. + fn resolve() -> Option { + if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") { + let (host, port) = split_host_port(&addr)?; + wait_until_listening(&host, port, Duration::from_secs(2))?; + return Some(Self { + host, + port, + child: None, + db: None, + }); + } + Self::spawn() + } + + /// Spawn a local soulfind on an ephemeral port with a throwaway database. + fn spawn() -> Option { + let bin = soulfind_binary()?; + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db")); + let _ = std::fs::remove_file(&db); + + let mut child = Command::new(&bin) + .arg("-p") + .arg(port.to_string()) + .arg("-d") + .arg(&db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) + .is_none() + { + // Server never came up (e.g. a toolchain/SQLite issue); skip. + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + Some(Self { + host: "127.0.0.1".to_string(), + port, + child: Some(child), + db: Some(db), + }) + } + + fn settings(&self, username: &str, password: &str) -> ClientSettings { + ClientSettings { + username: username.to_string(), + password: password.to_string(), + server_address: PeerAddress::new(self.host.clone(), self.port), + enable_listen: false, + listen_port: 0, + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(db) = self.db.as_ref() { + let _ = std::fs::remove_file(db); + } + } +} + +fn split_host_port(addr: &str) -> Option<(String, u16)> { + let (host, port) = addr.rsplit_once(':')?; + Some((host.to_string(), port.parse().ok()?)) +} + +fn soulfind_binary() -> Option { + if let Ok(p) = std::env::var("SOULFIND_BIN") { + let p = PathBuf::from(p); + return p.exists().then_some(p); + } + // Fall back to a `soulfind/bin/soulfind` checkout somewhere above this + // workspace (the workspace may be nested, e.g. under `.../src/rust/`). + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .map(|dir| dir.join("soulfind/bin/soulfind")) + .find(|candidate| candidate.exists()) +} + +fn free_port() -> Option { + let listener = std::net::TcpListener::bind("127.0.0.1:0").ok()?; + listener.local_addr().ok().map(|addr| addr.port()) +} + +fn wait_until_listening( + host: &str, + port: u16, + timeout: Duration, +) -> Option<()> { + let addr = format!("{host}:{port}"); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Ok(mut addrs) = addr.to_socket_addrs() + && let Some(sa) = addrs.next() + && TcpStream::connect_timeout(&sa, Duration::from_millis(200)) + .is_ok() + { + return Some(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +/// Resolve a test server or return early with a skip notice. +macro_rules! server_or_skip { + () => { + match TestServer::resolve() { + Some(server) => server, + None => { + println!( + "e2e skipped: no soulfind server (set SOULFIND_BIN or \ + SOULSEEK_TEST_SERVER to run)" + ); + return; + } + } + }; +} + +#[test] +fn connect_and_login_succeed() { + let server = server_or_skip!(); + let mut client = + Client::with_settings(server.settings("e2e_user", "e2e_pw")); + client.connect().expect("connect to soulfind"); + let logged_in = client.login().expect("login to soulfind"); + assert!(logged_in, "login should succeed (soulfind auto-registers)"); +} + +#[test] +fn search_round_trips_without_error() { + let server = server_or_skip!(); + let mut client = + Client::with_settings(server.settings("e2e_search", "e2e_pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + // A fresh server has no shared files, so the search simply has to + // round-trip without error and leave an (empty) queryable result set. + let query = "nonexistent query xyzzy"; + let _ = client.search(query, Duration::from_secs(2)); + assert!(client.get_search_results(query).is_empty()); +}