test: add server-optional e2e integration harness against soulfind
Adds soulseek-rs-lib/tests/e2e.rs, which drives the client's public API (connect, login, search) against a real Soulseek server. The harness is server-optional so cargo test stays green everywhere: it uses SOULSEEK_TEST_SERVER if set, otherwise spawns a soulfind binary (SOULFIND_BIN or a soulfind/bin/soulfind checkout in a parent dir) on an ephemeral port with a throwaway database, and skips with a notice when no server is available. Uses only std and the library's public API — no new dependencies. Documents how to run it (including the macOS soulfind SQLite workaround) in the README.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Child>,
|
||||
db: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// Resolve a server to test against, or `None` if the suite should skip.
|
||||
fn resolve() -> Option<Self> {
|
||||
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<Self> {
|
||||
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<PathBuf> {
|
||||
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<u16> {
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user