#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{fs, path::PathBuf, process::Command, time::SystemTime};
struct ConfigDir(PathBuf);
impl ConfigDir {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let suffix = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let index = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"plavno-services-{}-{suffix}-{index}",
std::process::id()
));
fs::create_dir(&path).unwrap();
Self(path.canonicalize().unwrap())
}
fn command(&self) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_plavno"));
command
.env("XDG_CONFIG_HOME", &self.0)
.env_remove("YANDEX_MUSIC_TOKEN");
command
}
}
impl Drop for ConfigDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn version_is_available_without_an_account_or_terminal() {
let config = ConfigDir::new();
for flag in ["--version", "-V"] {
let output = config.command().arg(flag).output().unwrap();
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("plavno {}\n", env!("CARGO_PKG_VERSION"))
);
assert!(output.stderr.is_empty());
}
}
#[test]
fn radio_override_enables_public_sources_without_creating_or_changing_profiles() {
let config = ConfigDir::new();
for source in ["ytmusic", "soundcloud"] {
let run = || {
let output = config
.command()
.args(["--source", source, "--seed", "session-track"])
.output()
.unwrap();
let error = String::from_utf8(output.stderr).unwrap();
assert!(error.contains("interactive terminal"), "{error}");
};
run();
let directory = config.0.join("plavno");
assert!(!directory.exists());
plavno_platform::private_directory(&directory).unwrap();
let profile = directory.join(format!("{source}.json"));
let saved = br#"{"seed":"saved-track","token":"fixture-token"}"#;
use std::io::Write;
plavno_platform::private_options()
.write(true)
.create_new(true)
.open(&profile)
.unwrap()
.write_all(saved)
.unwrap();
run();
assert_eq!(fs::read(&profile).unwrap(), saved);
fs::remove_dir_all(directory).unwrap();
}
let output = config
.command()
.args(["--source", "spotify", "--seed", "spotify:track:ID"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&output.stderr).contains("not connected"));
}
#[test]
fn radio_override_rejects_missing_values_ambiguous_sources_and_conflicting_modes() {
let config = ConfigDir::new();
for args in [
vec!["--seed"],
vec!["--source", "ytmusic", "--seed", ""],
vec!["--source", "ytmusic", "--seed", "--no-wave"],
vec!["--seed", "track"],
vec!["--source", "mixed", "--seed", "track"],
vec!["--source", "yandex", "--seed", "track"],
vec!["--source", "local", "--seed", "track"],
vec!["--source", "vk", "--seed", "track"],
vec!["--source", "ytmusic", "--seed", "track", "--favorites"],
vec![
"--source",
"ytmusic",
"--seed",
"track",
"--favorites-newest",
],
vec![
"--source",
"ytmusic",
"--seed",
"track",
"--file",
"track.mp3",
],
] {
let output = config.command().args(&args).output().unwrap();
assert!(!output.status.success(), "{args:?}");
let error = String::from_utf8(output.stderr).unwrap();
assert!(error.contains("--seed"), "{args:?}: {error}");
assert!(!error.contains("interactive terminal"), "{args:?}: {error}");
}
assert!(!config.0.join("plavno").exists());
}
#[test]
fn sources_lists_yandex_with_and_without_a_connected_account() {
let config = ConfigDir::new();
for connected in [false, true] {
let mut command = Command::new(env!("CARGO_BIN_EXE_plavno"));
command
.arg("sources")
.env("XDG_CONFIG_HOME", &config.0)
.env_remove("YANDEX_MUSIC_TOKEN");
if connected {
command.env("YANDEX_MUSIC_TOKEN", "fixture-token");
}
let output = command.output().unwrap();
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
let keys: Vec<_> = text
.lines()
.map(|line| line.split_whitespace().next().unwrap())
.collect();
assert_eq!(
keys,
[
"yandex",
"soundcloud",
"spotify",
"vk",
"ytmusic",
"local",
"mixed"
]
);
let yandex = text.lines().next().unwrap();
assert!(yandex.contains("Yandex Music"));
assert!(yandex.contains(if connected {
"Ready"
} else {
"Connect with: plavno login yandex"
}));
}
}
#[test]
fn local_directory_is_saved_privately_listed_and_can_be_overridden_without_changing_settings() {
let config = ConfigDir::new();
let music = config.0.join("music with spaces");
let other = config.0.join("other");
fs::create_dir(&music).unwrap();
fs::create_dir(&other).unwrap();
let output = config.command().arg("local").arg(&music).output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let settings = config.0.join("plavno/local.json");
let saved = fs::read(&settings).unwrap();
#[cfg(unix)]
assert_eq!(
fs::metadata(&settings).unwrap().permissions().mode() & 0o777,
0o600
);
let value: serde_json::Value = serde_json::from_slice(&saved).unwrap();
assert_eq!(
value["directory"].as_str().unwrap(),
music.to_str().unwrap()
);
let sources = config.command().arg("sources").output().unwrap();
assert!(sources.status.success());
let sources = String::from_utf8(sources.stdout).unwrap();
assert!(sources.lines().any(|line| line.starts_with("local ")
&& line.contains(music.to_str().unwrap())
&& line.contains("recursive shuffle")));
for source in ["local", "mixed"] {
let output = config
.command()
.args(["--source", source, "--directory"])
.arg(&other)
.output()
.unwrap();
assert!(!output.status.success());
let error = String::from_utf8(output.stderr).unwrap();
if source == "local" {
assert!(error.contains("interactive terminal"), "{error}");
} else {
assert!(error.contains("not connected"), "{error}");
}
assert_eq!(fs::read(&settings).unwrap(), saved);
}
let output = config
.command()
.arg("--directory")
.arg(&other)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&output.stderr).contains("interactive terminal"));
fs::remove_dir(&music).unwrap();
let output = config.command().arg("sources").output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("Directory unavailable"));
}
#[test]
fn invalid_local_configuration_and_arguments_do_not_break_other_sources_or_overwrite_settings() {
let config = ConfigDir::new();
assert!(
!config
.command()
.arg("local")
.arg(config.0.join("missing"))
.output()
.unwrap()
.status
.success()
);
assert!(!config.0.join("plavno/local.json").exists());
assert!(
!config
.command()
.arg("--directory")
.output()
.unwrap()
.status
.success()
);
let output = config
.command()
.args(["--file", "track.flac", "--directory", "."])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be combined"));
config
.command()
.arg("local")
.arg(&config.0)
.output()
.unwrap();
fs::write(config.0.join("plavno/local.json"), b"{}").unwrap();
let output = config
.command()
.arg("sources")
.env("YANDEX_MUSIC_TOKEN", "fixture-token")
.output()
.unwrap();
assert!(output.status.success());
let sources = String::from_utf8(output.stdout).unwrap();
assert!(sources.contains("Yandex Music Ready"));
assert!(sources.contains("Invalid local directory settings"));
}
#[test]
fn newest_favorites_flag_is_accepted_and_conflicts_with_local_files() {
let config = ConfigDir::new();
let output = config
.command()
.env("YANDEX_MUSIC_TOKEN", "fixture-token")
.arg("--favorites-newest")
.output()
.unwrap();
let error = String::from_utf8(output.stderr).unwrap();
assert!(error.contains("interactive terminal"), "{error}");
let output = config
.command()
.args(["--favorites-newest", "--file", "track.mp3"])
.output()
.unwrap();
let error = String::from_utf8(output.stderr).unwrap();
assert!(
error.contains("--file cannot be combined") && error.contains("--favorites-newest"),
"{error}"
);
}