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:
@@ -5,7 +5,7 @@ use std::path::Path;
|
||||
/// 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
|
||||
/// configs still load in older builds.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct FileConfig {
|
||||
pub username: Option<String>,
|
||||
@@ -54,7 +54,7 @@ impl FileConfig {
|
||||
|
||||
/// Fully-resolved settings after layering CLI (which already includes env via
|
||||
/// clap) over the config file over built-in defaults.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resolved {
|
||||
pub username: Option<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_SEARCH_TIMEOUT: u64 = 10;
|
||||
|
||||
/// Layer CLI/env values over the config file over defaults. The
|
||||
/// `--disable-listener` flag can only enable the disable (a bare flag has no
|
||||
/// "explicitly off" form), so file `disable_listener = true` wins unless the
|
||||
/// flag is passed.
|
||||
/// Layer CLI/env values over the config file over defaults.
|
||||
///
|
||||
/// The `--disable-listener` flag can only enable the disable (a bare flag
|
||||
/// 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 {
|
||||
Resolved {
|
||||
username: cli.username.clone().or_else(|| file.username.clone()),
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
/// Platform-appropriate locations for the config file and state directory.
|
||||
/// Overridable via `SOULSEEK_CONFIG_DIR` / `SOULSEEK_STATE_DIR` (used by the
|
||||
/// e2e tests, and handy for portable installs).
|
||||
#[must_use]
|
||||
pub fn config_file() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SOULSEEK_CONFIG_DIR") {
|
||||
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"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SOULSEEK_STATE_DIR") {
|
||||
return Some(PathBuf::from(dir));
|
||||
|
||||
@@ -32,9 +32,10 @@ impl SecretStore for KeyringStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the password with precedence: CLI/env (`cli_password`, already
|
||||
/// merged by clap) > OS keychain > `password_cmd` from config.toml. Returns
|
||||
/// `None` when no source has one — the caller shows the login screen.
|
||||
/// Resolve the password with precedence: CLI/env > keychain > `password_cmd`.
|
||||
///
|
||||
/// `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
|
||||
/// a secret service the fallbacks must still work.
|
||||
|
||||
@@ -17,6 +17,7 @@ impl Snapshot {
|
||||
/// Extract what should survive a restart. Downloads that are not yet
|
||||
/// `Completed` (including failed/timed-out ones) are marked incomplete
|
||||
/// so the next start can re-enqueue them.
|
||||
#[must_use]
|
||||
pub fn capture(state: &AppState) -> Self {
|
||||
let downloads = state
|
||||
.downloads
|
||||
@@ -25,10 +26,7 @@ impl Snapshot {
|
||||
username: entry.download.username.clone(),
|
||||
filename: entry.download.filename.clone(),
|
||||
size: entry.download.size,
|
||||
download_directory: entry
|
||||
.download
|
||||
.download_directory
|
||||
.clone(),
|
||||
download_directory: entry.download.download_directory.clone(),
|
||||
completed: matches!(
|
||||
entry.download.status,
|
||||
DownloadStatus::Completed
|
||||
@@ -43,8 +41,12 @@ impl Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
let rooms =
|
||||
state.rooms.open.iter().map(|room| room.name.clone()).collect();
|
||||
let rooms = state
|
||||
.rooms
|
||||
.open
|
||||
.iter()
|
||||
.map(|room| room.name.clone())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
downloads,
|
||||
@@ -80,10 +82,7 @@ mod tests {
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Instant;
|
||||
|
||||
fn download(
|
||||
filename: &str,
|
||||
status: DownloadStatus,
|
||||
) -> DownloadEntry {
|
||||
fn download(filename: &str, status: DownloadStatus) -> DownloadEntry {
|
||||
let (sender, _receiver) = mpsc::channel();
|
||||
DownloadEntry {
|
||||
download: Download {
|
||||
@@ -114,8 +113,12 @@ mod tests {
|
||||
#[test]
|
||||
fn capture_maps_download_status_to_completed_flag() {
|
||||
let mut state = AppState::new();
|
||||
state.downloads.push(download("done.mp3", DownloadStatus::Completed));
|
||||
state.downloads.push(download("queued.mp3", DownloadStatus::Queued));
|
||||
state
|
||||
.downloads
|
||||
.push(download("done.mp3", DownloadStatus::Completed));
|
||||
state
|
||||
.downloads
|
||||
.push(download("queued.mp3", DownloadStatus::Queued));
|
||||
state.downloads.push(download(
|
||||
"failed.mp3",
|
||||
DownloadStatus::Failed(Some("nope".into())),
|
||||
|
||||
@@ -29,7 +29,8 @@ pub struct StateStore {
|
||||
}
|
||||
|
||||
impl StateStore {
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
#[must_use]
|
||||
pub const fn new(dir: PathBuf) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
@@ -198,9 +199,7 @@ mod tests {
|
||||
fn migrations_upgrade_old_data() {
|
||||
// Machinery test with a synthetic chain: v0 stored plain strings,
|
||||
// v1 wraps each in an object {"name": ...}.
|
||||
#[derive(
|
||||
Debug, PartialEq, Default, Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[derive(Debug, PartialEq, Default, Serialize, serde::Deserialize)]
|
||||
struct Named {
|
||||
name: String,
|
||||
}
|
||||
@@ -216,15 +215,11 @@ mod tests {
|
||||
}];
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("things.json");
|
||||
std::fs::write(&path, r#"{"version": 0, "data": ["a", "b"]}"#)
|
||||
.unwrap();
|
||||
std::fs::write(&path, r#"{"version": 0, "data": ["a", "b"]}"#).unwrap();
|
||||
let loaded: Vec<Named> = load(&path, chain);
|
||||
assert_eq!(
|
||||
loaded,
|
||||
vec![
|
||||
Named { name: "a".into() },
|
||||
Named { name: "b".into() }
|
||||
]
|
||||
vec![Named { name: "a".into() }, Named { name: "b".into() }]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user