use crate::testing_prelude::*;
use di::ServiceCollection;
#[test]
fn batch_options_default_values() {
let result = BatchOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn file_options_default_values() {
let result = FileOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn target_options_default_values() {
let result = TargetOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn spectrogram_options_default_values() {
let result = SpectrogramOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn upload_options_default_values() {
let result = UploadOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn verify_options_default_values() {
let result = VerifyOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn cache_options_default_values() {
let resolved = CacheOptionsPartial::default().resolve_without_validation();
if is_docker() {
assert_eq!(resolved.cache, PathBuf::from("/cache"));
} else {
assert!(
resolved.cache.ends_with("caesura"),
"expected cache path to end with 'caesura', got: {:?}",
resolved.cache
);
}
}
#[test]
fn copy_options_default_values() {
let result = CopyOptionsPartial::default().resolve_without_validation();
assert_yaml_snapshot!(result);
}
#[test]
fn shared_options_calculates_indexer_from_red_announce_url() {
let resolved = SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc123/announce")),
..SharedOptionsPartial::default()
}
.resolve_without_validation();
assert_eq!(resolved.indexer, "red");
assert_eq!(resolved.indexer_url, RED_URL);
}
#[test]
fn shared_options_calculates_indexer_from_ops_announce_url() {
let resolved = SharedOptionsPartial {
announce_url: Some(format!("{OPS_TRACKER_URL}/abc123/announce")),
..SharedOptionsPartial::default()
}
.resolve_without_validation();
assert_eq!(resolved.indexer, "ops");
assert_eq!(resolved.indexer_url, OPS_URL);
}
#[test]
fn shared_options_does_not_override_explicit_indexer() {
let resolved = SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc123/announce")),
indexer: Some("custom".to_owned()),
..SharedOptionsPartial::default()
}
.resolve_without_validation();
assert_eq!(resolved.indexer, "custom");
assert_eq!(resolved.indexer_url, "");
}
#[test]
fn shared_options_does_not_override_explicit_indexer_url() {
let resolved = SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc123/announce")),
indexer_url: Some("https://custom.example.com".to_owned()),
..SharedOptionsPartial::default()
}
.resolve_without_validation();
assert_eq!(resolved.indexer, "red");
assert_eq!(resolved.indexer_url, "https://custom.example.com");
}
#[test]
fn shared_options_unknown_announce_url_leaves_indexer_empty() {
let resolved = SharedOptionsPartial {
announce_url: Some("https://unknown.tracker.com/announce".to_owned()),
..SharedOptionsPartial::default()
}
.resolve_without_validation();
assert_eq!(resolved.indexer, "");
assert_eq!(resolved.indexer_url, "");
}
#[test]
fn shared_options_explicit_empty_indexer_bypasses_required() {
let result = SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc/announce")),
api_key: Some("key".to_owned()),
indexer: Some(String::new()), content: Some(vec![PathBuf::from(".")]),
output: Some(PathBuf::from(".")),
..SharedOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should fail due to indexer_url");
assert!(
errors.iter().any(
|e| e.kind == OptionIssueKind::Required && e.keys == vec!["indexer_url".to_owned()]
)
);
}
#[test]
fn batch_options_rejects_upload_without_transcode() {
let result = BatchOptionsPartial {
upload: Some(true),
transcode: None,
..BatchOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should reject upload without transcode");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::DependencyMissing)
);
}
#[test]
fn batch_options_accepts_upload_with_transcode() {
let result = BatchOptionsPartial {
upload: Some(true),
transcode: Some(true),
..BatchOptionsPartial::default()
}
.resolve();
assert!(result.is_ok());
}
#[test]
fn batch_options_rejects_invalid_wait_duration() {
let result = BatchOptionsPartial {
wait_before_upload: Some("invalid".to_owned()),
..BatchOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should reject invalid duration");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::DurationInvalid)
);
}
#[test]
fn batch_options_accepts_valid_wait_duration() {
let result = BatchOptionsPartial {
wait_before_upload: Some("5m30s".to_owned()),
..BatchOptionsPartial::default()
}
.resolve();
assert!(result.is_ok());
}
#[test]
fn target_options_rejects_empty_target_list() {
let result = TargetOptionsPartial {
target: Some(vec![]),
..TargetOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should reject empty target list");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::RequiredNonEmpty)
);
}
#[test]
fn spectrogram_options_rejects_empty_size_list() {
let result = SpectrogramOptionsPartial {
spectrogram_size: Some(vec![]),
}
.resolve();
let errors = result.expect_err("should reject empty size list");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::RequiredNonEmpty)
);
}
#[test]
fn queue_fetch_options_rejects_empty_categories_list() {
let result = QueueFetchOptionsPartial {
qbit_fetch_categories: Some(vec![]),
}
.resolve();
let errors = result.expect_err("should reject empty categories list");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::RequiredNonEmpty)
);
}
#[test]
fn batch_options_yaml_round_trip() {
let original = BatchOptionsPartial {
spectrogram: Some(true),
transcode: Some(true),
upload: Some(true),
limit: Some(5),
no_limit: Some(false),
wait_before_upload: Some("10m".to_owned()),
..BatchOptionsPartial::default()
};
let yaml = yaml_to_string(&original).expect("should serialize");
let parsed: BatchOptionsPartial = yaml_from_str(&yaml).expect("should deserialize");
assert_eq!(original.spectrogram, parsed.spectrogram);
assert_eq!(original.transcode, parsed.transcode);
assert_eq!(original.upload, parsed.upload);
assert_eq!(original.limit, parsed.limit);
assert_eq!(original.wait_before_upload, parsed.wait_before_upload);
}
#[test]
fn target_options_yaml_round_trip() {
let original = TargetOptionsPartial {
target: Some(vec![TargetFormat::Flac, TargetFormat::V0]),
allow_existing: Some(true),
allow_less_specific: Some(true),
sox_random_dither: Some(true),
exclude_vorbis_comments: Some(TargetOptions::default_exclude_vorbis_comments()),
};
let yaml = yaml_to_string(&original).expect("should serialize");
let parsed: TargetOptionsPartial = yaml_from_str(&yaml).expect("should deserialize");
assert_eq!(original.target, parsed.target);
assert_eq!(original.allow_existing, parsed.allow_existing);
assert_eq!(original.sox_random_dither, parsed.sox_random_dither);
assert_eq!(
original.exclude_vorbis_comments,
parsed.exclude_vorbis_comments
);
}
#[test]
fn shared_options_yaml_round_trip() {
let original = SharedOptionsPartial {
announce_url: Some("https://example.com/announce".to_owned()),
api_key: Some("secret_key".to_owned()),
indexer: Some("red".to_owned()),
indexer_url: Some(RED_URL.to_owned()),
content: Some(vec![PathBuf::from("/data/music")]),
output: Some(PathBuf::from("/data/output")),
verbosity: Some(Verbosity::Debug),
log_time: Some(TimeFormat::Elapsed),
};
let yaml = yaml_to_string(&original).expect("should serialize");
let parsed: SharedOptionsPartial = yaml_from_str(&yaml).expect("should deserialize");
assert_eq!(original.announce_url, parsed.announce_url);
assert_eq!(original.api_key, parsed.api_key);
assert_eq!(original.indexer, parsed.indexer);
assert_eq!(original.content, parsed.content);
assert_eq!(original.verbosity, parsed.verbosity);
}
#[test]
fn batch_options_partial_merge_cli_overrides() {
let mut cli = BatchOptionsPartial {
limit: Some(10),
..BatchOptionsPartial::default()
};
let yaml = BatchOptionsPartial {
limit: Some(5),
spectrogram: Some(true),
transcode: Some(true),
..BatchOptionsPartial::default()
};
cli.merge(yaml);
assert_eq!(cli.limit, Some(10));
assert_eq!(cli.spectrogram, Some(true));
assert_eq!(cli.transcode, Some(true));
}
#[test]
fn batch_options_partial_merge_fills_none() {
let mut cli = BatchOptionsPartial::default();
let yaml = BatchOptionsPartial {
limit: Some(5),
spectrogram: Some(true),
..BatchOptionsPartial::default()
};
cli.merge(yaml);
assert_eq!(cli.limit, Some(5));
assert_eq!(cli.spectrogram, Some(true));
}
#[test]
fn shared_options_partial_merge_preserves_set() {
let mut cli = SharedOptionsPartial {
indexer: Some("custom".to_owned()),
verbosity: Some(Verbosity::Trace),
..SharedOptionsPartial::default()
};
let yaml = SharedOptionsPartial {
indexer: Some("red".to_owned()),
verbosity: Some(Verbosity::Info),
api_key: Some("from_yaml".to_owned()),
..SharedOptionsPartial::default()
};
cli.merge(yaml);
assert_eq!(cli.indexer, Some("custom".to_owned()));
assert_eq!(cli.verbosity, Some(Verbosity::Trace));
assert_eq!(cli.api_key, Some("from_yaml".to_owned()));
}
#[test]
fn host_builder_with_options_overrides() {
use crate::hosting::HostBuilder;
let mut builder = HostBuilder::new();
let custom_output = PathBuf::from("/custom/output/path");
let host = builder
.with_options(SharedOptions {
output: custom_output.clone(),
..SharedOptions::default()
})
.expect_build();
let options = host.services.get_required::<SharedOptions>();
assert_eq!(options.output, custom_output);
}
#[test]
fn shared_options_validate_missing_fields() {
let result = SharedOptionsPartial {
content: Some(vec![PathBuf::from("./nonexistent-content")]),
output: Some(PathBuf::from("./nonexistent-output")),
..SharedOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should reject missing required fields");
assert_yaml_snapshot!(errors);
}
#[test]
fn shared_options_validate_invalid_urls() {
let result = SharedOptionsPartial {
api_key: Some("key".to_owned()),
indexer: Some("red".to_owned()),
indexer_url: Some("not-a-url".to_owned()),
announce_url: Some("https://example.com/announce/".to_owned()),
content: Some(vec![PathBuf::from(".")]),
output: Some(PathBuf::from(".")),
..SharedOptionsPartial::default()
}
.resolve();
let errors = result.expect_err("should reject invalid URLs");
assert_yaml_snapshot!(errors);
}
#[test]
fn shared_options_validate_no_errors_when_valid() {
let result = SharedOptionsPartial {
api_key: Some("key".to_owned()),
indexer: Some("red".to_owned()),
indexer_url: Some(RED_URL.to_owned()),
announce_url: Some(format!("{RED_TRACKER_URL}/abc/announce")),
content: Some(vec![PathBuf::from(".")]),
output: Some(PathBuf::from(".")),
..SharedOptionsPartial::default()
}
.resolve();
assert!(result.is_ok());
}
#[test]
fn queue_rm_args_rejects_invalid_hash() {
let result = QueueRemoveArgsPartial {
queue_rm_hash: Some("not-a-valid-hash".to_owned()),
}
.resolve();
let errors = result.expect_err("should reject invalid hash");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::HashInvalid)
);
}
#[test]
fn queue_rm_args_rejects_missing_hash() {
let result = QueueRemoveArgsPartial {
queue_rm_hash: None,
}
.resolve();
let errors = result.expect_err("should reject missing hash");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::HashInvalid)
);
}
#[test]
fn queue_rm_args_accepts_valid_hash() {
let result = QueueRemoveArgsPartial {
queue_rm_hash: Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_owned()),
}
.resolve();
assert!(result.is_ok());
}
#[test]
fn cache_options_rejects_dollar_home() {
let path = "$HOME/.cache/caesura";
let errors = CacheOptionsPartial {
cache: Some(PathBuf::from(path)),
}
.resolve()
.expect_err("should reject");
assert_eq!(
errors,
vec![OptionIssue::directory_not_found(
"cache",
&PathBuf::from(path)
)]
);
}
#[test]
fn cache_options_expands_tilde() {
let result = CacheOptionsPartial {
cache: Some(PathBuf::from("~")),
}
.resolve();
assert!(result.is_ok(), "~ should expand to home directory");
}
#[test]
fn shared_options_rejects_dollar_home_output() {
let path = "$HOME/.local/share/caesura/output";
let errors = valid_shared_options_with_output(path)
.resolve()
.expect_err("should reject");
assert_eq!(
errors,
vec![OptionIssue::directory_not_found(
"output",
&PathBuf::from(path)
)]
);
}
#[test]
fn shared_options_expands_tilde_output() {
let result = valid_shared_options_with_output("~").resolve();
assert!(result.is_ok(), "~ should expand to home directory");
}
#[test]
fn shared_options_rejects_dollar_home_content() {
let path = "$HOME/music";
let errors = valid_shared_options_with_content(path)
.resolve()
.expect_err("should reject");
assert_eq!(
errors,
vec![OptionIssue::directory_not_found(
"content",
&PathBuf::from(path)
)]
);
}
#[test]
fn shared_options_expands_tilde_content() {
let result = valid_shared_options_with_content("~").resolve();
assert!(result.is_ok(), "~ should expand to home directory");
}
#[test]
fn config_read_to_string_fails_on_tilde() {
assert!(read_to_string("~/.config/caesura/config.yml").is_err());
}
#[test]
fn config_read_to_string_fails_on_dollar_home() {
assert!(read_to_string("$HOME/.config/caesura/config.yml").is_err());
}
#[test]
fn config_options_rejects_dollar_home() {
let path = "$HOME/.config/caesura/config.yml";
let errors = ConfigOptionsPartial {
config: Some(PathBuf::from(path)),
}
.resolve()
.expect_err("should reject");
assert_eq!(
errors,
vec![OptionIssue::file_not_found("config", &PathBuf::from(path))]
);
}
#[test]
fn config_options_expands_tilde() {
let partial = ConfigOptionsPartial {
config: Some(PathBuf::from("~/.config/caesura/nonexistent.yml")),
};
let errors = partial.resolve().expect_err("file should not exist");
assert_eq!(errors.len(), 1);
let issue = errors.first().expect("should have one issue");
assert_eq!(issue.kind, OptionIssueKind::FileNotFound);
let path = issue
.additional
.iter()
.find_map(|(k, v)| (k == "path").then_some(v.as_str()))
.expect("should have path additional");
assert!(!path.starts_with('~'));
}
fn valid_shared_options_with_output(output: &str) -> SharedOptionsPartial {
SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc/announce")),
api_key: Some("key".to_owned()),
content: Some(vec![PathBuf::from(".")]),
output: Some(PathBuf::from(output)),
..SharedOptionsPartial::default()
}
}
fn valid_shared_options_with_content(content: &str) -> SharedOptionsPartial {
SharedOptionsPartial {
announce_url: Some(format!("{RED_TRACKER_URL}/abc/announce")),
api_key: Some("key".to_owned()),
content: Some(vec![PathBuf::from(content)]),
output: Some(PathBuf::from(".")),
..SharedOptionsPartial::default()
}
}
#[test]
fn inspect_arg_rejects_nonexistent_path() {
let result = InspectArgPartial {
inspect_path: Some(PathBuf::from("/nonexistent/path/that/does/not/exist")),
}
.resolve();
let errors = result.expect_err("should reject nonexistent path");
assert!(
errors
.iter()
.any(|e| e.kind == OptionIssueKind::DirectoryNotFound)
);
}
#[test]
fn qbit_options_validate_connection_missing_credentials() {
let options = QbitOptions {
qbit_url: Some("http://127.0.0.1:8080".to_owned()),
qbit_username: None,
qbit_password: None,
};
let mut validator = OptionsValidator::new();
options.validate_connection(&mut validator);
let errors = validator.into_issues();
assert_eq!(
errors,
vec![
OptionIssue::required("qbit_username"),
OptionIssue::required("qbit_password")
]
);
}
#[test]
fn qbit_options_validate_connection_qui_proxy_url() {
let options = QbitOptions {
qbit_url: Some("http://localhost:7476/proxy/abc123".to_owned()),
qbit_username: None,
qbit_password: None,
};
let mut validator = OptionsValidator::new();
options.validate_connection(&mut validator);
assert!(validator.into_issues().is_empty());
}
#[test]
fn qbit_options_trailing_slash() {
let mock = QbitOptions::mock();
let partial = QbitOptionsPartial {
qbit_url: Some("http://localhost:7476/proxy/abc123/".to_owned()),
qbit_username: mock.qbit_username,
qbit_password: mock.qbit_password,
};
let result = partial.resolve();
let errors = result.expect_err("should reject trailing slash");
assert!(errors.iter().any(|e| e.kind == OptionIssueKind::UrlInvalid));
}
#[test]
fn options_provider_register_invalid_yaml() {
let yaml = "qbit_url:\n - not\n - a\n - string\n".to_owned();
let mut provider = OptionsProvider::from_yaml(Some(yaml));
let mut services = ServiceCollection::new();
provider.register::<QbitOptionsPartial>(&mut services);
assert!(provider.has_errors());
let error = provider.errors.first().expect("should have an error");
assert!(
error.kind == OptionIssueKind::ConfigInvalid,
"Expected ConfigInvalid, got: {error}"
);
}
#[test]
fn options_provider_register_valid_yaml() {
let yaml = "qbit_url: http://127.0.0.1:8080\n".to_owned();
let mut provider = OptionsProvider::from_yaml(Some(yaml));
let mut services = ServiceCollection::new();
provider.register::<QbitOptionsPartial>(&mut services);
assert!(!provider.has_errors());
}
#[test]
fn options_provider_register_no_yaml() {
let mut provider = OptionsProvider::from_yaml(None);
let mut services = ServiceCollection::new();
provider.register::<QbitOptionsPartial>(&mut services);
assert!(!provider.has_errors());
}