test: persistence e2e and registration-relogin e2e; lint clean

Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
This commit is contained in:
michel
2026-07-20 20:20:54 +02:00
parent c945bd5d5e
commit 36819d4c42
14 changed files with 203 additions and 66 deletions
+24
View File
@@ -417,6 +417,30 @@ fn wrong_password_is_rejected() {
); );
} }
#[test]
fn registered_username_can_relogin_with_same_password() {
let server = server_or_skip!();
// The TUI's stored-credentials flow depends on this pair of server
// behaviors: a fresh username is registered by simply logging in, and a
// later session (a "restart") with the same credentials is accepted.
let user = "e2e_relogin_user";
let mut first = Client::with_settings(server.settings(user, "pw-123"));
first.connect().expect("connect (registering login)");
assert!(
first.login().expect("registering login"),
"a fresh username should be auto-registered"
);
drop(first);
let mut second = Client::with_settings(server.settings(user, "pw-123"));
second.connect().expect("connect (relogin)");
assert!(
second.login().expect("relogin"),
"the same credentials must log in again after a restart"
);
}
#[test] #[test]
fn two_clients_can_be_logged_in_together() { fn two_clients_can_be_logged_in_together() {
let server = server_or_skip!(); let server = server_or_skip!();
+7
View File
@@ -13,6 +13,13 @@ categories = ["command-line-utilities"]
name = "soulseek-rs" name = "soulseek-rs"
path = "src/main.rs" path = "src/main.rs"
# Small library target so integration tests can exercise the persistence
# pipeline; the dependency soulseek-rs-lib already owns the `soulseek_rs`
# crate name, hence the distinct one.
[lib]
name = "soulseek_rs_tui"
path = "src/lib.rs"
[lints] [lints]
workspace = true workspace = true
+6
View File
@@ -0,0 +1,6 @@
//! Library surface of the TUI binary, exposed for integration tests.
//! The binary itself compiles these modules directly (see `main.rs`).
pub mod cli;
pub mod models;
pub mod persist;
+6 -10
View File
@@ -200,19 +200,15 @@ fn run_default_tui(
let enable_listen = !resolved.disable_listener; let enable_listen = !resolved.disable_listener;
let listen_port = resolved.listener_port; let listen_port = resolved.listener_port;
let make_settings = move |username: String, password: String| { let make_settings =
ClientSettings { move |username: String, password: String| ClientSettings {
username, username,
password, password,
server_address: PeerAddress::new( server_address: PeerAddress::new(server_host.clone(), server_port),
server_host.clone(),
server_port,
),
enable_listen, enable_listen,
listen_port, listen_port,
shared_directory: shared_directory.clone(), shared_directory: shared_directory.clone(),
} };
};
// Clear screen and enable mouse capture before initializing TUI // Clear screen and enable mouse capture before initializing TUI
let _ = let _ =
@@ -243,8 +239,8 @@ fn run_default_tui(
persist_credentials(&outcome, config_path, file_config, &secret_store); persist_credentials(&outcome, config_path, file_config, &secret_store);
let store = persist::paths::state_dir() let store =
.map(persist::state::StateStore::new); persist::paths::state_dir().map(persist::state::StateStore::new);
launch_main_tui( launch_main_tui(
terminal, terminal,
+2
View File
@@ -111,6 +111,7 @@ pub struct AppState {
} }
impl AppState { impl AppState {
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
let mut searches_table_state = TableState::default(); let mut searches_table_state = TableState::default();
searches_table_state.select(Some(0)); searches_table_state.select(Some(0));
@@ -166,6 +167,7 @@ impl AppState {
} }
#[allow(dead_code)] #[allow(dead_code)]
#[must_use]
pub fn get_selected_search(&self) -> Option<&SearchEntry> { pub fn get_selected_search(&self) -> Option<&SearchEntry> {
self.selected_search_index self.selected_search_index
.and_then(|idx| self.searches.get(idx)) .and_then(|idx| self.searches.get(idx))
+8 -6
View File
@@ -5,7 +5,7 @@ use std::path::Path;
/// Optional settings read from `config.toml`. Every field is optional so a /// Optional settings read from `config.toml`. Every field is optional so a
/// partial file (or none at all) is valid; unknown keys are ignored so newer /// partial file (or none at all) is valid; unknown keys are ignored so newer
/// configs still load in older builds. /// configs still load in older builds.
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct FileConfig { pub struct FileConfig {
pub username: Option<String>, pub username: Option<String>,
@@ -54,7 +54,7 @@ impl FileConfig {
/// Fully-resolved settings after layering CLI (which already includes env via /// Fully-resolved settings after layering CLI (which already includes env via
/// clap) over the config file over built-in defaults. /// clap) over the config file over built-in defaults.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved { pub struct Resolved {
pub username: Option<String>, pub username: Option<String>,
pub server: String, pub server: String,
@@ -73,10 +73,12 @@ pub const DEFAULT_DOWNLOAD_DIR: &str = "~/Downloads";
pub const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 5; pub const DEFAULT_MAX_CONCURRENT_DOWNLOADS: usize = 5;
pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10; pub const DEFAULT_SEARCH_TIMEOUT: u64 = 10;
/// Layer CLI/env values over the config file over defaults. The /// Layer CLI/env values over the config file over defaults.
/// `--disable-listener` flag can only enable the disable (a bare flag has no ///
/// "explicitly off" form), so file `disable_listener = true` wins unless the /// The `--disable-listener` flag can only enable the disable (a bare flag
/// flag is passed. /// has no "explicitly off" form), so file `disable_listener = true` wins
/// unless the flag is passed.
#[must_use]
pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
Resolved { Resolved {
username: cli.username.clone().or_else(|| file.username.clone()), username: cli.username.clone().or_else(|| file.username.clone()),
+2
View File
@@ -4,6 +4,7 @@ use std::path::PathBuf;
/// Platform-appropriate locations for the config file and state directory. /// Platform-appropriate locations for the config file and state directory.
/// Overridable via `SOULSEEK_CONFIG_DIR` / `SOULSEEK_STATE_DIR` (used by the /// Overridable via `SOULSEEK_CONFIG_DIR` / `SOULSEEK_STATE_DIR` (used by the
/// e2e tests, and handy for portable installs). /// e2e tests, and handy for portable installs).
#[must_use]
pub fn config_file() -> Option<PathBuf> { pub fn config_file() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("SOULSEEK_CONFIG_DIR") { if let Ok(dir) = std::env::var("SOULSEEK_CONFIG_DIR") {
return Some(PathBuf::from(dir).join("config.toml")); return Some(PathBuf::from(dir).join("config.toml"));
@@ -11,6 +12,7 @@ pub fn config_file() -> Option<PathBuf> {
project_dirs().map(|d| d.config_dir().join("config.toml")) project_dirs().map(|d| d.config_dir().join("config.toml"))
} }
#[must_use]
pub fn state_dir() -> Option<PathBuf> { pub fn state_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("SOULSEEK_STATE_DIR") { if let Ok(dir) = std::env::var("SOULSEEK_STATE_DIR") {
return Some(PathBuf::from(dir)); return Some(PathBuf::from(dir));
+4 -3
View File
@@ -32,9 +32,10 @@ impl SecretStore for KeyringStore {
} }
} }
/// Resolve the password with precedence: CLI/env (`cli_password`, already /// Resolve the password with precedence: CLI/env > keychain > `password_cmd`.
/// merged by clap) > OS keychain > `password_cmd` from config.toml. Returns ///
/// `None` when no source has one — the caller shows the login screen. /// `cli_password` already has env merged in by clap. Returns `None` when no
/// source has one — the caller shows the login screen.
/// ///
/// Keychain errors are downgraded to "not found": on headless boxes without /// Keychain errors are downgraded to "not found": on headless boxes without
/// a secret service the fallbacks must still work. /// a secret service the fallbacks must still work.
+15 -12
View File
@@ -17,6 +17,7 @@ impl Snapshot {
/// Extract what should survive a restart. Downloads that are not yet /// Extract what should survive a restart. Downloads that are not yet
/// `Completed` (including failed/timed-out ones) are marked incomplete /// `Completed` (including failed/timed-out ones) are marked incomplete
/// so the next start can re-enqueue them. /// so the next start can re-enqueue them.
#[must_use]
pub fn capture(state: &AppState) -> Self { pub fn capture(state: &AppState) -> Self {
let downloads = state let downloads = state
.downloads .downloads
@@ -25,10 +26,7 @@ impl Snapshot {
username: entry.download.username.clone(), username: entry.download.username.clone(),
filename: entry.download.filename.clone(), filename: entry.download.filename.clone(),
size: entry.download.size, size: entry.download.size,
download_directory: entry download_directory: entry.download.download_directory.clone(),
.download
.download_directory
.clone(),
completed: matches!( completed: matches!(
entry.download.status, entry.download.status,
DownloadStatus::Completed DownloadStatus::Completed
@@ -43,8 +41,12 @@ impl Snapshot {
} }
} }
let rooms = let rooms = state
state.rooms.open.iter().map(|room| room.name.clone()).collect(); .rooms
.open
.iter()
.map(|room| room.name.clone())
.collect();
Self { Self {
downloads, downloads,
@@ -80,10 +82,7 @@ mod tests {
use std::sync::{Arc, mpsc}; use std::sync::{Arc, mpsc};
use std::time::Instant; use std::time::Instant;
fn download( fn download(filename: &str, status: DownloadStatus) -> DownloadEntry {
filename: &str,
status: DownloadStatus,
) -> DownloadEntry {
let (sender, _receiver) = mpsc::channel(); let (sender, _receiver) = mpsc::channel();
DownloadEntry { DownloadEntry {
download: Download { download: Download {
@@ -114,8 +113,12 @@ mod tests {
#[test] #[test]
fn capture_maps_download_status_to_completed_flag() { fn capture_maps_download_status_to_completed_flag() {
let mut state = AppState::new(); let mut state = AppState::new();
state.downloads.push(download("done.mp3", DownloadStatus::Completed)); state
state.downloads.push(download("queued.mp3", DownloadStatus::Queued)); .downloads
.push(download("done.mp3", DownloadStatus::Completed));
state
.downloads
.push(download("queued.mp3", DownloadStatus::Queued));
state.downloads.push(download( state.downloads.push(download(
"failed.mp3", "failed.mp3",
DownloadStatus::Failed(Some("nope".into())), DownloadStatus::Failed(Some("nope".into())),
+5 -10
View File
@@ -29,7 +29,8 @@ pub struct StateStore {
} }
impl StateStore { impl StateStore {
pub fn new(dir: PathBuf) -> Self { #[must_use]
pub const fn new(dir: PathBuf) -> Self {
Self { dir } Self { dir }
} }
@@ -198,9 +199,7 @@ mod tests {
fn migrations_upgrade_old_data() { fn migrations_upgrade_old_data() {
// Machinery test with a synthetic chain: v0 stored plain strings, // Machinery test with a synthetic chain: v0 stored plain strings,
// v1 wraps each in an object {"name": ...}. // v1 wraps each in an object {"name": ...}.
#[derive( #[derive(Debug, PartialEq, Default, Serialize, serde::Deserialize)]
Debug, PartialEq, Default, Serialize, serde::Deserialize,
)]
struct Named { struct Named {
name: String, name: String,
} }
@@ -216,15 +215,11 @@ mod tests {
}]; }];
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("things.json"); let path = tmp.path().join("things.json");
std::fs::write(&path, r#"{"version": 0, "data": ["a", "b"]}"#) std::fs::write(&path, r#"{"version": 0, "data": ["a", "b"]}"#).unwrap();
.unwrap();
let loaded: Vec<Named> = load(&path, chain); let loaded: Vec<Named> = load(&path, chain);
assert_eq!( assert_eq!(
loaded, loaded,
vec![ vec![Named { name: "a".into() }, Named { name: "b".into() }]
Named { name: "a".into() },
Named { name: "b".into() }
]
); );
} }
+18 -23
View File
@@ -119,7 +119,7 @@ impl LoginForm {
} }
} }
fn focused_field_mut(&mut self) -> &mut String { const fn focused_field_mut(&mut self) -> &mut String {
match self.focused { match self.focused {
LoginField::Username => &mut self.username, LoginField::Username => &mut self.username,
LoginField::Password => &mut self.password, LoginField::Password => &mut self.password,
@@ -148,18 +148,18 @@ pub fn run_login_flow(
) -> Result<Option<LoginOutcome>> { ) -> Result<Option<LoginOutcome>> {
let mut form = LoginForm::new(initial_username); let mut form = LoginForm::new(initial_username);
let mut entered_via_form = false; let mut entered_via_form = false;
let mut attempt: Option<Receiver<Result<Client, String>>> = None; let mut attempt: Option<Receiver<Result<Client, String>>> =
match initial_password {
if let Some(password) = initial_password Some(password) if !form.username.is_empty() => {
&& !form.username.is_empty() form.password = password;
{ form.phase = LoginPhase::Connecting;
form.password = password; Some(spawn_attempt(make_settings(
form.phase = LoginPhase::Connecting; form.username.clone(),
attempt = Some(spawn_attempt(make_settings( form.password.clone(),
form.username.clone(), )))
form.password.clone(), }
))); _ => None,
} };
loop { loop {
terminal.draw(|frame| render(frame, &form))?; terminal.draw(|frame| render(frame, &form))?;
@@ -207,9 +207,7 @@ pub fn run_login_flow(
} }
/// Connect and log in on a background thread so the UI stays responsive. /// Connect and log in on a background thread so the UI stays responsive.
fn spawn_attempt( fn spawn_attempt(settings: ClientSettings) -> Receiver<Result<Client, String>> {
settings: ClientSettings,
) -> Receiver<Result<Client, String>> {
let (tx, rx) = channel(); let (tx, rx) = channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut client = Client::with_settings(settings); let mut client = Client::with_settings(settings);
@@ -288,13 +286,10 @@ fn render(frame: &mut Frame, form: &LoginForm) {
"Connecting…", "Connecting…",
Style::default().fg(Color::Yellow), Style::default().fg(Color::Yellow),
))), ))),
LoginPhase::Failed(message) => { LoginPhase::Failed(message) => Paragraph::new(Line::from(
Paragraph::new(Line::from(Span::styled( Span::styled(message.clone(), Style::default().fg(Color::Red)),
message.clone(), ))
Style::default().fg(Color::Red), .wrap(ratatui::widgets::Wrap { trim: true }),
)))
.wrap(ratatui::widgets::Wrap { trim: true })
}
}; };
frame.render_widget(status, rows[3]); frame.render_widget(status, rows[3]);
+2 -1
View File
@@ -88,7 +88,8 @@ impl MainTui {
status: soulseek_rs::DownloadStatus::Completed, status: soulseek_rs::DownloadStatus::Completed,
sender: std::sync::mpsc::channel().0, sender: std::sync::mpsc::channel().0,
queue_position: None, queue_position: None,
metadata: Default::default(), metadata: soulseek_rs::types::DownloadMetadata::default(
),
}, },
receiver: None, receiver: None,
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
mod download_selector; mod download_selector;
pub mod login;
mod downloads; mod downloads;
pub mod login;
mod main_tui; mod main_tui;
mod panes; mod panes;
mod styles; mod styles;
+103
View File
@@ -0,0 +1,103 @@
//! End-to-end test of the persistence pipeline across a simulated restart:
//! capture live-ish app state, write it through the real state files, load
//! it back in a fresh store (a "second session"), and restore it.
use soulseek_rs::DownloadStatus;
use soulseek_rs::types::{Download, DownloadMetadata};
use soulseek_rs_tui::models::{AppState, DownloadEntry};
use soulseek_rs_tui::persist::config::FileConfig;
use soulseek_rs_tui::persist::snapshot::{Snapshot, restore_searches};
use soulseek_rs_tui::persist::state::StateStore;
fn download_entry(filename: &str, status: DownloadStatus) -> DownloadEntry {
DownloadEntry {
download: Download {
username: "peer".into(),
filename: filename.into(),
token: 1,
size: 1000,
download_directory: "/music".into(),
status,
sender: std::sync::mpsc::channel().0,
queue_position: None,
metadata: DownloadMetadata::default(),
},
receiver: None,
}
}
#[test]
fn state_survives_a_restart() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
// Session 1: user searched, joined rooms, and has one finished and one
// in-flight download when the app exits.
{
let mut state = AppState::new();
state
.downloads
.push(download_entry("done.flac", DownloadStatus::Completed));
state.downloads.push(download_entry(
"half.flac",
DownloadStatus::InProgress {
bytes_downloaded: 500,
total_bytes: 1000,
speed_bytes_per_sec: 1.0,
},
));
restore_searches(&mut state, &["beatles".to_string()]);
state.rooms.focus_or_open("indie");
let store = StateStore::new(state_dir.clone());
let snapshot = Snapshot::capture(&state);
store.save_downloads(&snapshot.downloads).unwrap();
store.save_search_queries(&snapshot.queries).unwrap();
store.save_rooms(&snapshot.rooms).unwrap();
}
// The files on disk are versioned envelopes.
let raw =
std::fs::read_to_string(state_dir.join("downloads.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(parsed.get("version").is_some(), "missing version envelope");
// Session 2: a fresh store (new process) sees the same state; the
// incomplete download is flagged for auto-resume.
let store = StateStore::new(state_dir);
let downloads = store.load_downloads();
assert_eq!(downloads.len(), 2);
assert!(
downloads
.iter()
.any(|d| d.filename == "done.flac" && d.completed)
);
assert!(
downloads
.iter()
.any(|d| d.filename == "half.flac" && !d.completed)
);
assert_eq!(store.load_search_queries(), vec!["beatles".to_string()]);
assert_eq!(store.load_rooms(), vec!["indie".to_string()]);
let mut state = AppState::new();
restore_searches(&mut state, &store.load_search_queries());
assert_eq!(state.searches[0].query, "beatles");
}
#[test]
fn config_survives_a_restart() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config").join("config.toml");
let config = FileConfig {
username: Some("alice".into()),
server: Some("localhost:2242".into()),
..FileConfig::default()
};
config.save(&path).unwrap();
// "Second session": reload from disk.
let loaded = FileConfig::load(&path).unwrap();
assert_eq!(loaded, config);
}